Files
used-car-h5/pages/index/publishedit.vue
T
2026-01-13 15:10:33 +08:00

847 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<!-- <uni-nav-bar leftText=" " @clickLeft="leftClick" leftIcon="back" :fixed="true" :border="false" color="#fff" backgroundColor="#3F435B" :statusBar="true" title="车辆编辑"></uni-nav-bar> -->
<view class="content">
<!-- 产品名称 + 绑定 -->
<view class="cell">
<view>产品名称 <text class="red">*</text></view>
<view class="right right-bind">
<input class="inp" placeholder="请输入产品名称" v-model="carinfo.product_name" />
<view class="bind-btn" @tap.stop="onBindByName">绑定</view>
</view>
</view>
<!-- 已绑定提示 -->
<view v-if="bindState.boundProduct" class="bind-tip">
<text class="bind-tip-text">已绑定:{{ bindState.boundProduct.name || bindState.boundProduct.product_name || bindState.boundProduct.title || '-' }}</text>
<text class="bind-tip-clear" @tap.stop="clearBind">清除</text>
</view>
<!-- (保持原字段:VIN/上牌地 如你不需要可继续注释) -->
<!--
<view class="cell">
<view>车辆识别码</view>
<view class="right">
<input placeholder="请输入车架号/VIN码" v-model="carinfo.vin" />
</view>
</view>
-->
<view class="cell">
<view>车价(¥)</view>
<view class="right">
<input placeholder="请输入车价" v-model="carinfo.price" />
</view>
</view>
<view class="cell">
<view>车源地</view>
<view class="right">
<input placeholder="请输入车源地" v-model="carinfo.source_place" />
</view>
</view>
<!--
<view class="cell">
<view>上牌地</view>
<view class="right">
<input placeholder="请输入上牌地" v-model="carinfo.plate_place" />
</view>
</view>
-->
<view class="cell">
<view>上牌时间</view>
<view class="right">
<uni-datetime-picker
ref="borrowTimePicker"
type="date"
v-model="carinfo.plate_time"
placeholder="请选择时间"
:border="false"
:clear-icon="false"
@change="onBorrowTimeChange"
>
<view class="custom-datetime-input">
<text class="custom-datetime-text" :class="{ 'placeholder': !carinfo.plate_time }">
{{ carinfo.plate_time || '请选择时间' }}
</text>
<uni-icons type="calendar" size="16" color="#999"></uni-icons>
</view>
</uni-datetime-picker>
</view>
</view>
<view class="cell">
<view>排量</view>
<view class="right">
<input placeholder="请输入排量" v-model="carinfo.displacement" />
</view>
</view>
<view class="cell">
<view>马力</view>
<view class="right">
<input placeholder="请输入马力" v-model="carinfo.power" />
</view>
</view>
<view class="cell">
<view>里程</view>
<view class="right">
<input placeholder="请输入里程" v-model="carinfo.mileage" />
</view>
</view>
<view class="cell">
<view>车身颜色</view>
<view class="right">
<input placeholder="请输入车身颜色" v-model="carinfo.body_color" />
</view>
</view>
<view class="cont">
<view class="conts">产品图</view>
<view class="media-upload-wrapper">
<uni-file-picker
ref="mediaPicker"
v-model="fileList"
:limit="maxMedia"
file-mediatype="image"
mode="grid"
:auto-upload="false"
@select="selectMedia"
@delete="delfn"
>
<view class="upload-icon-wrapper">
<image src="/static/image/img.png" mode="" class="addimg"></image>
</view>
</uni-file-picker>
</view>
</view>
<view class="cont">
<view class="conts">车况介绍</view>
<view>
<textarea auto-height v-model="carinfo.condition_desc" placeholder="请输入车况介绍"/>
</view>
</view>
<!-- 附件(与 publish 添加页一致:attachment 为数组对象 {name,src},后端存 JSON 字符串) -->
<view class="cont">
<view class="conts">附件</view>
<view class="media-upload-wrapper">
<uni-file-picker
ref="filePicker"
v-model="fileListAttach"
:limit="maxMedia"
file-mediatype="all"
mode="grid"
:auto-upload="false"
@select="selectMedia1"
@delete="delfn1"
>
<view class="upload-icon-wrapper">
<image src="/static/image/photo.png" mode="" class="addimg"></image>
</view>
</uni-file-picker>
</view>
</view>
</view>
<view class="zw">
<button class="btn" @click="draftadd">提交</button>
</view>
<!-- 绑定产品弹框 -->
<view v-if="bindState.showDialog" class="bind-mask" @tap="closeBindDialog">
<view class="bind-dialog" @tap.stop>
<view class="bind-dialog-head">
<text class="bind-dialog-title">选择要绑定的产品</text>
<text class="bind-dialog-close" @tap="closeBindDialog">×</text>
</view>
<!-- 弹框内搜索:仅过滤接口返回列表 -->
<view class="bind-search" v-if="bindState.list && bindState.list.length">
<input class="bind-search-input" v-model="bindState.keyword" placeholder="搜索产品名称/编码" placeholder-class="bind-search-placeholder" />
<text v-if="bindState.keyword" class="bind-search-clear" @tap.stop="bindState.keyword = ''">清除</text>
</view>
<scroll-view scroll-y class="bind-dialog-body">
<view v-if="bindState.loading" class="bind-loading">加载中…</view>
<view v-else-if="!bindState.list.length" class="bind-empty">未找到匹配的产品</view>
<view v-else-if="!filteredBindList.length" class="bind-empty">未找到匹配结果</view>
<view v-else>
<view
class="bind-item"
:class="{ active: bindState.selectedId === getBindItemId(it, idx) }"
v-for="(it, idx) in filteredBindList"
:key="getBindItemId(it, idx)"
@tap="selectBind(it, idx)"
>
<view class="bind-item-main">
<text class="bind-item-title">{{ it.name || it.product_name || it.title || ('产品 ' + (idx + 1)) }}</text>
<text class="bind-item-sub">{{ formatBindSub(it) }}</text>
</view>
<view class="bind-item-check">
<view class="check-dot" v-if="bindState.selectedId === getBindItemId(it, idx)"></view>
</view>
</view>
</view>
</scroll-view>
<view class="bind-dialog-foot">
<view class="btn btn-ghost" @tap="closeBindDialog">取消</view>
<view class="btn btn-primary" :class="{ disabled: !bindState.selectedId }" @tap="confirmBind">确认绑定</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { request, showToast } from '@/api/apiT.js'
export default {
data() {
return {
maxMedia: 20,
// 图片回显列表(uni-file-picker v-model)
fileList: [],
// 附件回显列表(uni-file-picker v-model)
fileListAttach: [],
id: '',
_uploading: false,
_uploadingFiles: false,
// 绑定弹框状态
bindState: {
showDialog: false,
loading: false,
list: [],
selectedId: '',
selectedItem: null,
boundProduct: null,
keyword: ''
},
carinfo: {
id: '',
product_name: '',
vin: '',
price: '',
source_place: '',
plate_place: '',
plate_time: '',
displacement: '',
power: '',
body_color: '',
// 提交给后端:相对路径数组(最终 join(','))
images: [],
condition_desc: '',
mileage: '',
// 附件:对象数组(最终 JSON.stringify)
attachment: [],
// 抓取绑定字段(与 publish 添加页一致)
crawl_title: '',
crawl_brand: '',
crawl_series: '',
crawl_year: '',
crawl_msrp: '',
crawl_car_id: ''
}
}
},
onLoad(options) {
this.id = options?.id || ''
this.getshows()
},
computed: {
// 弹框内搜索过滤(仅前端过滤接口返回结果,不重复请求)
filteredBindList() {
const list = Array.isArray(this.bindState.list) ? this.bindState.list : []
const kw = String(this.bindState.keyword || '').trim().toLowerCase()
if (!kw) return list
return list.filter((it, idx) => {
const id = String(it.id || it.product_id || it.code || idx).toLowerCase()
const name = String(it.name || it.product_name || it.title || '').toLowerCase()
const sub = String(this.formatBindSub(it) || '').toLowerCase()
return id.includes(kw) || name.includes(kw) || sub.includes(kw)
})
}
},
methods: {
leftClick() {
uni.navigateBack()
},
onBorrowTimeChange(e) {
this.carinfo.plate_time = e
},
// ========== 数据回显 ==========
getshows() {
request({
url: 'common/usedcar/show',
method: 'GET',
data: { id: this.id }
}).then(res => {
if (res.data.code !== 200) return
const d = res.data.data || {}
// images: string => array
const imageKeys = String(d.images || '')
.split(',')
.map(s => String(s || '').trim())
.filter(Boolean)
// attachment:后端是 JSON 字符串:[{name,src}],并且会给 attachment_pull 用于回显
let attachArr = []
try {
if (Array.isArray(d.attachment_pull) && d.attachment_pull.length) {
// 注意:attachment_pull 的 src 多数是带签名的绝对 URL,只用于展示
attachArr = d.attachment_pull.map(a => ({
name: a.name || a.original_name || '未命名',
src: a.src || a.file_path || a.file_name || ''
}))
} else if (d.attachment) {
const parsed = typeof d.attachment === 'string' ? JSON.parse(d.attachment) : d.attachment
attachArr = Array.isArray(parsed) ? parsed : []
}
} catch (e) {
attachArr = []
}
// 绑定信息:详情接口会返回 crawl_* 字段(示例数据里就是 crawl_*)
const bound = (d.crawl_car_id || d.crawl_title || d.crawl_brand || d.crawl_series)
? {
car_id: d.crawl_car_id,
title: d.crawl_title,
brand_name: d.crawl_brand,
series_name: d.crawl_series,
year: d.crawl_year,
msrp: d.crawl_msrp
}
: null
this.carinfo = {
...this.carinfo,
...d,
images: imageKeys,
attachment: attachArr.map(a => ({
name: a.name || a.original_name || '未命名',
src: a.src || a.file_path || a.file_name || ''
})),
power: d.power ?? this.carinfo.power,
// crawl_* 会被 ...d 自动覆盖,这里只兜底
crawl_car_id: d.crawl_car_id ?? this.carinfo.crawl_car_id
}
// 图片回显:优先 images_pull(签名URL)否则用 keys
const pulls = Array.isArray(d.images_pull) && d.images_pull.length ? d.images_pull : imageKeys
this.fileList = pulls.map((u, idx) => {
const raw = String(u || '')
const filename = raw.split('?')[0].split('/').pop() || `img_${idx + 1}`
const ext = filename.includes('.') ? filename.split('.').pop() : ''
return { url: raw, name: filename, extname: ext }
})
// 附件回显:优先用 attachment_pull(带签名 URL),否则用 attachment 里的 src
const pullsFiles = (Array.isArray(d.attachment_pull) && d.attachment_pull.length)
? d.attachment_pull
: (this.carinfo.attachment || [])
this.fileListAttach = (pullsFiles || []).map((u, idx) => {
const raw = typeof u === 'string'
? String(u)
: String(u?.src || '')
const filename = (typeof u === 'object' && u)
? String(u.name || u.original_name || '').trim() || (raw.split('?')[0].split('/').pop() || `file_${idx + 1}`)
: (raw.split('?')[0].split('/').pop() || `file_${idx + 1}`)
const ext = filename.includes('.') ? filename.split('.').pop() : ''
return { url: raw, name: filename, extname: ext }
})
// 已绑定提示:用 crawl_* 拼一个 boundProduct
this.bindState.boundProduct = bound
})
},
// ========== 上传(与 publish 添加页一致) ==========
// 返回后端 data 对象:{ file_name, original_name, ... }
uploadFileByScene(filePath, scene) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: 'https://userdcar-server.hnxinyouhe.com/upload?scene='+scene,
filePath,
name: 'file',
header: {
'Fdesk-Auth-Token': uni.getStorageSync('token'),
'Fdesk-Login-Type': 'feishu',
'Fdesk-System-Code': 'fdesk'
},
success: (uploadFileRes) => {
const r = JSON.parse(uploadFileRes.data || '{}')
if (r && r.data && r.data.file_name) {
resolve(r.data)
} else {
reject('上传失败')
}
},
fail: (err) => reject(err)
})
})
},
uploadfn(imgUrl,scene){
return new Promise((resolve, reject) => {
uni.uploadFile({
url: 'https://userdcar-server.hnxinyouhe.com/upload?scene='+scene,
filePath: imgUrl,
name: 'file',
header:{'Fdesk-Auth-Token': uni.getStorageSync('token'),'Fdesk-Login-Type': 'feishu','Fdesk-System-Code':'fdesk'},
success: (uploadFileRes) => {
const res = JSON.parse(uploadFileRes.data || '{}')
if(res && res.data && res.data.file_name){
resolve(res.data.file_name)
}else{
reject('上传失败')
}
},
fail: (err) => reject(err)
});
})
},
uploadImage(path) {
return this.uploadfn(path, 'usedcarimg')
},
uploadAttachment(path) {
return this.uploadFileByScene(path, 'usedcarfile')
},
// 图片选择
selectMedia(e) {
const allowCount = this.maxMedia - (this.carinfo.images?.length || 0)
if (allowCount <= 0) {
uni.showToast({ title: `最多只能上传${this.maxMedia}张图片`, icon: 'none' })
return
}
const tempFiles = e.tempFiles || []
const tempFilePaths = (tempFiles.length
? tempFiles.map(f => f.path || f.url).filter(Boolean)
: (e.tempFilePaths || [])
)
const paths = tempFilePaths.slice(0, allowCount)
if (paths.length === 0) return
if (this._uploading) return
this._uploading = true
// 占位,保持索引一致
const startAt = this.carinfo.images.length
for (let i = 0; i < paths.length; i++) this.carinfo.images.push('__PENDING__')
Promise.all(paths.map(p => this.uploadImage(p)))
.then(keys => {
keys.forEach((key, i) => this.$set(this.carinfo.images, startAt + i, String(key)))
})
.catch(() => {
this.carinfo.images.splice(startAt, paths.length)
if (Array.isArray(this.fileList) && this.fileList.length >= startAt + paths.length) {
this.fileList.splice(startAt, paths.length)
}
uni.showToast({ title: '图片上传失败,请重试', icon: 'none' })
})
.finally(() => { this._uploading = false })
},
delfn(e) {
this.carinfo.images.splice(e.index, 1)
},
// 附件选择(与 publish 添加页一致:attachment push {name,src})
selectMedia1(e) {
const allowCount = this.maxMedia - (this.carinfo.attachment?.length || 0)
if (allowCount <= 0) {
uni.showToast({ title: `最多只能上传${this.maxMedia}个附件`, icon: 'none' })
return
}
const tempFiles = e.tempFiles || []
const tempFilePaths = (tempFiles.length
? tempFiles.map(f => f.path || f.url).filter(Boolean)
: (e.tempFilePaths || [])
)
const paths = tempFilePaths.slice(0, allowCount)
if (paths.length === 0) return
if (this._uploadingFiles) return
this._uploadingFiles = true
Promise.all(paths.map(p => this.uploadAttachment(p)))
.then(list => {
list.forEach(info => {
// uploadAttachment 返回 {original_name,file_name}
this.carinfo.attachment.push({
name: info.original_name || '未命名',
src: info.file_name || ''
})
})
})
.catch(() => {
uni.showToast({ title: '附件上传失败,请重试', icon: 'none' })
})
.finally(() => { this._uploadingFiles = false })
},
delfn1(e) {
this.carinfo.attachment.splice(e.index, 1)
},
// ========== 绑定产品 ==========
onBindByName() {
const name = String(this.carinfo.product_name || '').trim()
if (!name) {
uni.showToast({ title: '请先输入产品名称', icon: 'none' })
return
}
this.bindState.loading = true
this.bindState.showDialog = true
this.bindState.list = []
this.bindState.selectedId = ''
this.bindState.selectedItem = null
this.bindState.keyword = ''
// 与 publish 添加页一致:按名称调用抓取接口,返回可能多个车型
request({
url: 'common/usedcar/crawl',
method: 'get',
data: { keyword: name }
}).then(res => {
if (res.data.code === 200) {
this.bindState.list = res.data.data?.model || []
} else {
uni.showToast({ title: res.data.message || '拉取失败', icon: 'none' })
}
}).catch(() => {
uni.showToast({ title: '拉取产品失败,请稍后重试', icon: 'none' })
}).finally(() => {
this.bindState.loading = false
})
},
closeBindDialog() {
this.bindState.showDialog = false
this.bindState.keyword = ''
},
getBindItemId(it, idx) {
return String(it.id || it.product_id || it.code || idx)
},
formatBindSub(it) {
// 你可以按接口字段改:编码/规格/分类/库存等
const parts = []
if (it.code) parts.push(`编码:${it.code}`)
if (it.sku) parts.push(`SKU:${it.sku}`)
if (it.category_name) parts.push(`分类:${it.category_name}`)
if (it.stock != null) parts.push(`库存:${it.stock}`)
return parts.join(' ')
},
selectBind(it, idx) {
const id = this.getBindItemId(it, idx)
this.bindState.selectedId = id
this.bindState.selectedItem = it
},
confirmBind() {
if (!this.bindState.selectedId || !this.bindState.selectedItem) return
const it = this.bindState.selectedItem
// 与 publish 添加页一致:写入 crawl_* 字段
this.carinfo.crawl_title = it.title || ''
this.carinfo.crawl_brand = it.brand_name || ''
this.carinfo.crawl_series = it.series_name || ''
this.carinfo.crawl_year = it.year || ''
this.carinfo.crawl_msrp = it.msrp || ''
this.carinfo.crawl_car_id = it.car_id || ''
this.bindState.boundProduct = it
this.closeBindDialog()
uni.showToast({ title: '绑定成功', icon: 'none' })
},
clearBind() {
this.bindState.boundProduct = null
this.carinfo.crawl_title = ''
this.carinfo.crawl_brand = ''
this.carinfo.crawl_series = ''
this.carinfo.crawl_year = ''
this.carinfo.crawl_msrp = ''
this.carinfo.crawl_car_id = ''
},
// ========== 提交 ==========
draftadd() {
const payload = {
...this.carinfo,
images: (this.carinfo.images || [])
.map(s => String(s || '').trim())
.filter(s => !!s && s !== '__PENDING__')
.join(',')
}
request({
url: 'common/usedcar/update',
method: 'post',
data: {
id: payload.id,
product_name: payload.product_name,
vin: payload.vin,
price: payload.price,
source_place: payload.source_place,
plate_place: payload.plate_place,
plate_time: payload.plate_time,
displacement: payload.displacement,
power: payload.power,
body_color: payload.body_color,
images: payload.images,
condition_desc: payload.condition_desc,
mileage: payload.mileage,
attachment: payload.attachment,
crawl_title: payload.crawl_title,
crawl_brand: payload.crawl_brand,
crawl_series: payload.crawl_series,
crawl_year: payload.crawl_year,
crawl_msrp: payload.crawl_msrp,
crawl_car_id: payload.crawl_car_id
}
}).then(res => {
uni.showToast({ title: res.data.message, icon: 'none' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/index/mine' })
}, 1000)
})
}
}
}
</script>
<style lang="scss">
.uniui-right{ position:absolute; top:2px; }
.red{ color:red; }
.content{ margin:20px 12px; background:#FFFFFF; border-radius:10px; }
.zw{ height:120px; }
.cont{ position:relative; padding:18px 20px; align-items:center; font-weight:500; }
.conts{ margin-bottom:20rpx; }
.media-upload-wrapper{ display:flex; flex-wrap:wrap; gap:16rpx; }
.cell{
position: relative;
display: flex;
justify-content: space-between;
padding: 18px 20px;
align-items: center;
font-weight: 500;
}
.cont::after, .cell::after{
content:'';
position:absolute;
width:200%;
height:200%;
top:0;
left:0;
transform-origin:0 0;
border-width:0;
border-bottom-width:1px;
border-style:solid;
transform:scale(0.5,0.5);
border-radius:1px;
box-sizing:border-box;
pointer-events:none;
border-color:#C3C3C3;
}
.right{ text-align:right; }
.btn{
background-color:#3F435B;
color:white;
border-radius:50px;
width:92%;
margin:15px 4%;
}
.addimg{ width:215rpx; height:215rpx; }
/* 产品名称 + 绑定按钮 */
.right-bind{
flex-direction: row;
align-items: center;
display: flex;
width: 70%;
}
.right-bind .inp{ flex:1; }
.bind-btn{
margin-left: 16rpx;
padding: 0 18rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 12rpx;
background: #2E6CF6;
color: #fff;
font-size: 26rpx;
}
.bind-tip{
margin: 0 24rpx 16rpx;
padding: 12rpx 18rpx;
border-radius: 12rpx;
background: #F3F6FF;
display: flex;
align-items: center;
justify-content: space-between;
}
.bind-tip-text{ color:#2B3A55; font-size:24rpx; }
.bind-tip-clear{ color:#2E6CF6; font-size:24rpx; }
/* 弹框搜索 */
.bind-search{
margin: 5px 24rpx 16rpx;
padding: 12rpx 18rpx;
border-radius: 12rpx;
background: #F3F6FF;
display: flex;
align-items: center;
justify-content: space-between;
}
.bind-search-input{
flex: 1;
font-size: 26rpx;
color: #333;
}
.bind-search-placeholder{
color: #9AA6BF;
}
.bind-search-clear{
margin-left: 16rpx;
font-size: 24rpx;
color: #2E6CF6;
}
/* 绑定弹框 */
.bind-mask{
position: fixed;
left: 0; right: 0; top: 0; bottom: 0;
background: rgba(0,0,0,0.45);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
}
.bind-dialog{
width: 100%;
max-width: 680rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
}
.bind-dialog-head{
height: 96rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
}
.bind-dialog-title{
font-size: 30rpx;
font-weight: 600;
color: #111;
}
.bind-dialog-close{
font-size: 40rpx;
color: #999;
line-height: 1;
}
.bind-dialog-body{
max-height: 720rpx;
padding: 12rpx 0;
}
.bind-loading,.bind-empty{
padding: 40rpx 24rpx;
color: #888;
text-align: center;
font-size: 26rpx;
}
.bind-item{
padding: 18rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.bind-item.active{
background: #F3F6FF;
}
.bind-item-main{
flex: 1;
padding-right: 16rpx;
}
.bind-item-title{
font-size: 28rpx;
font-weight: 600;
color: #222;
display: block;
}
.bind-item-sub{
margin-top: 6rpx;
font-size: 24rpx;
color: #777;
display: block;
}
.bind-item-check{
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 2rpx solid #2E6CF6;
display: flex;
align-items: center;
justify-content: center;
}
.check-dot{
width: 22rpx;
height: 22rpx;
border-radius: 50%;
background: #2E6CF6;
}
.bind-dialog-foot{
padding: 16rpx 24rpx 22rpx;
display: flex;
gap: 16rpx;
border-top: 1px solid #f0f0f0;
}
.btn{
flex: 1;
height: 76rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.btn-ghost{
background: #F5F6F8;
color: #333;
}
.btn-primary{
background: #2E6CF6;
color: #fff;
}
.btn-primary.disabled{
opacity: .45;
}
</style>