hookehuyr

feat(productDetail): 新增商品详情页及相关组件

添加商品详情页面,包含图片轮播、基本信息展示、车辆评估、卖家信息等功能模块
引入支付组件payCard,实现订单支付流程
更新app.config.js添加新页面路由
优化多个页面的收藏图标统一使用Heart1组件
新增底部操作栏样式和深色模式适配
......@@ -8,10 +8,12 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
NavBar: typeof import('./src/components/navBar.vue')['default']
NutActionSheet: typeof import('@nutui/nutui-taro')['ActionSheet']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCol: typeof import('@nutui/nutui-taro')['Col']
NutConfigProvider: typeof import('@nutui/nutui-taro')['ConfigProvider']
NutDatePicker: typeof import('@nutui/nutui-taro')['DatePicker']
NutDialog: typeof import('@nutui/nutui-taro')['Dialog']
NutForm: typeof import('@nutui/nutui-taro')['Form']
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
NutImagePreview: typeof import('@nutui/nutui-taro')['ImagePreview']
......@@ -32,6 +34,7 @@ declare module 'vue' {
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
PayCard: typeof import('./src/components/payCard.vue')['default']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
......
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-02 22:18:10
* @LastEditTime: 2025-07-03 09:34:59
* @FilePath: /jgdl/src/app.config.js
* @Description: 文件描述
*/
......@@ -18,6 +18,7 @@ export default {
'pages/setAuthCar/index',
'pages/newCarList/index',
'pages/goodCarList/index',
'pages/productDetail/index',
'pages/auth/index',
],
subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
......
......@@ -13,3 +13,28 @@
font-style: normal !important;
font-weight: normal !important;
}
button {
margin: 0;
padding: 0;
background-color: inherit;
position: static;
}
button:after {
content: none;
}
button::after {
border: none;
}
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
padding: 24rpx 32rpx;
border-top: 1rpx solid #f3f4f6;
z-index: 100;
}
......
<!--
* @Date: 2023-12-20 14:11:11
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-03 11:43:29
* @FilePath: /jgdl/src/components/payCard.vue
* @Description: 文件描述
-->
<template>
<div class="pay-card">
<nut-action-sheet v-model:visible="props.visible" title="" @close="onClose">
<view style="padding: 2rem 1rem; text-align: center;">
<view style="font-size: 32rpx;">实付金额</view>
<view style="color: red; margin: 10rpx 0;"><text style="font-size: 50rpx;">¥</text><text style="font-size: 80rpx;">{{ price }}</text></view>
<view style="font-size: 28rpx; margin-bottom: 20rpx;">支付剩余时间 <text style="color: red;">{{ formatTime(remain_time) }}</text></view>
<nut-button block color="#fb923c" @tap="goToPay">立即支付</nut-button>
</view>
</nut-action-sheet>
</div>
</template>
<script setup>
import Taro from '@tarojs/taro'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { getCurrentPageUrl } from "@/utils/weapp";
import { payAPI, payCheckAPI, orderSuccessAPI } from '@/api/index'
/**
* 格式化时间
* @param {*} seconds
*/
function formatTime(seconds) {
const hours = Math.floor(seconds / 3600); // 计算小时数
const minutes = Math.floor((seconds % 3600) / 60); // 计算分钟数
const remainingSeconds = seconds % 60; // 计算剩余的秒数
const formattedHours = String(hours).padStart(2, "0"); // 格式化小时数,保证两位数
const formattedMinutes = String(minutes).padStart(2, "0"); // 格式化分钟数,保证两位数
const formattedSeconds = String(remainingSeconds).padStart(2, "0"); // 格式化剩余的秒数,保证两位数
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
}
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
data: {
type: Object,
default: {},
},
});
const emit = defineEmits(['close']);
const onClose = () => {
emit('close');
}
const id = ref('');
const price = ref('');
const remain_time = ref('');
let timeId = null;
watch(
() => props.visible,
(val) => {
if (val) {
id.value = props.data.id;
price.value = props.data.price;
remain_time.value = props.data.remain_time;
}
}
)
onMounted(() => {
// 进入页面后,开始倒计时
timeId = setInterval(() => {
remain_time.value ? remain_time.value -= 1 : 0;
if (remain_time.value === 0) { // 倒计时结束
clearInterval(timeId);
emit('close');
}
}, 1000);
})
onUnmounted(() => {
timeId && clearInterval(timeId);
})
const goToPay = async () => {
if (price.value > 0) { // 金额大于0
// 获取支付参数
const { code, data } = await payAPI({ order_id: id.value });
if (code) {
let pay = data;
// 触发微信支付操作
wx.requestPayment({
timeStamp: pay.timeStamp,
nonceStr: pay.nonceStr,
package: pay.package,
signType: pay.signType,
paySign: pay.paySign,
success: async (result) => {
emit('close'); // 关闭支付弹框
Taro.showToast({
title: '支付成功',
icon: 'success',
duration: 1000
});
// 支付成功后,调用检查接口
const pay_success = await payCheckAPI({ order_id: id.value });
if (pay_success.code) {
let current_page = getCurrentPageUrl();
if (current_page === 'pages/my/index') { // 我的页面打开
// 刷新当前页面
Taro.reLaunch({
url: '/pages/my/index?tab_index=5'
});
}
if (current_page === 'pages/detail/index') { // 订房确认页打开
// 跳转订单成功页
Taro.navigateTo({
url: '/pages/payInfo/index',
});
}
}
}
});
}
}
}
</script>
<style lang="less">
</style>
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-02 18:15:19
* @LastEditTime: 2025-07-03 12:40:46
* @FilePath: /jgdl/src/pages/authCar/index.vue
* @Description: 认证车源
-->
......@@ -61,8 +61,8 @@
</view>
<view class="flex-1 p-3 relative">
<view class="absolute top-3 right-4" @tap.stop="() => toggleFavorite(car.id)">
<Addfollow v-if="!favoriteIds.includes(car.id)" size="16" color="#9ca3af" />
<HeartFill v-else size="16" color="#ef4444" />
<Heart1 v-if="!favoriteIds.includes(car.id)" size="16" :color="'#9ca3af'" />
<HeartFill v-else size="16" :color="'#ef4444'" />
</view>
<text class="font-medium text-sm block">{{ car.name }}</text>
<text class="text-xs text-gray-600 mt-1 block">
......@@ -109,7 +109,7 @@
<script setup>
import Taro from '@tarojs/taro'
import { ref, computed, onMounted } from 'vue'
import { Check, RectRight, Addfollow, HeartFill } from '@nutui/icons-vue-taro'
import { Check, Addfollow, Follow, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import './index.less'
// Banner图片数据
......
......@@ -68,7 +68,7 @@
</view>
<view class="flex-1 p-3 relative">
<view class="absolute top-3 right-4" @tap.stop="() => toggleFavorite(car.id)">
<Addfollow v-if="!favoriteIds.includes(car.id)" size="16" color="#9ca3af" />
<Heart1 v-if="!favoriteIds.includes(car.id)" size="16" color="#9ca3af" />
<HeartFill v-else size="16" color="#ef4444" />
</view>
<text class="font-medium text-sm block">{{ car.name }}</text>
......@@ -125,7 +125,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Search2, Addfollow, HeartFill } from '@nutui/icons-vue-taro'
import { Search2, Addfollow, Follow, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import './index.less'
......
<!--
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-02 22:58:32
* @LastEditTime: 2025-07-03 12:43:02
* @FilePath: /jgdl/src/pages/index/index.vue
* @Description: 捡个电驴首页
-->
......@@ -77,7 +77,7 @@
<image :src="scooter.imageUrl" :alt="scooter.name" mode="aspectFill"
class="w-full h-36 object-cover rounded-lg" />
<view class="absolute top-4 right-4 p-1" @tap.stop="() => toggleFavorite(scooter.id)">
<Addfollow v-if="!favoriteIds.includes(scooter.id)" size="20" :color="'#ffffff'" />
<Heart1 v-if="!favoriteIds.includes(scooter.id)" size="20" :color="'#ffffff'" />
<HeartFill v-else size="20" :color="'#ef4444'" />
</view>
<view v-if="scooter.isVerified"
......@@ -131,7 +131,7 @@
</view>
<view class="flex-1 p-3 relative">
<view class="absolute top-2 right-2" @tap.stop="() => toggleFavorite(scooter.id)">
<Addfollow v-if="!favoriteIds.includes(scooter.id)" size="16" :color="'#ffffff'" />
<Heart1 v-if="!favoriteIds.includes(scooter.id)" size="16" :color="'#ffffff'" />
<HeartFill v-else size="16" :color="'#ef4444'" />
</view>
<text class="font-medium text-sm block">{{ scooter.name }}</text>
......@@ -162,7 +162,7 @@ import Taro from '@tarojs/taro'
import '@tarojs/taro/html5.css' //和 nutui组件居然有冲突?
import { ref, onMounted } from 'vue'
import { useDidShow, useReady } from '@tarojs/taro'
import { Clock, Star, RectRight, Addfollow, HeartFill, Check, Search2, Shop } from '@nutui/icons-vue-taro'
import { Clock, Star, RectRight, Addfollow, Follow, Check, Search2, Shop, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import "./index.less";
......@@ -280,9 +280,8 @@ const toggleFavorite = (scooterId) => {
* @param {Object} scooter - 电动车信息
*/
const onProductClick = (scooter) => {
Taro.showToast({
title: `查看${scooter.name}`,
icon: 'none'
Taro.navigateTo({
url: `/pages/productDetail/index?id=${scooter.id}`
})
}
......
......@@ -63,7 +63,7 @@
</view>
<view class="flex-1 p-3 relative">
<view class="absolute top-3 right-4" @tap.stop="() => toggleFavorite(car.id)">
<Addfollow v-if="!favoriteIds.includes(car.id)" size="16" color="#9ca3af" />
<Heart1 v-if="!favoriteIds.includes(car.id)" size="16" color="#9ca3af" />
<HeartFill v-else size="16" color="#ef4444" />
</view>
<text class="font-medium text-sm block">{{ car.name }}</text>
......@@ -114,7 +114,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Search2, Addfollow, HeartFill } from '@nutui/icons-vue-taro'
import { Search2, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import './index.less'
......@@ -234,12 +234,12 @@ const scrollStyle = computed(() => {
* @param {string} carId - 车辆ID
*/
const toggleFavorite = (carId) => {
const index = favoriteIds.value.indexOf(carId.toString())
const index = favoriteIds.value.indexOf(carId)
if (index > -1) {
favoriteIds.value.splice(index, 1)
showToast('取消收藏', 'success')
} else {
favoriteIds.value.push(carId.toString())
favoriteIds.value.push(carId)
showToast('收藏成功', 'success')
}
}
......
......@@ -45,7 +45,7 @@
</view>
<view class="flex-1 p-3 relative">
<view class="absolute top-3 right-4" @tap.stop="() => toggleFavorite(scooter.id)">
<Addfollow v-if="!favoriteIds.includes(scooter.id)" size="16" color="#9ca3af" />
<Heart1 v-if="!favoriteIds.includes(scooter.id)" size="16" color="#9ca3af" />
<HeartFill v-else size="16" color="#ef4444" />
</view>
<text class="font-medium text-sm block">{{ scooter.name }}</text>
......@@ -96,7 +96,7 @@
<image :src="scooter.imageUrl" :alt="scooter.name" mode="aspectFill"
class="w-full h-36 object-cover rounded-lg" />
<view class="absolute top-4 right-4 p-1" @tap.stop="() => toggleFavorite(scooter.id)">
<Addfollow v-if="!favoriteIds.includes(scooter.id)" size="20" color="#ffffff" />
<Heart1 v-if="!favoriteIds.includes(scooter.id)" size="20" color="#ffffff" />
<HeartFill v-else size="20" color="#ef4444" />
</view>
<view v-if="scooter.isVerified"
......@@ -132,7 +132,7 @@
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { Search2, RectRight, Check, Addfollow, HeartFill } from '@nutui/icons-vue-taro'
import { Search2, RectRight, Check, Heart1, HeartFill } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
// 响应式数据
......
/*
* @Date: 2025-07-03 09:34:12
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-03 09:34:43
* @FilePath: /jgdl/src/pages/productDetail/index.config.js
* @Description: 文件描述
*/
export default {
navigationBarTitleText: '商品详情',
usingComponents: {
},
}
// 商品详情页样式
.product-detail-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 120rpx;
}
// 图片轮播样式
.image-carousel {
.nut-swiper {
.nut-swiper-pagination {
bottom: 20rpx;
}
}
}
// 商品信息区域
.product-info {
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.flex-col {
flex-direction: column;
}
.space-x-4 > view:not(:first-child) {
margin-left: 1rem;
}
.bg-gray-50 {
background-color: #f9fafb;
}
.rounded-full {
border-radius: 50%;
}
.p-2 {
padding: 0.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-gray-500 {
color: #6b7280;
}
}
// 基本信息网格布局
.grid {
display: grid;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.gap-4 {
gap: 1rem;
}
// 间距工具类
.space-x-3 > view:not(:first-child) {
margin-left: 0.75rem;
}
.space-x-4 > view:not(:first-child) {
margin-left: 1rem;
}
.space-y-3 > view:not(:first-child) {
margin-top: 0.75rem;
}
// 富文本内容样式
.rich-content {
line-height: 1.6;
p {
margin-bottom: 12rpx;
}
ul {
margin: 12rpx 0;
padding-left: 20rpx;
li {
margin-bottom: 6rpx;
}
}
strong {
font-weight: bold;
}
span {
display: inline;
}
}
// 联系卖家弹框样式
.contact-modal {
.seller-card {
background-color: #f9fafb;
}
.contact-options {
.nut-button {
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
}
}
// 微信弹框样式
.nut-dialog {
.nut-dialog-content {
.text-center {
text-align: center;
}
.p-4 {
padding: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.font-medium {
font-weight: 500;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.block {
display: block;
}
}
}
// 底部按钮区域
.fixed {
position: fixed;
}
.bottom-0 {
bottom: 0;
}
.left-0 {
left: 0;
}
.right-0 {
right: 0;
}
.bg-white {
background-color: #ffffff;
}
.border-t {
border-top-width: 1px;
}
.border-gray-200 {
border-color: #e5e7eb;
}
.p-3 {
padding: 0.75rem;
}
.flex-1 {
flex: 1 1 0%;
}
// 响应式适配
@media (max-width: 768rpx) {
.product-detail-page {
padding-bottom: 140rpx;
}
.product-info {
.space-x-4 > view:not(:first-child) {
margin-left: 0.5rem;
}
.text-xs {
font-size: 0.7rem;
}
}
.grid-cols-2 {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
// 深色模式适配
@media (prefers-color-scheme: dark) {
.product-detail-page {
background-color: #1f2937;
color: #f9fafb;
}
.bg-white {
background-color: #374151;
}
.text-gray-500 {
color: #9ca3af;
}
.border-gray-200 {
border-color: #4b5563;
}
}
// 动画效果
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.product-info,
.basic-info,
.vehicle-condition,
.vehicle-description,
.seller-info {
animation: fadeInUp 0.6s ease-out;
}
// 卡片悬停效果
.product-info,
.basic-info,
.vehicle-condition,
.vehicle-description,
.seller-info {
transition: all 0.3s ease;
&:hover {
transform: translateY(-2rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
}
// 按钮样式增强
.nut-button {
transition: all 0.3s ease;
&:active {
transform: scale(0.98);
}
}
// 图标按钮样式
.product-info {
.bg-gray-50 {
transition: all 0.3s ease;
&:active {
background-color: #e5e7eb;
transform: scale(0.95);
}
}
}
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-03 12:45:36
* @FilePath: /jgdl/src/pages/productDetail/index.vue
* @Description: 商品详情页
-->
<template>
<view class="product-detail-page">
<!-- 图片轮播 -->
<view class="image-carousel">
<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.images" :key="index" style="height: 400rpx">
<image
:src="image"
:alt="product.name"
mode="aspectFill"
class="w-full h-full object-cover"
@error="onImageError"
@load="onImageLoad"
/>
</nut-swiper-item>
</nut-swiper>
</view>
<!-- 商品信息 -->
<view class="product-info bg-white p-4">
<text class="text-xl font-bold block mb-3">{{ product.name }}</text>
<view class="flex items-center justify-between">
<view class="flex items-center">
<text class="text-2xl text-orange-500 font-bold">
¥{{ product.price.toLocaleString() }}
</text>
<view class="ml-2 text-xs px-2 py-1 bg-orange-100 text-orange-600 rounded">
低于市场价{{ product.discountPercent }}%
</view>
</view>
<view class="flex space-x-4">
<button
open-type="share"
class="flex flex-col items-center"
>
<view style="height: 2.55rem;">
<Share size="18" color="#666" style="margin-bottom: 0.05rem;"/>
</view>
<text class="text-xs text-gray-500 mt-1">分享</text>
</button>
<view @tap="toggleFavorite" class="flex flex-col items-center ml-3">
<view class="p-2">
<HeartFill v-if="isFavorite" size="20" color="#ef4444" />
<Heart1 v-else size="20" color="#666" />
</view>
<text class="text-xs text-gray-500 mt-1">{{ isFavorite ? '已收藏' : '收藏' }}</text>
</view>
</view>
</view>
</view>
<!-- 基本信息 -->
<view class="basic-info bg-white mt-2 p-4">
<text class="text-lg font-medium mb-3 block">基本信息</text>
<view class="grid grid-cols-2 gap-4">
<view class="flex items-center">
<view class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center mr-2">
<text class="text-orange-500">📅</text>
</view>
<view>
<text class="text-xs text-gray-500 block">出厂年份</text>
<text class="text-sm block">{{ product.year }}</text>
</view>
</view>
<view class="flex items-center">
<view class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center mr-2">
<text class="text-orange-500">🔋</text>
</view>
<view>
<text class="text-xs text-gray-500 block">续航里程</text>
<text class="text-sm block">{{ product.range }}</text>
</view>
</view>
<view class="flex items-center">
<view class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center mr-2">
<text class="text-orange-500">🛣️</text>
</view>
<view>
<text class="text-xs text-gray-500 block">行驶里程</text>
<text class="text-sm block">{{ product.mileage }}</text>
</view>
</view>
<view class="flex items-center">
<view class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center mr-2">
<text class="text-orange-500">⚡</text>
</view>
<view>
<text class="text-xs text-gray-500 block">最高时速</text>
<text class="text-sm block">{{ product.maxSpeed }}</text>
</view>
</view>
</view>
</view>
<!-- 车辆评估 -->
<view class="vehicle-condition bg-white mt-2 p-4">
<text class="text-lg font-medium mb-3 block">车辆评估</text>
<view class="space-y-3">
<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>
</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>
</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>
</view>
</view>
</view>
</view>
<!-- 车辆描述 -->
<view class="vehicle-description bg-white mt-2 p-4">
<text class="text-lg font-medium mb-3 block">车辆描述</text>
<rich-text :nodes="product.richDescription" class="rich-content"></rich-text>
<image
:src="product.images[1]"
alt="车辆细节"
mode="aspectFill"
class="w-full h-40 object-cover rounded-lg mt-3"
@error="onImageError"
@load="onImageLoad"
/>
</view>
<!-- 卖家信息 -->
<view class="seller-info bg-white mt-2 p-4 mb-24">
<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" :alt="product.seller.name" 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" 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>
</view>
</view>
<view @tap="showWechatModal" class="text-green-500 font-medium flex items-center">
<text>加微信</text>
<Right size="16" />
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-actions">
<nut-row :gutter="10">
<nut-col :span="12">
<nut-button @click="handleContactSeller"
block
type="default"
shape="round"
style="border-color: #f97316; color: #f97316;"
>
联系卖家
</nut-button>
</nut-col>
<nut-col :span="12">
<nut-button
@click="handlePurchase"
block
type="primary"
shape="round"
style="background-color: #f97316; border-color: #f97316;"
>
我想购买
</nut-button>
</nut-col>
</nut-row>
</view>
<!-- 微信号弹框 -->
<nut-dialog
v-model:visible="showWechat"
title="卖家微信号"
:close-on-click-overlay="true"
>
<view class="text-center p-4">
<text class="text-lg font-medium block mb-2">{{ product.seller.wechat }}</text>
<text class="text-sm text-gray-500 block mb-4">长按复制微信号</text>
<nut-button
@click="copyWechat"
type="primary"
size="small"
style="background-color: #f97316; border-color: #f97316;"
>
复制微信号
</nut-button>
</view>
</nut-dialog>
<!-- 联系卖家弹框 -->
<nut-popup
v-model:visible="showContactModal"
position="bottom"
:style="{ height: '60%' }"
round
>
<view class="contact-modal p-4">
<view class="text-center mb-4">
<text class="text-lg font-medium">联系卖家</text>
</view>
<view class="seller-card bg-gray-50 rounded-lg p-3 mb-4">
<view class="flex items-center">
<image :src="product.seller.avatar" :alt="product.seller.name" 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>
</view>
</view>
</view>
<!-- 留言输入框 -->
<view class="message-input mb-4">
<nut-textarea
v-model="messageText"
placeholder="请输入您想对卖家说的话..."
:rows="4"
:max-length="200"
show-word-limit
class="w-full"
/>
</view>
<!-- 快捷标签 -->
<view class="quick-tags mb-4">
<text class="text-sm text-gray-600 block mb-2">快捷输入:</text>
<view class="flex flex-wrap gap-2">
<view
v-for="tag in quickTags"
:key="tag"
@click="addQuickTag(tag)"
class="quick-tag px-3 py-1 bg-orange-50 text-orange-600 rounded-full text-sm cursor-pointer"
>
{{ tag }}
</view>
</view>
</view>
<!-- 发送按钮 -->
<nut-button
@click="sendMessageToSeller"
block
type="primary"
shape="round"
style="background-color: #f97316; border-color: #f97316;"
:disabled="!messageText.trim()"
>
发送消息
</nut-button>
</view>
</nut-popup>
<!-- 支付组件 -->
<payCard :visible="show_pay" :data="payData" @close="onPayClose" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { Share, Heart1, HeartFill, Right } from '@nutui/icons-vue-taro'
import payCard from '@/components/payCard.vue'
import avatarImg from '@/assets/images/avatar.png'
// 分享功能
wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline']
})
// 响应式数据
const currentImageIndex = ref(0)
const isFavorite = ref(false)
const showWechat = ref(false)
const showContactModal = ref(false)
const show_pay = ref(false)
const messageText = ref('')
const payData = ref({
id: '',
price: 0,
remain_time: 0
})
// 快捷标签数据
const quickTags = ref([
'你好,我对这辆车很感兴趣',
'请问车况怎么样?',
'可以面谈吗?',
'价格还能商量吗?',
'什么时候方便看车?',
'还有其他配件吗?'
])
// 备用图片数组
const fallbackImages = ref([
avatarImg,
avatarImg,
avatarImg,
avatarImg,
avatarImg
])
const imageLoadErrors = ref(new Set())
// 模拟商品数据
const product = ref({
id: '5',
name: '雅迪 豪华版',
price: 3200,
discountPercent: 8,
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公里,车身几乎无划痕,配置齐全,包括原装充电器、车锁、后视镜等。因为毕业需要离开学校,所以忍痛出售,价格比市场价低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'
}
})
/**
* 轮播图切换事件
* @param {number} index - 当前图片索引
*/
const onSwiperChange = (index) => {
currentImageIndex.value = index
}
/**
* 切换收藏状态
*/
const toggleFavorite = () => {
isFavorite.value = !isFavorite.value
Taro.showToast({
title: isFavorite.value ? '已收藏' : '已取消收藏',
icon: 'none'
})
}
/**
* 显示微信号弹框
*/
const showWechatModal = () => {
showWechat.value = true
}
/**
* 复制微信号
*/
const copyWechat = () => {
Taro.setClipboardData({
data: product.value.seller.wechat,
success: () => {
Taro.showToast({
title: '微信号已复制',
icon: 'success'
})
showWechat.value = false
},
})
}
/**
* 联系卖家
*/
const handleContactSeller = () => {
showContactModal.value = true
}
/**
* 添加快捷标签到输入框
* @param {string} tag - 快捷标签文本
*/
const addQuickTag = (tag) => {
if (messageText.value.trim()) {
messageText.value += ' ' + tag
} else {
messageText.value = tag
}
}
/**
* 发送消息给卖家
*/
const sendMessageToSeller = () => {
if (!messageText.value.trim()) {
Taro.showToast({
title: '请输入留言内容',
icon: 'none'
})
return
}
Taro.showToast({
title: '消息发送成功',
icon: 'success'
})
// 清空输入框并关闭弹框
messageText.value = ''
showContactModal.value = false
// 这里可以调用API发送消息
// sendMessageAPI({
// sellerId: product.value.seller.id,
// message: messageText.value,
// productId: product.value.id
// })
}
/**
* 购买商品
*/
const handlePurchase = () => {
onPay({
id: product.value.id,
remain_time: 1800, // 30分钟
price: product.value.price
})
}
/**
* 发送订单支付信息到支付组件
* @param {Object} payInfo - 支付信息
* @param {string} payInfo.id - 订单ID
* @param {number} payInfo.remain_time - 剩余时间
* @param {number} payInfo.price - 价格
*/
const onPay = ({ id, remain_time, price }) => {
show_pay.value = true
payData.value.id = id
payData.value.price = price
payData.value.remain_time = remain_time
}
/**
* 关闭支付弹框
*/
const onPayClose = () => {
show_pay.value = false
}
/**
* 图片加载成功
*/
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
})
}
</script>
<style lang="less">
.product-detail-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 120rpx;
}
.image-carousel {
.nut-swiper {
.nut-swiper-pagination {
bottom: 20rpx;
}
}
}
.space-x-3 > view:not(:first-child) {
margin-left: 0.75rem;
}
.space-x-6 > view:not(:first-child) {
margin-left: 1.5rem;
}
.space-y-3 > view:not(:first-child) {
margin-top: 0.75rem;
}
.grid {
display: grid;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.gap-4 {
gap: 1rem;
}
.rich-content {
line-height: 1.6;
p {
margin-bottom: 12rpx;
}
ul {
margin: 12rpx 0;
padding-left: 20rpx;
li {
margin-bottom: 6rpx;
}
}
}
.contact-modal {
.seller-card {
background-color: #f9fafb;
}
.message-input {
.nut-textarea {
border-radius: 12rpx;
border: 1px solid #e5e7eb;
&:focus {
border-color: #f97316;
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
}
}
}
.quick-tags {
.quick-tag {
transition: all 0.2s ease;
border: 1px solid #fed7aa;
&:hover {
background-color: #f97316;
color: white;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
.gap-2 {
gap: 8rpx;
}
.flex-wrap {
flex-wrap: wrap;
}
}
</style>