hookehuyr

feat(发布页): 重构车辆图片上传组件并添加图片预览功能

- 将原有的通用上传组件替换为分类上传网格布局
- 为每种车辆照片类型添加独立的上传和删除功能
- 新增图片预览组件支持点击查看大图
- 优化表单样式和布局
- 移除不再使用的NutUploader组件声明
......@@ -12,6 +12,7 @@ declare module 'vue' {
NutConfigProvider: typeof import('@nutui/nutui-taro')['ConfigProvider']
NutForm: typeof import('@nutui/nutui-taro')['Form']
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
NutImagePreview: typeof import('@nutui/nutui-taro')['ImagePreview']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutMenu: typeof import('@nutui/nutui-taro')['Menu']
NutMenuItem: typeof import('@nutui/nutui-taro')['MenuItem']
......@@ -22,7 +23,6 @@ declare module 'vue' {
NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutUploader: typeof import('@nutui/nutui-taro')['Uploader']
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']
......
......@@ -2,11 +2,7 @@
<view class="sell-page">
<!-- 顶部导航 -->
<nut-config-provider :theme-vars="themeVars">
<nut-navbar
title="发布车源"
left-show
@on-click-back="goBack"
>
<nut-navbar title="发布车源" left-show @on-click-back="goBack">
<template #left-show>
<RectLeft />
</template>
......@@ -18,27 +14,74 @@
<!-- 车辆照片上传 -->
<view class="form-section">
<text class="section-title">添加车辆照片</text>
<nut-uploader
v-model:file-list="fileList"
:maximum="4"
:multiple="true"
:preview-type="'picture'"
@oversize="onOversize"
@delete="onDelete"
>
<nut-button type="success" size="small">
<template #icon>
<Plus />
</template>
上传文件
</nut-button>
</nut-uploader>
<view class="upload-tips">
<text class="tip-item">正面照</text>
<text class="tip-item">左侧照</text>
<text class="tip-item">右侧照</text>
<text class="tip-item">其他</text>
<view class="upload-grid">
<!-- 正面照 -->
<view class="upload-item">
<!-- 上传按钮 -->
<view v-if="!uploadedImages.front" class="upload-button" @click="triggerUpload('front')">
<Plus class="upload-icon" />
</view>
<!-- 图片预览 -->
<view v-else class="image-preview" @click="previewImage(uploadedImages.front)">
<image :src="uploadedImages.front" class="preview-image" mode="aspectFill" />
<view class="delete-btn" @click.stop="deleteImage('front')">
<Close class="delete-icon" />
</view>
</view>
<text class="upload-label">正面照</text>
</view>
<!-- 左侧照 -->
<view class="upload-item">
<!-- 上传按钮 -->
<view v-if="!uploadedImages.left" class="upload-button" @click="triggerUpload('left')">
<Plus class="upload-icon" />
</view>
<!-- 图片预览 -->
<view v-else class="image-preview" @click="previewImage(uploadedImages.left)">
<image :src="uploadedImages.left" class="preview-image" mode="aspectFill" />
<view class="delete-btn" @click.stop="deleteImage('left')">
<Close class="delete-icon" />
</view>
</view>
<text class="upload-label">左侧照</text>
</view>
<!-- 右侧照 -->
<view class="upload-item">
<!-- 上传按钮 -->
<view v-if="!uploadedImages.right" class="upload-button" @click="triggerUpload('right')">
<Plus class="upload-icon" />
</view>
<!-- 图片预览 -->
<view v-else class="image-preview" @click="previewImage(uploadedImages.right)">
<image :src="uploadedImages.right" class="preview-image" mode="aspectFill" />
<view class="delete-btn" @click.stop="deleteImage('right')">
<Close class="delete-icon" />
</view>
</view>
<text class="upload-label">右侧照</text>
</view>
<!-- 其他 -->
<view class="upload-item">
<!-- 上传按钮 -->
<view v-if="!uploadedImages.other" class="upload-button" @click="triggerUpload('other')">
<Plus class="upload-icon" />
</view>
<!-- 图片预览 -->
<view v-else class="image-preview" @click="previewImage(uploadedImages.other)">
<image :src="uploadedImages.other" class="preview-image" mode="aspectFill" />
<view class="delete-btn" @click.stop="deleteImage('other')">
<Close class="delete-icon" />
</view>
</view>
<text class="upload-label">其他</text>
</view>
</view>
<!-- 图片预览组件 -->
<nut-image-preview v-model:show="previewVisible" :images="previewImages" :init-no="previewIndex" />
</view>
<!-- 所在学校 -->
......@@ -59,12 +102,7 @@
<nut-form ref="formRef" :model-value="formData">
<view class="form-section">
<!-- 车型品牌 -->
<nut-form-item
label="车型品牌"
prop="brand"
required
:rules="[{ required: true, message: '请选择车型品牌' }]"
>
<nut-form-item label-position="top" label="车型品牌" prop="brand" required :rules="[{ required: true, message: '请选择车型品牌' }]">
<view class="form-item-content" @click="showBrandPicker">
<text class="form-value">{{ formData.brand || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -72,7 +110,7 @@
</nut-form-item>
<!-- 车辆型号 -->
<nut-form-item label="车辆型号" prop="model">
<nut-form-item label-position="top" label="车辆型号" prop="model">
<view class="form-item-content" @click="showModelPicker">
<text class="form-value">{{ formData.model || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -80,7 +118,7 @@
</nut-form-item>
<!-- 车辆出厂年份 -->
<nut-form-item label="车辆出厂年份" prop="year">
<nut-form-item label-position="top" label="车辆出厂年份" prop="year">
<view class="form-item-content" @click="showYearPicker">
<text class="form-value">{{ formData.year || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -88,12 +126,8 @@
</nut-form-item>
<!-- 新旧程度 -->
<nut-form-item
label="新旧程度"
prop="condition"
required
:rules="[{ required: true, message: '请选择新旧程度' }]"
>
<nut-form-item label-position="top" label="新旧程度" prop="condition" required
:rules="[{ required: true, message: '请选择新旧程度' }]">
<view class="form-item-content" @click="showConditionPicker">
<text class="form-value">{{ formData.condition || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -101,18 +135,9 @@
</nut-form-item>
<!-- 行驶里程 -->
<nut-form-item
label="行驶里程"
prop="mileage"
required
:rules="[{ required: true, message: '请输入行驶里程' }]"
>
<nut-input
v-model="formData.mileage"
placeholder="1200"
type="number"
input-align="right"
>
<nut-form-item label="行驶里程" prop="mileage" required
:rules="[{ required: true, message: '请输入行驶里程' }]">
<nut-input v-model="formData.mileage" placeholder="1200" type="number" input-align="right">
<template #right>
<text class="unit">公里</text>
</template>
......@@ -121,12 +146,7 @@
<!-- 续航里程 -->
<nut-form-item label="续航里程" prop="range">
<nut-input
v-model="formData.range"
placeholder="60"
type="number"
input-align="right"
>
<nut-input v-model="formData.range" placeholder="60" type="number" input-align="right">
<template #right>
<text class="unit">公里</text>
</template>
......@@ -135,12 +155,7 @@
<!-- 最高时速 -->
<nut-form-item label="最高时速" prop="maxSpeed">
<nut-input
v-model="formData.maxSpeed"
placeholder="25"
type="number"
input-align="right"
>
<nut-input v-model="formData.maxSpeed" placeholder="25" type="number" input-align="right">
<template #right>
<text class="unit">km/h</text>
</template>
......@@ -149,12 +164,8 @@
<!-- 电池容量 -->
<nut-form-item label="电池容量" prop="batteryCapacity">
<nut-input
v-model="formData.batteryCapacity"
placeholder="20"
type="number"
input-align="right"
>
<nut-input v-model="formData.batteryCapacity" placeholder="20" type="number"
input-align="right">
<template #right>
<text class="unit">Ah</text>
</template>
......@@ -162,7 +173,7 @@
</nut-form-item>
<!-- 电池损耗度 -->
<nut-form-item label="电池损耗度" prop="batteryWear">
<nut-form-item label-position="top" label="电池损耗度" prop="batteryWear">
<view class="form-item-content" @click="showBatteryWearPicker">
<text class="form-value">{{ formData.batteryWear || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -170,7 +181,7 @@
</nut-form-item>
<!-- 刹车磨损度 -->
<nut-form-item label="刹车磨损度" prop="brakeWear">
<nut-form-item label-position="top" label="刹车磨损度" prop="brakeWear">
<view class="form-item-content" @click="showBrakeWearPicker">
<text class="form-value">{{ formData.brakeWear || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -178,7 +189,7 @@
</nut-form-item>
<!-- 轮胎磨损度 -->
<nut-form-item label="轮胎磨损度" prop="tireWear">
<nut-form-item label-position="top" label="轮胎磨损度" prop="tireWear">
<view class="form-item-content" @click="showTireWearPicker">
<text class="form-value">{{ formData.tireWear || '请选择' }}</text>
<Right class="arrow-icon" />
......@@ -196,12 +207,7 @@
</view>
<view class="form-item-right">
<text class="price-symbol">¥</text>
<input
v-model="formData.sellingPrice"
placeholder="3,200"
type="text"
class="price-input"
/>
<input v-model="formData.sellingPrice" placeholder="3,200" type="text" class="price-input" />
</view>
</view>
......@@ -212,36 +218,22 @@
</view>
<view class="form-item-right">
<text class="market-price-symbol">¥</text>
<input
v-model="formData.marketPrice"
placeholder="6,500"
type="text"
class="market-price-input"
/>
<input v-model="formData.marketPrice" placeholder="6,500" type="text"
class="market-price-input" />
</view>
</view>
</view>
<!-- 车辆描述 -->
<view class="form-section">
<nut-textarea
v-model="formData.description"
placeholder="请描述车辆详情,如使用感受、车况特点等"
:max-length="200"
:rows="4"
show-word-limit
/>
<nut-textarea v-model="formData.description" placeholder="请描述车辆详情,如使用感受、车况特点等" :max-length="200"
:rows="4" show-word-limit />
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-actions">
<nut-button
color="#f97316"
size="large"
block
@click="onPublish"
>
<nut-button color="#f97316" size="large" block @click="onPublish">
确认发布
</nut-button>
</view>
......@@ -249,106 +241,83 @@
<!-- 选择器弹窗 -->
<!-- 学校选择 -->
<nut-popup v-model:visible="schoolPickerVisible" position="bottom">
<nut-picker
v-model="schoolValue"
:columns="schoolOptions"
title="选择学校"
@confirm="onSchoolConfirm"
@cancel="schoolPickerVisible = false"
/>
<nut-picker v-model="schoolValue" :columns="schoolOptions" title="选择学校" @confirm="onSchoolConfirm"
@cancel="schoolPickerVisible = false" />
</nut-popup>
<!-- 品牌选择 -->
<nut-popup v-model:visible="brandPickerVisible" position="bottom">
<nut-picker
v-model="brandValue"
:columns="brandOptions"
title="选择车型品牌"
@confirm="onBrandConfirm"
@cancel="brandPickerVisible = false"
/>
<nut-picker v-model="brandValue" :columns="brandOptions" title="选择车型品牌" @confirm="onBrandConfirm"
@cancel="brandPickerVisible = false" />
</nut-popup>
<!-- 型号选择 -->
<nut-popup v-model:visible="modelPickerVisible" position="bottom">
<nut-picker
v-model="modelValue"
:columns="modelOptions"
title="选择车辆型号"
@confirm="onModelConfirm"
@cancel="modelPickerVisible = false"
/>
<nut-picker v-model="modelValue" :columns="modelOptions" title="选择车辆型号" @confirm="onModelConfirm"
@cancel="modelPickerVisible = false" />
</nut-popup>
<!-- 年份选择 -->
<nut-popup v-model:visible="yearPickerVisible" position="bottom">
<nut-picker
v-model="yearValue"
:columns="yearOptions"
title="选择出厂年份"
@confirm="onYearConfirm"
@cancel="yearPickerVisible = false"
/>
<nut-picker v-model="yearValue" :columns="yearOptions" title="选择出厂年份" @confirm="onYearConfirm"
@cancel="yearPickerVisible = false" />
</nut-popup>
<!-- 新旧程度选择 -->
<nut-popup v-model:visible="conditionPickerVisible" position="bottom">
<nut-picker
v-model="conditionValue"
:columns="conditionOptions"
title="选择新旧程度"
@confirm="onConditionConfirm"
@cancel="conditionPickerVisible = false"
/>
<nut-picker v-model="conditionValue" :columns="conditionOptions" title="选择新旧程度"
@confirm="onConditionConfirm" @cancel="conditionPickerVisible = false" />
</nut-popup>
<!-- 电池损耗度选择 -->
<nut-popup v-model:visible="batteryWearPickerVisible" position="bottom">
<nut-picker
v-model="batteryWearValue"
:columns="wearLevelOptions"
title="选择电池损耗度"
@confirm="onBatteryWearConfirm"
@cancel="batteryWearPickerVisible = false"
/>
<nut-picker v-model="batteryWearValue" :columns="wearLevelOptions" title="选择电池损耗度"
@confirm="onBatteryWearConfirm" @cancel="batteryWearPickerVisible = false" />
</nut-popup>
<!-- 刹车磨损度选择 -->
<nut-popup v-model:visible="brakeWearPickerVisible" position="bottom">
<nut-picker
v-model="brakeWearValue"
:columns="wearLevelOptions"
title="选择刹车磨损度"
@confirm="onBrakeWearConfirm"
@cancel="brakeWearPickerVisible = false"
/>
<nut-picker v-model="brakeWearValue" :columns="wearLevelOptions" title="选择刹车磨损度"
@confirm="onBrakeWearConfirm" @cancel="brakeWearPickerVisible = false" />
</nut-popup>
<!-- 轮胎磨损度选择 -->
<nut-popup v-model:visible="tireWearPickerVisible" position="bottom">
<nut-picker
v-model="tireWearValue"
:columns="wearLevelOptions"
title="选择轮胎磨损度"
@confirm="onTireWearConfirm"
@cancel="tireWearPickerVisible = false"
/>
<nut-picker v-model="tireWearValue" :columns="wearLevelOptions" title="选择轮胎磨损度" @confirm="onTireWearConfirm"
@cancel="tireWearPickerVisible = false" />
</nut-popup>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { Plus, Right, Location, RectLeft } from '@nutui/icons-vue-taro'
import { Plus, Right, Location, RectLeft, Close } from '@nutui/icons-vue-taro'
import Taro from '@tarojs/taro'
// import BASE_URL from '@/utils/config';
const themeVars = ref({
// navbarBackground: '#FFA135',
// navbarColor: '#ffffff',
})
// 文件上传列表
const fileList = ref([])
// 文件上传相关
// const frontFileList = ref([]) // 正面照(保留用于兼容旧代码)
// const leftFileList = ref([]) // 左侧照(保留用于兼容旧代码)
// const rightFileList = ref([]) // 右侧照(保留用于兼容旧代码)
// const otherFileList = ref([]) // 其他照片(保留用于兼容旧代码)
// 已上传图片的URL
const uploadedImages = reactive({
front: '',
left: '',
right: '',
other: ''
})
// 图片预览相关
const previewVisible = ref(false)
const previewImages = ref([])
const previewIndex = ref(0)
// 表单数据
const formData = reactive({
......@@ -459,22 +428,132 @@ const goBack = () => {
}
/**
* 文件上传超出限制
* 触发图片上传
* @param {String} type - 图片类型 (front/left/right/other)
*/
const onOversize = () => {
const triggerUpload = (type) => {
Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: function (res) {
const tempFilePath = res.tempFilePaths[0]
uploadImage(tempFilePath, type)
},
fail: function () {
Taro.showToast({
title: '文件大小超出限制',
title: '选择图片失败',
icon: 'none'
})
}
})
}
/**
* 上传图片到服务器
* @param {String} filePath - 图片文件路径
* @param {String} type - 图片类型 (front/left/right/other)
*/
const uploadImage = (filePath, type) => {
// 显示上传中提示
Taro.showLoading({
title: '上传中',
mask: true
})
// 模拟上传成功(实际项目中替换为真实的上传逻辑)
setTimeout(() => {
Taro.hideLoading()
// 模拟服务器返回的图片URL
const mockImageUrl = filePath // 在实际项目中,这里应该是服务器返回的URL
// 更新对应类型的图片
uploadedImages[type] = mockImageUrl
Taro.showToast({
title: '上传成功',
icon: 'success'
})
}, 1500)
// 真实上传逻辑(注释掉的代码供参考)
/*
Taro.uploadFile({
url: BASE_URL + '/admin/?m=srv&a=upload',
filePath: filePath,
name: 'file',
header: {
'content-type': 'multipart/form-data'
},
success: function (res) {
try {
const upload_data = JSON.parse(res.data)
Taro.hideLoading()
if (res.statusCode === 200 && upload_data.data) {
// 上传成功,更新对应类型的图片
uploadedImages[type] = upload_data.data.src
Taro.showToast({
title: '上传成功',
icon: 'success'
})
} else {
throw new Error('上传失败')
}
} catch (error) {
Taro.hideLoading()
Taro.showToast({
icon: 'error',
title: '服务器错误,稍后重试!',
mask: true
})
}
},
fail: function () {
Taro.hideLoading()
Taro.showToast({
icon: 'error',
title: '上传失败,请重试',
mask: true
})
}
})
*/
}
/**
* 预览图片
* @param {String} imageUrl - 图片URL
*/
const previewImage = (imageUrl) => {
previewImages.value = [{ src: imageUrl }]
previewIndex.value = 0
previewVisible.value = true
}
/**
* 删除上传的文件
* 删除图片
* @param {String} type - 图片类型 (front/left/right/other)
*/
const onDelete = () => {
// 删除逻辑已由组件内部处理
const deleteImage = (type) => {
uploadedImages[type] = ''
Taro.showToast({
title: '删除成功',
icon: 'success'
})
}
// 保留旧的上传逻辑作为备用(已注释)
// const afterRead = (event, type) => { ... }
// 保留旧的删除逻辑作为备用(已注释)
// const onDeleteFront = (file, index) => { frontFileList.value.splice(index, 1) }
// const onDeleteLeft = (file, index) => { leftFileList.value.splice(index, 1) }
// const onDeleteRight = (file, index) => { rightFileList.value.splice(index, 1) }
// const onDeleteOther = (file, index) => { otherFileList.value.splice(index, 1) }
/**
* 显示学校选择器
*/
......@@ -611,6 +690,25 @@ const onPublish = () => {
Taro.showLoading({ title: '发布中...' })
// 收集所有上传的图片URL
const images = {
front: uploadedImages.front || '',
left: uploadedImages.left || '',
right: uploadedImages.right || '',
other: uploadedImages.other || ''
}
// 构建提交数据
const submitData = {
...formData,
images: images,
imageUrls: Object.values(images).filter(url => url) // 过滤空URL
}
// 发布车辆信息数据已准备完成
// TODO: 在此处调用实际的API接口提交数据
// console.log('提交数据:', submitData)
// 模拟发布请求
setTimeout(() => {
Taro.hideLoading()
......@@ -631,7 +729,9 @@ const onPublish = () => {
* @returns {boolean} 验证结果
*/
const validateForm = () => {
if (fileList.value.length === 0) {
// 检查是否至少上传了一张图片
const hasImages = uploadedImages.front || uploadedImages.left || uploadedImages.right || uploadedImages.other
if (!hasImages) {
Taro.showToast({ title: '请上传车辆图片', icon: 'none' })
return false
}
......@@ -669,6 +769,7 @@ const validateForm = () => {
.form-container {
padding: 0 32rpx;
margin-bottom: 2rem;
}
.form-section {
......@@ -686,17 +787,84 @@ const validateForm = () => {
display: block;
}
.upload-tips {
display: flex;
margin-top: 16rpx;
.upload-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32rpx;
margin-top: 24rpx;
}
.tip-item {
font-size: 24rpx;
.upload-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.upload-button {
width: 160rpx;
height: 160rpx;
border: 2rpx dashed #d1d5db;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9fafb;
transition: all 0.3s ease;
}
.upload-button:active {
background-color: #f3f4f6;
border-color: #f97316;
}
.upload-icon {
font-size: 48rpx;
color: #9ca3af;
}
.upload-label {
font-size: 24rpx;
color: #6b7280;
text-align: center;
}
.image-preview {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.delete-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 32rpx;
height: 32rpx;
background-color: #ef4444;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16rpx;
z-index: 10;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
}
.delete-icon {
font-size: 16rpx;
color: white;
}
.form-item {
display: flex;
justify-content: space-between;
......@@ -842,20 +1010,43 @@ const validateForm = () => {
}
:deep(.nut-uploader) {
margin-bottom: 16rpx;
margin-bottom: 0;
}
:deep(.nut-uploader__preview) {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24rpx;
margin-bottom: 24rpx;
margin-bottom: 0;
}
:deep(.nut-uploader__preview-img) {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
object-fit: cover;
}
:deep(.nut-uploader__upload) {
width: 160rpx;
height: 160rpx;
}
:deep(.nut-uploader__input) {
width: 100%;
height: 100%;
}
:deep(.nut-uploader__preview-delete) {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 32rpx;
height: 32rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20rpx;
}
:deep(.nut-picker__toolbar) {
......