forked from Qi/Oroqen-Vue
639 lines
15 KiB
Vue
639 lines
15 KiB
Vue
<template>
|
||
<div class="products">
|
||
<!-- 页面头部 -->
|
||
<section class="page-header">
|
||
<div class="container">
|
||
<div class="header-content">
|
||
<h1 class="page-title">特色产品</h1>
|
||
<p class="page-subtitle">精选鄂伦春族传统手工艺品与文创产品</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 产品筛选 -->
|
||
<section class="filters-section">
|
||
<div class="container">
|
||
<div class="filters">
|
||
<div class="filter-group">
|
||
<label>产品分类:</label>
|
||
<div class="filter-buttons">
|
||
<button
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
:class="['filter-btn', { active: selectedCategory === category.id }]"
|
||
@click="selectedCategory = category.id; onCategoryChange()"
|
||
>
|
||
{{ category.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>价格范围:</label>
|
||
<el-select v-model="priceRange" placeholder="选择价格范围" style="width: 200px;">
|
||
<el-option label="全部价格" value="all" />
|
||
<el-option label="100元以下" value="0-100" />
|
||
<el-option label="100-300元" value="100-300" />
|
||
<el-option label="300-500元" value="300-500" />
|
||
<el-option label="500元以上" value="500+" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>排序方式:</label>
|
||
<el-select v-model="sortBy" placeholder="排序方式" style="width: 150px;">
|
||
<el-option label="默认排序" value="default" />
|
||
<el-option label="价格从低到高" value="price-asc" />
|
||
<el-option label="价格从高到低" value="price-desc" />
|
||
<el-option label="最新上架" value="newest" />
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 产品列表 -->
|
||
<section class="products-section section">
|
||
<div class="container">
|
||
<div class="products-grid">
|
||
<div
|
||
v-for="product in filteredProducts"
|
||
:key="product.id"
|
||
class="product-card card"
|
||
@click="$router.push(`/product/${product.id}`)"
|
||
>
|
||
<div class="product-image">
|
||
<img
|
||
v-if="product.mainImage && getFileAccessHttpUrl(product.mainImage)"
|
||
:src="getFileAccessHttpUrl(product.mainImage)"
|
||
:alt="product.name"
|
||
class="product-cover-image"
|
||
@error="handleImageError"
|
||
/>
|
||
<div v-else class="image-placeholder">
|
||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||
<polyline points="21,15 16,10 5,21"/>
|
||
</svg>
|
||
</div>
|
||
<div class="product-badges">
|
||
<span v-if="product.isNew" class="badge new">新品</span>
|
||
<span v-if="product.isHot" class="badge hot">热销</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="product-info">
|
||
<h3 class="product-name">{{ product.productName || '产品名称' }}</h3>
|
||
<p class="product-category">{{ getCategoryName(product.categoryId) }}</p>
|
||
<p class="product-description">{{ product.description || '暂无描述' }}</p>
|
||
|
||
<div class="product-meta">
|
||
<div class="craftsman-info" v-if="product.craftsmanInfo">
|
||
<el-icon><User /></el-icon>
|
||
<span>{{ product.craftsmanInfo }}</span>
|
||
</div>
|
||
<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="product-footer">
|
||
<div class="price-info">
|
||
<span class="current-price">¥{{ product.price }}</span>
|
||
<span v-if="product.originalPrice" class="original-price">¥{{ product.originalPrice }}</span>
|
||
</div>
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
:disabled="product.stock === 0"
|
||
@click.stop="addToCart(product)"
|
||
>
|
||
{{ product.stock > 0 ? '加入购物车' : '缺货' }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载更多 -->
|
||
<div class="load-more" v-if="hasMore">
|
||
<el-button @click="loadMore" :loading="loading">加载更多</el-button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { useMainStore } from '../stores'
|
||
import { User } from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { productApi, productCategoryApi } from '../api/product'
|
||
import { cartApi } from '../api/cart'
|
||
|
||
const route = useRoute()
|
||
const store = useMainStore()
|
||
|
||
// 响应式数据
|
||
const selectedCategory = ref('all')
|
||
const priceRange = ref('all')
|
||
const sortBy = ref('default')
|
||
const loading = ref(false)
|
||
const hasMore = ref(true)
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(12)
|
||
|
||
// 产品分类
|
||
const categories = ref<any[]>([
|
||
{ id: 'all', name: '全部产品' }
|
||
])
|
||
|
||
// 产品数据
|
||
const products = ref<any[]>([])
|
||
|
||
// 获取产品分类
|
||
const getCategories = async () => {
|
||
try {
|
||
const response = await productCategoryApi.getCategoryList({
|
||
pageNo: 1,
|
||
pageSize: 100
|
||
})
|
||
if (response && response.records) {
|
||
const categoryList = response.records.map((cat: any) => ({
|
||
id: cat.id,
|
||
name: cat.categoryName
|
||
}))
|
||
categories.value = [
|
||
{ id: 'all', name: '全部产品' },
|
||
...categoryList
|
||
]
|
||
}
|
||
} catch (error) {
|
||
console.error('获取分类失败:', error)
|
||
}
|
||
}
|
||
|
||
// 获取产品列表
|
||
const getProducts = async (loadMore = false) => {
|
||
try {
|
||
loading.value = true
|
||
const params: any = {
|
||
pageNo: loadMore ? currentPage.value + 1 : 1,
|
||
pageSize: pageSize.value,
|
||
status: 1 // 只获取上架的产品
|
||
}
|
||
|
||
// 添加分类筛选
|
||
if (selectedCategory.value !== 'all') {
|
||
params.categoryId = selectedCategory.value
|
||
}
|
||
|
||
const response = await productApi.getProductList(params)
|
||
|
||
if (response && response.records) {
|
||
const newProducts = response.records.map((product: any) => ({
|
||
...product,
|
||
color: getRandomColor(),
|
||
isNew: isNewProduct(product.createTime),
|
||
isHot: product.salesCount > 50 // 假设销量大于50为热销
|
||
}))
|
||
|
||
if (loadMore) {
|
||
products.value = [...products.value, ...newProducts]
|
||
currentPage.value++
|
||
} else {
|
||
products.value = newProducts
|
||
currentPage.value = 1
|
||
}
|
||
|
||
hasMore.value = response.current < response.pages
|
||
}
|
||
} catch (error) {
|
||
console.error('获取产品数据失败:', error)
|
||
ElMessage.error('获取产品数据失败,请稍后重试')
|
||
if (!loadMore) {
|
||
products.value = []
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 判断是否为新产品
|
||
const isNewProduct = (createTime: string) => {
|
||
if (!createTime) return false
|
||
const now = new Date()
|
||
const created = new Date(createTime)
|
||
const diffDays = (now.getTime() - created.getTime()) / (1000 * 3600 * 24)
|
||
return diffDays <= 30 // 30天内为新品
|
||
}
|
||
|
||
// 获取随机颜色
|
||
const getRandomColor = () => {
|
||
const colors = ['#8b4513', '#2d5016', '#8b4513', '#2d5016']
|
||
return colors[Math.floor(Math.random() * colors.length)]
|
||
}
|
||
|
||
// 计算属性
|
||
const filteredProducts = computed(() => {
|
||
let result = products.value
|
||
|
||
// 分类筛选
|
||
if (selectedCategory.value !== 'all') {
|
||
result = result.filter(p => p.categoryId === selectedCategory.value)
|
||
}
|
||
|
||
// 价格筛选
|
||
if (priceRange.value !== 'all') {
|
||
const [min, max] = priceRange.value.split('-').map(v => v === '+' ? Infinity : parseInt(v))
|
||
result = result.filter(p => {
|
||
if (max === undefined) return p.price >= min
|
||
return p.price >= min && p.price <= max
|
||
})
|
||
}
|
||
|
||
// 排序
|
||
switch (sortBy.value) {
|
||
case 'price-asc':
|
||
result.sort((a, b) => a.price - b.price)
|
||
break
|
||
case 'price-desc':
|
||
result.sort((a, b) => b.price - a.price)
|
||
break
|
||
case 'newest':
|
||
result.sort((a, b) => (b.isNew ? 1 : 0) - (a.isNew ? 1 : 0))
|
||
break
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
// 方法
|
||
const getCategoryName = (categoryId: string) => {
|
||
const category = categories.value.find(c => c.id === categoryId)
|
||
return category ? category.name : ''
|
||
}
|
||
|
||
const addToCart = async (product: any) => {
|
||
if (product.stock === 0) {
|
||
ElMessage.warning('商品暂时缺货')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 这里使用默认用户ID,实际应用中应该从用户状态获取
|
||
const userId = '1'
|
||
await cartApi.addToCart(userId, product.id, 1)
|
||
store.addToCart(product)
|
||
ElMessage.success('已添加到购物车')
|
||
} catch (error) {
|
||
console.error('添加到购物车失败:', error)
|
||
ElMessage.error('添加到购物车失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
const loadMore = () => {
|
||
getProducts(true)
|
||
}
|
||
|
||
// 监听分类变化
|
||
const onCategoryChange = () => {
|
||
getProducts()
|
||
}
|
||
|
||
// 处理图片路径
|
||
const getFileAccessHttpUrl = (path: string) => {
|
||
if (!path) return ''
|
||
|
||
// 如果已经是完整的HTTP URL或blob URL,直接返回
|
||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('blob:')) {
|
||
return path
|
||
}
|
||
|
||
// 如果是相对路径,转换为静态资源URL
|
||
if (path.startsWith('/')) {
|
||
return `http://localhost:8080/jeecg-boot/sys/common/static${path}`
|
||
}
|
||
|
||
// 为产品图片添加默认路径
|
||
return `http://localhost:8080/jeecg-boot/sys/common/static/${path}`
|
||
}
|
||
|
||
// 图片加载错误处理
|
||
const handleImageError = (event: Event) => {
|
||
const img = event.target as HTMLImageElement
|
||
img.style.display = 'none'
|
||
const placeholder = img.nextElementSibling as HTMLElement
|
||
if (placeholder) {
|
||
placeholder.style.display = 'flex'
|
||
}
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
getCategories()
|
||
getProducts()
|
||
|
||
// 处理搜索参数
|
||
const searchQuery = route.query.search as string
|
||
if (searchQuery) {
|
||
// 这里可以根据搜索关键词筛选产品
|
||
console.log('搜索关键词:', searchQuery)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 页面头部 */
|
||
.page-header {
|
||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||
color: white;
|
||
padding: 4rem 0 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 3rem;
|
||
font-weight: bold;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.page-subtitle {
|
||
font-size: 1.2rem;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* 筛选区域 */
|
||
.filters-section {
|
||
background: var(--background-light);
|
||
padding: 2rem 0;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 2rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.filter-group label {
|
||
font-weight: 500;
|
||
color: var(--text-color);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.filter-buttons {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 0.5rem 1rem;
|
||
border: 1px solid var(--border-color);
|
||
background: white;
|
||
color: var(--text-color);
|
||
border-radius: 20px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.filter-btn:hover,
|
||
.filter-btn.active {
|
||
background: var(--primary-color);
|
||
color: white;
|
||
border-color: var(--primary-color);
|
||
}
|
||
|
||
/* 产品网格 */
|
||
.products-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 2rem;
|
||
}
|
||
|
||
.product-card {
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.product-card:hover {
|
||
transform: translateY(-5px);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.product-image {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 250px;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.product-cover-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.product-cover-image:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.image-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
|
||
border: 2px dashed #dee2e6;
|
||
}
|
||
|
||
.image-placeholder svg {
|
||
opacity: 0.6;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.product-image:hover .image-placeholder svg {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.product-badges {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
}
|
||
|
||
.badge {
|
||
padding: 4px 8px;
|
||
border-radius: 12px;
|
||
font-size: 0.8rem;
|
||
font-weight: bold;
|
||
color: white;
|
||
}
|
||
|
||
.badge.new {
|
||
background: #ff4757;
|
||
}
|
||
|
||
.badge.hot {
|
||
background: #ff6b35;
|
||
}
|
||
|
||
.product-info {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.product-name {
|
||
font-size: 1.2rem;
|
||
font-weight: bold;
|
||
color: var(--primary-color);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.product-category {
|
||
color: var(--secondary-color);
|
||
font-size: 0.9rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.product-description {
|
||
color: var(--text-light);
|
||
font-size: 0.9rem;
|
||
line-height: 1.5;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.product-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.craftsman-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: var(--text-light);
|
||
}
|
||
|
||
.stock-status {
|
||
font-size: 0.8rem;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.product-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.price-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.current-price {
|
||
font-size: 1.3rem;
|
||
font-weight: bold;
|
||
color: var(--secondary-color);
|
||
}
|
||
|
||
.original-price {
|
||
font-size: 1rem;
|
||
color: var(--text-light);
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
/* 加载更多 */
|
||
.load-more {
|
||
text-align: center;
|
||
margin-top: 3rem;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.page-title {
|
||
font-size: 2rem;
|
||
}
|
||
|
||
.filters {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.filter-group {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.filter-buttons {
|
||
justify-content: center;
|
||
}
|
||
|
||
.products-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
.product-meta {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.product-footer {
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.products-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-btn {
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
}
|
||
</style> |