Oroqen-Vue/src/views/ProductDetail.vue

984 lines
24 KiB
Vue
Raw Normal View History

2025-08-07 21:29:04 +08:00
<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>
2025-08-10 20:42:49 +08:00
<span class="current">{{ product.productName || product.name || '产品详情' }}</span>
2025-08-07 21:29:04 +08:00
</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"
2025-08-10 20:42:49 +08:00
font-size="24" font-weight="bold">{{ (product.productName || product.name || '产品').substring(0, 2) }}</text>
2025-08-07 21:29:04 +08:00
</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>
2025-08-10 20:42:49 +08:00
<h1 class="product-title">{{ product.productName || product.name || '产品名称' }}</h1>
2025-08-07 21:29:04 +08:00
<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>
2025-08-10 20:42:49 +08:00
<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>
2025-08-07 21:29:04 +08:00
</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"
2025-08-10 20:42:49 +08:00
font-size="12" font-weight="bold">{{ (relatedProduct.productName || relatedProduct.name || '产品').substring(0, 2) }}</text>
2025-08-07 21:29:04 +08:00
</svg>
</div>
</div>
<div class="product-info">
2025-08-10 20:42:49 +08:00
<h3 class="product-name">{{ relatedProduct.productName || relatedProduct.name || '产品名称' }}</h3>
2025-08-07 21:29:04 +08:00
<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'
2025-08-10 20:42:49 +08:00
import { productApi } from '../api/product'
2025-08-07 21:29:04 +08:00
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: '最后整理修饰,确保产品完美呈现'
}
])
2025-08-10 20:42:49 +08:00
// 相关产品
const relatedProducts = ref<any[]>([])
2025-08-07 21:29:04 +08:00
// 用户评价
const reviews = ref([
{
id: 1,
userName: '文化爱好者',
rating: 5,
date: '2024-01-15',
2025-08-10 20:42:49 +08:00
content: '非常精美的工艺品,做工精细,很有民族特色,值得收藏!'
2025-08-07 21:29:04 +08:00
},
{
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')
}
// 生命周期
2025-08-10 20:42:49 +08:00
onMounted(async () => {
const productId = route.params.id as string
if (!productId) {
ElMessage.error('产品ID无效')
router.push('/products')
return
}
2025-08-07 21:29:04 +08:00
2025-08-10 20:42:49 +08:00
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)
2025-08-07 21:29:04 +08:00
} else {
ElMessage.error('产品不存在')
router.push('/products')
}
2025-08-10 20:42:49 +08:00
} catch (error) {
console.error('获取产品详情失败:', error)
// 检查是否是"未找到对应数据"的错误
if (error.message && error.message.includes('未找到对应数据')) {
ElMessage.error('产品不存在')
} else {
ElMessage.error('获取产品详情失败,请稍后重试')
}
router.push('/products')
}
2025-08-07 21:29:04 +08:00
})
2025-08-10 20:42:49 +08:00
// 获取相关产品
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)]
}
2025-08-07 21:29:04 +08:00
</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>