Oroqen-Vue/src/views/ProductDetail.vue
2025-08-10 20:42:49 +08:00

984 lines
24 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" v-if="product">
<!-- 面包屑导航 -->
<section class="breadcrumb-section">
<div class="container">
<div class="breadcrumb">
<router-link to="/">首页</router-link>
<span class="separator">/</span>
<router-link to="/products">产品中心</router-link>
<span class="separator">/</span>
<span class="current">{{ product.productName || product.name || '产品详情' }}</span>
</div>
</div>
</section>
<!-- 产品详情主体 -->
<section class="product-main section">
<div class="container">
<div class="product-layout">
<!-- 产品图片 -->
<div class="product-images">
<div class="main-image">
<div class="image-placeholder">
<svg viewBox="0 0 400 400" width="100%" height="400">
<rect width="400" height="400" :fill="product.color" opacity="0.2"/>
<circle cx="200" cy="200" r="80" :fill="product.color" opacity="0.5"/>
<text x="200" y="210" text-anchor="middle" :fill="product.color"
font-size="24" font-weight="bold">{{ (product.productName || product.name || '产品').substring(0, 2) }}</text>
</svg>
</div>
</div>
<div class="thumbnail-list">
<div v-for="i in 4" :key="i" class="thumbnail" :class="{ active: i === 1 }">
<svg viewBox="0 0 100 100" width="100%" height="80">
<rect width="100" height="100" :fill="product.color" opacity="0.3"/>
<text x="50" y="55" text-anchor="middle" :fill="product.color"
font-size="12" font-weight="bold">{{ i }}</text>
</svg>
</div>
</div>
</div>
<!-- 产品信息 -->
<div class="product-info">
<div class="product-badges">
<span v-if="product.isNew" class="badge new">新品</span>
<span v-if="product.isHot" class="badge hot">热销</span>
</div>
<h1 class="product-title">{{ product.productName || product.name || '产品名称' }}</h1>
<p class="product-subtitle">{{ getCategoryName(product.category) }}</p>
<div class="price-section">
<div class="current-price">¥{{ product.price }}</div>
<div v-if="product.originalPrice" class="original-price">原价:¥{{ product.originalPrice }}</div>
</div>
<div class="product-specs">
<div class="spec-item">
<label>材质:</label>
<span>{{ product.material || '天然桦皮' }}</span>
</div>
<div class="spec-item">
<label>尺寸:</label>
<span>{{ product.size || '长20cm × 宽15cm × 高8cm' }}</span>
</div>
<div class="spec-item">
<label>重量:</label>
<span>{{ product.weight || '约200g' }}</span>
</div>
<div class="spec-item">
<label>产地:</label>
<span>{{ product.origin || '黑龙江大兴安岭' }}</span>
</div>
</div>
<div class="craftsman-section" v-if="product.craftsman">
<h3>手工艺人</h3>
<div class="craftsman-info">
<div class="craftsman-avatar">
<el-icon size="40"><User /></el-icon>
</div>
<div class="craftsman-details">
<div class="craftsman-name">{{ product.craftsman }}</div>
<div class="craftsman-title">{{ product.craftsmanTitle || '桦皮工艺传承人' }}</div>
<div class="craftsman-desc">{{ product.craftsmanDesc || '从事桦皮工艺制作30余年作品精美技艺精湛' }}</div>
</div>
</div>
</div>
<div class="stock-section">
<div class="stock-info">
<span :class="['stock-status', product.stock > 0 ? 'in-stock' : 'out-stock']">
{{ product.stock > 0 ? `现货${product.stock}件` : '暂时缺货' }}
</span>
</div>
</div>
<div class="purchase-section">
<div class="quantity-selector">
<label>数量:</label>
<div class="quantity-controls">
<el-button size="small" @click="decreaseQuantity" :disabled="quantity <= 1">-</el-button>
<span class="quantity">{{ quantity }}</span>
<el-button size="small" @click="increaseQuantity" :disabled="quantity >= product.stock">+</el-button>
</div>
</div>
<div class="action-buttons">
<el-button
type="primary"
size="large"
:disabled="product.stock === 0"
@click="addToCart"
>
加入购物车
</el-button>
<el-button
type="danger"
size="large"
:disabled="product.stock === 0"
@click="buyNow"
>
立即购买
</el-button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 产品详细信息 -->
<section class="product-details section">
<div class="container">
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="产品详情" name="details">
<div class="detail-content">
<h3>产品介绍</h3>
<p>{{ product.fullDescription || product.description }}</p>
<h3>制作工艺</h3>
<div class="craft-process">
<div v-for="(step, index) in craftSteps" :key="index" class="craft-step">
<div class="step-number">{{ index + 1 }}</div>
<div class="step-content">
<h4>{{ step.title }}</h4>
<p>{{ step.description }}</p>
</div>
</div>
</div>
<h3>文化内涵</h3>
<p>{{ product.culturalMeaning || '桦皮工艺是鄂伦春族传统手工艺,承载着深厚的民族文化内涵,每一件作品都体现了匠人的精湛技艺和对传统文化的传承。' }}</p>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="specs-table">
<table>
<tbody>
<tr>
<td>产品名称</td>
<td>{{ product.productName || product.name || '产品名称' }}</td>
</tr>
<tr>
<td>产品分类</td>
<td>{{ getCategoryName(product.category) }}</td>
</tr>
<tr>
<td>材质</td>
<td>{{ product.material || '天然桦皮' }}</td>
</tr>
<tr>
<td>尺寸</td>
<td>{{ product.size || '长20cm × 宽15cm × 高8cm' }}</td>
</tr>
<tr>
<td>重量</td>
<td>{{ product.weight || '约200g' }}</td>
</tr>
<tr>
<td>产地</td>
<td>{{ product.origin || '黑龙江大兴安岭' }}</td>
</tr>
<tr>
<td>保养方式</td>
<td>避免阳光直射,保持干燥,定期清洁</td>
</tr>
</tbody>
</table>
</div>
</el-tab-pane>
<el-tab-pane label="用户评价" name="reviews">
<div class="reviews-section">
<div class="reviews-summary">
<div class="rating-overview">
<div class="rating-score">4.8</div>
<div class="rating-stars">
<el-icon v-for="i in 5" :key="i" color="#ffd700"><StarFilled /></el-icon>
</div>
<div class="rating-count">基于 {{ reviews.length }} 条评价</div>
</div>
</div>
<div class="reviews-list">
<div v-for="review in reviews" :key="review.id" class="review-item">
<div class="review-header">
<div class="reviewer-info">
<div class="reviewer-avatar">
<el-icon><User /></el-icon>
</div>
<div class="reviewer-details">
<div class="reviewer-name">{{ review.userName }}</div>
<div class="review-date">{{ review.date }}</div>
</div>
</div>
<div class="review-rating">
<el-icon v-for="i in review.rating" :key="i" color="#ffd700"><StarFilled /></el-icon>
</div>
</div>
<div class="review-content">
{{ review.content }}
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</section>
<!-- 相关推荐 -->
<section class="related-products section">
<div class="container">
<h2 class="section-title">相关推荐</h2>
<div class="products-grid">
<div
v-for="relatedProduct in relatedProducts"
:key="relatedProduct.id"
class="product-card card"
@click="$router.push(`/product/${relatedProduct.id}`)"
>
<div class="product-image">
<div class="image-placeholder">
<svg viewBox="0 0 200 150" width="100%" height="150">
<rect width="200" height="150" :fill="relatedProduct.color" opacity="0.2"/>
<circle cx="100" cy="75" r="30" :fill="relatedProduct.color" opacity="0.5"/>
<text x="100" y="80" text-anchor="middle" :fill="relatedProduct.color"
font-size="12" font-weight="bold">{{ (relatedProduct.productName || relatedProduct.name || '产品').substring(0, 2) }}</text>
</svg>
</div>
</div>
<div class="product-info">
<h3 class="product-name">{{ relatedProduct.productName || relatedProduct.name || '产品名称' }}</h3>
<div class="product-price">¥{{ relatedProduct.price }}</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div v-else class="loading">
<el-icon class="is-loading" size="50"><Loading /></el-icon>
<p>加载中...</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMainStore } from '../stores'
import { User, StarFilled, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { productApi } from '../api/product'
const route = useRoute()
const router = useRouter()
const store = useMainStore()
// 响应式数据
const product = ref<any>(null)
const quantity = ref(1)
const activeTab = ref('details')
// 产品分类
const categories = ref([
{ id: 'handicrafts', name: '手工艺品' },
{ id: 'food', name: '特色食品' },
{ id: 'cultural', name: '文创产品' },
{ id: 'clothing', name: '服饰配饰' }
])
// 制作工艺步骤
const craftSteps = ref([
{
title: '选材',
description: '选择优质的桦树皮,确保材质坚韧有韧性'
},
{
title: '处理',
description: '将桦皮进行清洁、软化处理,去除杂质'
},
{
title: '裁剪',
description: '根据设计图案精确裁剪桦皮材料'
},
{
title: '缝制',
description: '使用传统针法将各部分缝制组装'
},
{
title: '装饰',
description: '添加民族特色图案和装饰元素'
},
{
title: '整理',
description: '最后整理修饰,确保产品完美呈现'
}
])
// 相关产品
const relatedProducts = ref<any[]>([])
// 用户评价
const reviews = ref([
{
id: 1,
userName: '文化爱好者',
rating: 5,
date: '2024-01-15',
content: '非常精美的工艺品,做工精细,很有民族特色,值得收藏!'
},
{
id: 2,
userName: '传统工艺迷',
rating: 5,
date: '2024-01-10',
content: '质量很好,包装精美,能感受到浓厚的鄂伦春族文化气息。'
},
{
id: 3,
userName: '手工艺收藏家',
rating: 4,
date: '2024-01-05',
content: '工艺精湛,材质天然,是很好的文化艺术品。'
}
])
// 计算属性
const getCategoryName = (categoryId: string) => {
const category = categories.value.find(c => c.id === categoryId)
return category ? category.name : ''
}
// 方法
const increaseQuantity = () => {
if (quantity.value < product.value.stock) {
quantity.value++
}
}
const decreaseQuantity = () => {
if (quantity.value > 1) {
quantity.value--
}
}
const addToCart = () => {
if (product.value.stock === 0) {
ElMessage.warning('商品暂时缺货')
return
}
for (let i = 0; i < quantity.value; i++) {
store.addToCart(product.value)
}
ElMessage.success(`已添加 ${quantity.value} 件商品到购物车`)
}
const buyNow = () => {
if (product.value.stock === 0) {
ElMessage.warning('商品暂时缺货')
return
}
addToCart()
router.push('/cart')
}
// 生命周期
onMounted(async () => {
const productId = route.params.id as string
if (!productId) {
ElMessage.error('产品ID无效')
router.push('/products')
return
}
try {
// 从API获取产品数据
const response = await productApi.getProductById(productId)
if (response && response.id) {
product.value = {
...response,
color: getRandomColor(),
// 设置默认值
material: response.material || '天然材料',
size: response.size || '标准尺寸',
weight: response.weight || '约200g',
origin: response.origin || '黑龙江大兴安岭',
culturalMeaning: response.culturalMeaning || '承载着深厚的鄂伦春族文化内涵'
}
// 获取相关产品
await getRelatedProducts(response.categoryId)
} else {
ElMessage.error('产品不存在')
router.push('/products')
}
} catch (error) {
console.error('获取产品详情失败:', error)
// 检查是否是"未找到对应数据"的错误
if (error.message && error.message.includes('未找到对应数据')) {
ElMessage.error('产品不存在')
} else {
ElMessage.error('获取产品详情失败,请稍后重试')
}
router.push('/products')
}
})
// 获取相关产品
const getRelatedProducts = async (categoryId: string) => {
try {
const response = await productApi.getProductList({
pageNo: 1,
pageSize: 4,
categoryId: categoryId,
status: 1
})
if (response?.records && response.records.length > 0) {
relatedProducts.value = response.records
.filter((p: any) => p.id !== product.value.id)
.slice(0, 4)
.map((p: any) => ({
...p,
color: getRandomColor()
}))
} else {
relatedProducts.value = []
}
} catch (error) {
console.error('获取相关产品失败:', error)
relatedProducts.value = []
}
}
// 获取随机颜色
const getRandomColor = () => {
const colors = ['#8b4513', '#2d5016']
return colors[Math.floor(Math.random() * colors.length)]
}
</script>
<style scoped>
/* 面包屑导航 */
.breadcrumb-section {
background: var(--background-light);
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.breadcrumb a {
color: var(--primary-color);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.separator {
color: var(--text-light);
}
.current {
color: var(--text-color);
font-weight: 500;
}
/* 产品主体布局 */
.product-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: start;
}
/* 产品图片 */
.product-images {
position: sticky;
top: 2rem;
}
.main-image {
width: 100%;
margin-bottom: 1rem;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-light);
}
.thumbnail-list {
display: flex;
gap: 0.5rem;
}
.thumbnail {
width: 80px;
height: 80px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.3s ease;
}
.thumbnail.active,
.thumbnail:hover {
border-color: var(--primary-color);
}
/* 产品信息 */
.product-info {
padding: 1rem 0;
}
.product-badges {
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
color: white;
margin-right: 0.5rem;
}
.badge.new {
background: #ff4757;
}
.badge.hot {
background: #ff6b35;
}
.product-title {
font-size: 2rem;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.product-subtitle {
color: var(--secondary-color);
font-size: 1.1rem;
margin-bottom: 1.5rem;
}
.price-section {
margin-bottom: 2rem;
}
.current-price {
font-size: 2.5rem;
font-weight: bold;
color: var(--secondary-color);
}
.original-price {
font-size: 1.2rem;
color: var(--text-light);
text-decoration: line-through;
margin-left: 1rem;
}
.product-specs {
background: var(--background-light);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.spec-item {
display: flex;
margin-bottom: 0.5rem;
}
.spec-item:last-child {
margin-bottom: 0;
}
.spec-item label {
font-weight: 500;
color: var(--text-color);
width: 80px;
flex-shrink: 0;
}
.craftsman-section {
background: var(--background-light);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.craftsman-section h3 {
margin-bottom: 1rem;
color: var(--primary-color);
}
.craftsman-info {
display: flex;
gap: 1rem;
}
.craftsman-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.craftsman-name {
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.craftsman-title {
color: var(--secondary-color);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.craftsman-desc {
color: var(--text-light);
font-size: 0.9rem;
line-height: 1.5;
}
.stock-section {
margin-bottom: 2rem;
}
.stock-status {
font-size: 1rem;
padding: 8px 16px;
border-radius: 20px;
font-weight: 500;
}
.stock-status.in-stock {
background: rgba(46, 160, 67, 0.1);
color: #2ea043;
}
.stock-status.out-stock {
background: rgba(218, 54, 51, 0.1);
color: #da3633;
}
.purchase-section {
border-top: 1px solid var(--border-color);
padding-top: 2rem;
}
.quantity-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.quantity-selector label {
font-weight: 500;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.quantity {
font-size: 1.2rem;
font-weight: bold;
min-width: 2rem;
text-align: center;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.action-buttons .el-button {
flex: 1;
height: 50px;
font-size: 1.1rem;
}
/* 详细信息标签页 */
.detail-tabs {
margin-top: 3rem;
}
.detail-content h3 {
color: var(--primary-color);
margin: 2rem 0 1rem;
font-size: 1.3rem;
}
.detail-content h3:first-child {
margin-top: 0;
}
.detail-content p {
line-height: 1.8;
color: var(--text-color);
margin-bottom: 1rem;
}
.craft-process {
margin: 2rem 0;
}
.craft-step {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
align-items: flex-start;
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.step-content h4 {
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.step-content p {
color: var(--text-light);
margin: 0;
}
.specs-table table {
width: 100%;
border-collapse: collapse;
}
.specs-table td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.specs-table td:first-child {
font-weight: 500;
color: var(--text-color);
width: 150px;
}
.reviews-summary {
background: var(--background-light);
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
text-align: center;
}
.rating-score {
font-size: 3rem;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.rating-stars {
margin-bottom: 0.5rem;
}
.rating-count {
color: var(--text-light);
}
.review-item {
border-bottom: 1px solid var(--border-color);
padding: 1.5rem 0;
}
.review-item:last-child {
border-bottom: none;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.reviewer-info {
display: flex;
gap: 1rem;
align-items: center;
}
.reviewer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.reviewer-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.reviewer-date {
color: var(--text-light);
font-size: 0.9rem;
}
.review-content {
line-height: 1.6;
color: var(--text-color);
}
/* 相关推荐 */
.related-products {
background: var(--background-light);
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.product-card {
cursor: pointer;
transition: all 0.3s ease;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.product-card .product-image {
width: 100%;
height: 150px;
margin-bottom: 1rem;
}
.product-card .product-info {
padding: 1rem;
}
.product-card .product-name {
font-size: 1rem;
font-weight: 500;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.product-card .product-price {
font-size: 1.2rem;
font-weight: bold;
color: var(--secondary-color);
}
/* 加载状态 */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
color: var(--text-light);
}
.loading p {
margin-top: 1rem;
font-size: 1.1rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-layout {
grid-template-columns: 1fr;
gap: 2rem;
}
.product-images {
position: static;
}
.product-title {
font-size: 1.5rem;
}
.current-price {
font-size: 2rem;
}
.action-buttons {
flex-direction: column;
}
.craftsman-info {
flex-direction: column;
text-align: center;
}
.quantity-selector {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.quantity-controls {
justify-content: center;
}
.products-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>