hookehuyr

feat(商品详情): 重构商品详情页以适配后端数据接口

- 替换静态数据为动态接口获取
- 更新字段映射以匹配后端返回结构
- 添加数据加载和空值处理逻辑
- 使用nut-rate组件替代手动星级显示
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-10 17:47:49
* @LastEditTime: 2025-07-11 12:18:58
* @FilePath: /jgdl/src/pages/myCar/index.vue
* @Description: 文件描述
-->
......@@ -57,8 +57,8 @@
<nut-ellipsis direction="end" :content="car.note" :rows="2"></nut-ellipsis>
</view>
<view class="price-section">
<view class="current-price">¥{{ car.price }}</view>
<view class="market-price">市场价 ¥{{ car.market_price }}</view>
<view v-if="car.price" class="current-price">¥{{ car.price }}</view>
<view v-if="car.market_price" class="market-price">市场价 ¥{{ car.market_price }}</view>
</view>
</view>
</nut-col>
......
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-10 22:03:48
* @LastEditTime: 2025-07-11 14:56:02
* @FilePath: /jgdl/src/pages/productDetail/index.vue
* @Description: 商品详情页
-->
......@@ -12,8 +12,8 @@
<nut-swiper :init-page="currentImageIndex" :pagination-visible="true" pagination-color="#426543"
auto-play="3000" @change="onSwiperChange">
<nut-swiper-item v-for="(image, index) in product.cover_image" :key="index" style="height: 400rpx">
<image :src="image" :alt="product.name" mode="aspectFill" class="w-full h-full object-cover"
@error="onImageError" @load="onImageLoad" />
<image :src="image" mode="aspectFill" class="w-full h-full object-cover" @error="onImageError"
@load="onImageLoad" />
</nut-swiper-item>
</nut-swiper>
</view>
......@@ -21,7 +21,7 @@
<!-- 商品信息 -->
<view class="product-info bg-white p-4">
<view class="flex items-center justify-between mb-3">
<text class="text-xl font-bold">{{ product.name }}</text>
<text class="text-xl font-bold">{{ product.brand }} {{ product.model }}</text>
<view class="flex space-x-3">
<view class="w-8 h-8 bg-gray-100 rounded-full relative">
<button open-type="share" class="absolute inset-0 w-full h-full" style="border-radius: 50%;">
......@@ -39,11 +39,11 @@
</view>
<view class="flex items-center mb-3">
<text class="text-2xl font-bold text-orange-500 mr-2">
¥{{ product.price.toLocaleString() }}
¥{{ product?.price?.toLocaleString() }}
</text>
<view class="ml-2 text-xs px-2 py-1 bg-orange-100 text-orange-600 rounded">
<!-- <view class="ml-2 text-xs px-2 py-1 bg-orange-100 text-orange-600 rounded">
低于市场价{{ product.discountPercent }}%
</view>
</view> -->
</view>
</view>
......@@ -57,7 +57,7 @@
</view>
<view>
<text class="text-xs text-gray-500 block">出厂年份</text>
<text class="text-sm block">{{ product.year }}</text>
<text class="text-sm block">{{ product.manufacture_year }}</text>
</view>
</view>
<view class="flex items-center">
......@@ -66,7 +66,7 @@
</view>
<view>
<text class="text-xs text-gray-500 block">续航里程</text>
<text class="text-sm block">{{ product.range }}</text>
<text class="text-sm block">{{ product.range_km }}</text>
</view>
</view>
<view class="flex items-center">
......@@ -75,7 +75,7 @@
</view>
<view>
<text class="text-xs text-gray-500 block">行驶里程</text>
<text class="text-sm block">{{ product.mileage }}</text>
<text class="text-sm block">{{ product.total_mileage_km }}</text>
</view>
</view>
<view class="flex items-center">
......@@ -84,7 +84,7 @@
</view>
<view>
<text class="text-xs text-gray-500 block">最高时速</text>
<text class="text-sm block">{{ product.maxSpeed }}</text>
<text class="text-sm block">{{ product.max_speed_kmh }}</text>
</view>
</view>
</view>
......@@ -97,25 +97,19 @@
<view class="flex justify-between items-center">
<text>车辆成色</text>
<view class="flex">
<text v-for="star in 5" :key="star" class="text-orange-400">★</text>
<nut-rate v-model="product.new_level" active-color="orange" />
</view>
</view>
<view class="flex justify-between items-center">
<text>刹车磨损度</text>
<view class="flex">
<text v-for="star in 5" :key="star"
:class="star <= Math.round(product.brakeCondition) ? 'text-orange-400' : 'text-gray-300'">
</text>
<nut-rate v-model="product.brake_wear_level" active-color="orange" />
</view>
</view>
<view class="flex justify-between items-center">
<text>轮胎磨损度</text>
<view class="flex">
<text v-for="star in 5" :key="star"
:class="star <= product.tireCondition ? 'text-orange-400' : 'text-gray-300'">
</text>
<nut-rate v-model="product.tire_wear_level" active-color="orange" />
</view>
</view>
</view>
......@@ -124,27 +118,18 @@
<!-- 车辆描述 -->
<view class="vehicle-description bg-white mt-2 p-4">
<text class="text-lg font-medium mb-3 block">车辆描述</text>
<rich-text :nodes="product.description" class="rich-content"></rich-text>
<rich-text :nodes="product.note" class="rich-content"></rich-text>
<!-- <rich-text :nodes="product.richDescription" class="rich-content"></rich-text> -->
<!-- 多张图片展示 -->
<view class="vehicle-images mt-3">
<!-- <text class="text-base font-medium mb-2 block">车辆图片</text> -->
<view class="flex flex-col gap-3">
<view
v-for="(image, index) in product.images"
:key="index"
class="image-item"
@click="previewImages(index)"
>
<image
:src="image"
:alt="`车辆图片${index + 1}`"
mode="aspectFill"
class="w-full h-48 object-cover rounded-lg cursor-pointer"
@error="onImageError"
@load="onImageLoad"
/>
<view v-for="(image, index) in product.images" :key="index" class="image-item"
@click="previewImages(index)">
<image :src="image" :alt="`车辆图片${index + 1}`" mode="aspectFill"
class="w-full h-48 object-cover rounded-lg cursor-pointer" @error="onImageError"
@load="onImageLoad" />
</view>
</view>
</view>
......@@ -155,20 +140,20 @@
<text class="text-lg font-medium mb-3 block">卖家信息</text>
<view class="flex items-center justify-between">
<view class="flex items-center">
<image :src="product.seller.avatar || defaultAvatar" :alt="product.seller.name" mode="aspectFill"
<image :src="product.seller?.avatar_url || defaultAvatar" mode="aspectFill"
class="w-10 h-10 rounded-full object-cover mr-3" />
<view>
<view class="flex items-center">
<text class="font-medium">{{ product.seller.name }}</text>
<view v-if="product.seller.verified"
<text class="font-medium">{{ product.seller?.nickname }}</text>
<view v-if="product.seller?.real_name_verified"
class="ml-1 text-xs text-blue-500 bg-blue-50 px-1 py-0.5 rounded">
已认证卖家
</view>
</view>
<text class="text-xs text-gray-500 block">{{ product.seller.school }}</text>
<text class="text-xs text-gray-500 block">{{ product.seller?.school_name }}</text>
</view>
</view>
<view v-if="product.seller.wechat" @tap="showWechatModal" class="text-green-500 flex items-center">
<view v-if="product.seller?.wechat_id" @tap="showWechatModal" class="text-green-500 flex items-center">
<nut-button type="success">
<template #icon>
<Message size="16" />
......@@ -202,7 +187,7 @@
<!-- 微信号弹框 -->
<nut-dialog v-model:visible="showWechat" title="卖家微信号" :close-on-click-overlay="true" :no-footer="true">
<view class="text-center">
<text class="text-lg font-medium block mb-4">{{ product.seller.wechat }}</text>
<text class="text-lg font-medium block mb-4">{{ product.seller?.wechat_id }}</text>
<!-- <text class="text-sm text-gray-500 block mb-4">长按复制微信号</text> -->
<nut-button @click="copyWechat" type="primary" color="orange">
复制微信号
......@@ -219,11 +204,11 @@
</view>
<view class="seller-card bg-gray-50 rounded-lg p-3 mb-4">
<view class="flex items-center">
<image :src="product.seller.avatar || defaultAvatar" :alt="product.seller.name"
<image :src="product.seller?.avatar || defaultAvatar" :alt="product.seller?.nickname"
mode="aspectFill" class="w-12 h-12 rounded-full object-cover mr-3" />
<view>
<text class="font-medium block">{{ product.seller.name }}</text>
<text class="text-sm text-gray-500 block">{{ product.seller.school }}</text>
<text class="font-medium block">{{ product.seller?.nickname }}</text>
<text class="text-sm text-gray-500 block">{{ product.seller?.school_name }}</text>
</view>
</view>
</view>
......@@ -260,7 +245,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import Taro, { useShareAppMessage } from '@tarojs/taro'
import { Share, Heart1, HeartFill, Message } from '@nutui/icons-vue-taro'
import payCard from '@/components/payCard.vue'
......@@ -268,6 +253,9 @@ import { useFavorite } from '@/composables/useFavorite'
import avatarImg from '@/assets/images/avatar.png'
import { getCurrentPageParam } from "@/utils/weapp"
import { checkPermission, PERMISSION_TYPES } from '@/utils/permission'
// 导入接口
import { getVehicleDetailAPI } from '@/api/car'
import { DEFAULT_COVER_IMG } from '@/utils/config'
// 默认头像
const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
......@@ -310,61 +298,15 @@ const fallbackImages = ref([
const imageLoadErrors = ref(new Set())
// 模拟商品数据
const product = ref({
id: '5',
name: '雅迪 豪华版',
price: 3200,
discountPercent: 8,
cover_image: [
'https://images.unsplash.com/photo-1558981806-ec527fa84c39?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
],
images: [
'https://images.unsplash.com/photo-1558981806-ec527fa84c39?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1558981285-6f0c94958bb6?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60',
'https://images.unsplash.com/photo-1558981403-c5f9899a28bc?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=60'
],
year: '2023年',
range: '60km',
mileage: '1200公里',
maxSpeed: '25km/h',
batteryHealth: 98,
brakeCondition: 4.5,
tireCondition: 4,
bodyCondition: 5,
description: '这辆雅迪豪华版电动车是我去年购买的,一直很爱惜。电池健康度保持在98%,行驶里程仅1200公里,车身几乎无划痕,配置齐全,包括原装充电器、车锁、后视镜等。\n因为毕业需要离开学校,所以忍痛出售,价格比市场价低8%,非常划算。',
richDescription: `
<div style="line-height: 1.6; color: #333;">
<p style="margin-bottom: 12px;">这辆<strong style="color: #f97316;">雅迪豪华版电动车</strong>是我去年购买的,一直很爱惜。</p>
<p style="margin-bottom: 12px;">🔋 电池健康度保持在<span style="color: #10b981; font-weight: bold;">98%</span></p>
<p style="margin-bottom: 12px;">🛣️ 行驶里程仅<span style="color: #3b82f6; font-weight: bold;">1200公里</span></p>
<p style="margin-bottom: 12px;">✨ 车身几乎无划痕,配置齐全</p>
<ul style="margin: 12px 0; padding-left: 20px;">
<li style="margin-bottom: 6px;">✅ 原装充电器</li>
<li style="margin-bottom: 6px;">✅ 车锁</li>
<li style="margin-bottom: 6px;">✅ 后视镜</li>
<li style="margin-bottom: 6px;">✅ 脚踏板</li>
</ul>
<p style="margin-bottom: 12px; background: #fef3c7; padding: 8px; border-radius: 6px;">💰 因为毕业需要离开学校,所以忍痛出售,价格比市场价低8%,非常划算!</p>
</div>
`,
seller: {
name: '李同学',
verified: true,
school: '上海理工大学-本部',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
wechat: 'li_student_2023',
phone: '138****8888'
},
is_favorite: false
})
const product = ref({})
/**
* 轮播图切换事件
* @param {number} index - 当前图片索引
*/
const onSwiperChange = (index) => {
currentImageIndex.value = index
}
// const onSwiperChange = (index) => {
// currentImageIndex.value = index
// }
// 使用收藏功能composables
const { toggleFavorite } = useFavorite()
......@@ -381,7 +323,7 @@ const showWechatModal = () => {
*/
const copyWechat = () => {
Taro.setClipboardData({
data: product.value.seller.wechat,
data: product.value.seller?.wechat_id,
success: () => {
Taro.showToast({
title: '微信号已复制',
......@@ -479,39 +421,6 @@ const onPayClose = () => {
}
/**
* 图片加载成功
*/
const onImageLoad = () => {
// 图片加载成功
}
/**
* 图片加载失败处理
*/
const onImageError = (e) => {
const target = e.target || e.currentTarget
const src = target.src
// 记录加载失败的图片
imageLoadErrors.value.add(src)
// 如果不是备用图片,则替换为备用图片
if (!src.includes('avatar.png')) {
const imageIndex = product.value.images.findIndex(img => img === src)
if (imageIndex !== -1 && fallbackImages.value[imageIndex]) {
// 替换为备用图片
product.value.images[imageIndex] = fallbackImages.value[imageIndex]
}
}
Taro.showToast({
title: '图片加载失败,已使用备用图片',
icon: 'none',
duration: 2000
})
}
/**
* 预览图片
* @param {number} index - 当前图片索引
*/
......@@ -540,6 +449,37 @@ useShareAppMessage(() => {
// 返回shareObj
return shareObj;
})
// 过滤数组中的非空值(包括空字符串、null、undefined、空数组、空对象)
function filterEmptyValues(arr) {
return arr.filter(item => {
// 处理 null 和 undefined
if (item == null) return false;
// 处理空字符串
if (typeof item === 'string') return item.trim().length > 0;
// 处理数组
if (Array.isArray(item)) return item.length > 0;
// 处理对象
if (typeof item === 'object') return Object.keys(item).length > 0;
// 其他情况(数字、布尔值等)保留
return true;
});
}
onMounted(async () => {
// 获取商品详情
let params = getCurrentPageParam();
const { code, data } = await getVehicleDetailAPI({ id: params.id })
if (code) {
product.value = data
product.value.cover_image = [data.front_photo || DEFAULT_COVER_IMG]
product.value.images = filterEmptyValues([data.front_photo, data.other_photo, data.left_photo, data.right_photo])
}
})
</script>
<style lang="less">
......
......@@ -219,7 +219,7 @@
</view>
<view class="form-item-right">
<text class="price-symbol">¥</text>
<input v-model="formData.price" placeholder="3200" type="text" class="price-input" />
<input v-model="formData.price" placeholder="请输入" type="text" class="price-input" />
</view>
</view>
......@@ -230,7 +230,7 @@
</view>
<view class="form-item-right">
<text class="market-price-symbol">¥</text>
<input v-model="formData.market_price" placeholder="6500" type="text"
<input v-model="formData.market_price" placeholder="请输入" type="text"
class="market-price-input" />
</view>
</view>
......@@ -381,8 +381,8 @@ const formData = reactive({
// batteryWear: '',
brake_wear_level: '',
tire_wear_level: '',
price: '3200',
market_price: '6500',
price: '',
market_price: '',
note: '',
// 车辆照片字段
front_photo: '',
......