You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

880 lines
24 KiB

<template>
<view class="release-page">
<view class="header" :style="{ paddingTop: statusBarTop + 'px' }">
<view class="back" @tap="back">
<uni-icons type="left" size="22" color="#18372f"></uni-icons>
</view>
<view class="header-title">{{isEdit ? '编辑校园二手' : '发布校园二手'}}</view>
<view class="header-sub">实名发布线下面交请勿提前转账</view>
<view class="tabs">
<view class="tab" :class="{ active: publishType == 'sell', disabled: isEdit }" @tap="switchType('sell')">出售</view>
<view class="tab" :class="{ active: publishType == 'want', disabled: isEdit }" @tap="switchType('want')">求购</view>
</view>
</view>
<view class="form-scroll" :style="{ paddingTop: headerHeight + 'px' }">
<view class="form-card">
<view class="field" id="fieldTitle">
<view class="label">标题</view>
<input v-model.trim="form.title" maxlength="30" placeholder="最多30个字" />
</view>
<view class="field price-row" id="fieldPrice">
<view class="price-field">
<view class="label">{{publishType == 'sell' ? '售价' : '预算'}}</view>
<input v-model="form.price" type="digit" placeholder="0.00" />
</view>
<view class="price-field" v-if="publishType == 'sell'">
<view class="label">原价</view>
<input v-model="form.originPrice" type="digit" placeholder="可不填" />
</view>
</view>
<view class="field">
<view class="label">分类</view>
<picker :range="categoryNames" @change="chooseCategory">
<view class="picker-value">{{selectedCategoryName || '请选择分类'}} <text>›</text></view>
</picker>
</view>
<view class="field">
<view class="label">图片 <text>{{publishType == 'sell' ? '最多9张' : '最多1张,可选'}}</text></view>
<view class="image-list">
<view class="image-item" v-for="(item,index) in form.images" :key="item">
<image :src="item" mode="aspectFill"></image>
<view class="remove" @tap="removeImage(index)">×</view>
</view>
<view class="image-add" v-if="form.images.length < maxImageCount" @tap="chooseImages">
<uni-icons type="plusempty" size="28" color="#8aa09a"></uni-icons>
<text>上传</text>
</view>
</view>
</view>
<view class="field" id="fieldContent">
<view class="label">{{publishType == 'sell' ? '商品介绍' : '求购内容'}}</view>
<view class="editor-box">
<sp-editor
editor-id="fishReleaseEditor"
:toolbar-config="editorToolbarConfig"
:maxlength="1000"
:placeholder="publishType == 'sell' ? '描述成色、尺寸、交易地点、可取时间等' : '描述想要的物品、预算、交易地点、可取时间等'"
@init="initEditor"
@input="inputEditor"
@upinImage="upinEditorImage"
@overMax="overEditorMax"
></sp-editor>
</view>
</view>
<view class="field" id="fieldContact">
<view class="label">联系方式</view>
<input v-model.trim="form.wechat" maxlength="20" placeholder="微信号,可选" cursor-spacing="180" />
<input v-model.trim="form.phone" class="contact-input" maxlength="11" type="number" placeholder="手机号,可选" cursor-spacing="180" />
</view>
<view class="field" v-if="publishType == 'sell'">
<view class="label">商品标签</view>
<view class="tag-list">
<view class="tag" :class="{ checked: form.tags.indexOf(item) > -1 }" v-for="item in tagOptions" :key="item" @tap="toggleTag(item)">
{{item}}
</view>
</view>
</view>
</view>
<view class="safe-tip">发布内容、图片、评论都会进行内容安全审核。可以留电话或微信,但请线下面交,谨防诈骗。</view>
<view class="bottom-space"></view>
</view>
<view class="submit-bar">
<view class="submit-btn" :class="{ disabled: submitting }" @tap="submit">{{submitting ? '提交中...' : '提交发布'}}</view>
</view>
<view class="audit-mask" v-if="submitting">
<view class="audit-card">
<view class="audit-spinner">
<view></view>
<view></view>
<view></view>
</view>
<view class="audit-title">{{auditTitle}}</view>
<view class="audit-desc">发布内容正在提交审核,请勿重复点击</view>
</view>
</view>
<ie-auth-dialog />
</view>
</template>
<script>
import { getIeProfile } from '@/common/ieApi.js'
import { ensureIeVerifiedBeforeAction } from '@/common/ieRealNameAuth.js'
import IeAuthDialog from '@/package1/components/ie-auth-dialog/ie-auth-dialog.vue'
import SpEditor from '@/package1/components/sp-editor/components/sp-editor/sp-editor.vue'
import {
listFishCategories,
getFishGoodsDetail,
getFishWantDetail,
publishFishGoods,
publishFishWant,
uploadFishReleaseImage
} from '@/common/fishMarketApi.js'
export default {
components: {
IeAuthDialog,
SpEditor
},
data() {
return {
statusBarTop: 24,
publishType: 'sell',
editId: '',
isEdit: false,
pendingEditDetail: null,
pendingEditorHtml: '',
categories: [],
selectedCategoryIndex: -1,
submitting: false,
submitLocked: false,
keyboardOpen: false,
auditTitle: '提交中,等待审核',
editorIns: null,
editorToolbarConfig: {
keys: ['bold', 'italic', 'underline', 'header', 'listOrdered', 'listBullet', 'align', 'image', 'removeFormat', 'clear'],
iconSize: '17px',
iconColumns: 10
},
tagOptions: ['可小刀', '当天可取', '宿舍自提', '可送宿舍', '急出', '九成新'],
form: {
title: '',
price: '',
originPrice: '',
content: '',
contentText: '',
images: [],
wechat: '',
phone: '',
tags: ['可小刀']
}
}
},
computed: {
categoryNames() {
return this.categories.map(item => item.name)
},
selectedCategoryName() {
const item = this.categories[this.selectedCategoryIndex]
return item ? item.name : ''
},
maxImageCount() {
return this.publishType == 'sell' ? 9 : 1
},
headerHeight() {
return this.statusBarTop + uni.upx2px(300)
}
},
onLoad(options) {
if (options && (options.type == 'want' || options.type == 'sell')) {
this.publishType = options.type
}
if (options && options.mode == 'edit' && options.id) {
this.isEdit = true
this.editId = options.id
}
if (uni.getMenuButtonBoundingClientRect) {
this.statusBarTop = uni.getMenuButtonBoundingClientRect().top || this.statusBarTop
}
this.loadCategories()
if (this.isEdit) {
this.loadEditDetail()
}
this.ensurePublishVerified()
this.bindKeyboardListener()
},
onUnload() {
if (uni.offKeyboardHeightChange) {
uni.offKeyboardHeightChange()
}
},
methods: {
bindKeyboardListener() {
if (!uni.onKeyboardHeightChange) return
uni.onKeyboardHeightChange((res) => {
this.keyboardOpen = Number(res && res.height) > 0
})
},
back() {
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
},
switchType(type) {
if (this.isEdit) return
this.publishType = type
if (type == 'want' && this.form.images.length > 1) {
this.form.images = this.form.images.slice(0, 1)
}
},
getArea() {
const area = uni.getStorageSync('area')
if (!area) return {}
if (typeof area === 'string') {
try {
return JSON.parse(area)
} catch (e) {
return {}
}
}
return area
},
loadCategories() {
const area = this.getArea()
listFishCategories(area.id || '').then(list => {
this.categories = Array.isArray(list) ? list : []
this.applyEditCategory()
})
},
loadEditDetail() {
const request = this.publishType == 'want' ? getFishWantDetail(this.editId) : getFishGoodsDetail(this.editId)
request.then(detail => {
if (!detail) return
this.pendingEditDetail = detail
this.applyEditDetail(detail)
})
},
applyEditDetail(detail) {
const contact = this.parseJsonObject(detail.contactInfo)
const imageList = detail.imageList && detail.imageList.length ? detail.imageList : this.parseJsonList(detail.images)
const tagList = this.publishType == 'sell' ? this.parseJsonList(detail.tags) : []
const content = detail.content || ''
this.form.title = detail.title || ''
this.form.price = String(this.publishType == 'want' ? (detail.expectedPrice || '') : (detail.price || ''))
this.form.originPrice = this.publishType == 'sell' && detail.originPrice ? String(detail.originPrice) : ''
this.form.content = content
this.form.contentText = this.extractText(content)
this.form.images = imageList.slice(0, this.maxImageCount)
this.form.wechat = contact.wechat || ''
this.form.phone = contact.phone || ''
this.form.tags = tagList.length ? tagList : (this.publishType == 'sell' ? ['可小刀'] : [])
this.pendingEditorHtml = content
this.applyEditCategory()
this.applyPendingEditorContent()
},
applyEditCategory() {
const detail = this.pendingEditDetail
if (!detail || !this.categories.length) return
const index = this.categories.findIndex(item => String(item.id) == String(detail.categoryId) || item.name == detail.categoryName)
this.selectedCategoryIndex = index > -1 ? index : this.selectedCategoryIndex
},
applyPendingEditorContent() {
if (!this.editorIns || !this.pendingEditorHtml) return
if (this.editorIns.setContents) {
this.editorIns.setContents({
html: this.pendingEditorHtml,
complete: () => this.hideEditKeyboard()
})
}
this.hideEditKeyboard()
},
hideEditKeyboard() {
if (!this.isEdit) return
setTimeout(() => {
uni.hideKeyboard()
}, 120)
},
parseJsonList(value) {
if (!value) return []
if (Array.isArray(value)) return value
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch (e) {
return []
}
},
parseJsonObject(value) {
if (!value) return {}
if (typeof value === 'object') return value
try {
return JSON.parse(value) || {}
} catch (e) {
return {}
}
},
extractText(html) {
return String(html || '')
.replace(/<img[^>]*>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.trim()
},
async ensurePublishVerified() {
const profile = await getIeProfile().catch(() => null)
const verified = await ensureIeVerifiedBeforeAction({
profile: profile || {},
actionText: '发布校园二手信息',
authDialog: this.fishAuthDialogOptions(),
reload: getIeProfile
})
if (!verified) {
setTimeout(() => this.back(), 300)
}
},
chooseCategory(e) {
this.selectedCategoryIndex = Number(e.detail.value)
},
chooseImages() {
const remain = this.maxImageCount - this.form.images.length
if (remain <= 0) return
uni.chooseImage({
count: remain,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const paths = res.tempFilePaths || []
uni.showLoading({ title: '上传中...', mask: true })
const failMessages = []
try {
for (let i = 0; i < paths.length; i++) {
const result = await uploadFishReleaseImage(paths[i])
if (result && result.passed && result.url) {
this.form.images.push(result.url)
} else {
failMessages.push((result && result.message) || '图片审核未通过,请更换图片')
}
}
} finally {
uni.hideLoading()
}
this.showUploadAuditFail(failMessages)
}
})
},
removeImage(index) {
this.form.images.splice(index, 1)
},
initEditor(editor) {
this.editorIns = editor
this.applyPendingEditorContent()
},
inputEditor(e) {
this.form.content = e && e.html ? e.html : ''
this.form.contentText = e && e.text ? e.text : ''
},
overEditorMax() {
uni.showToast({ title: '内容最多1000字', icon: 'none' })
},
async upinEditorImage(tempFiles, editorCtx) {
const files = Array.isArray(tempFiles) ? tempFiles : []
if (!files.length || !editorCtx) return
uni.showLoading({ title: '上传中...', mask: true })
const failMessages = []
try {
for (let i = 0; i < files.length; i++) {
const filePath = files[i].tempFilePath || files[i].path
const result = filePath ? await uploadFishReleaseImage(filePath) : null
if (result && result.passed && result.url) {
editorCtx.insertImage({
src: result.url,
width: '90%'
})
} else {
failMessages.push((result && result.message) || '图片审核未通过,请更换图片')
}
}
} finally {
uni.hideLoading()
}
this.showUploadAuditFail(failMessages)
},
showUploadAuditFail(messages) {
if (!messages || !messages.length) return
const uniq = Array.from(new Set(messages.filter(Boolean)))
uni.showModal({
title: '图片审核未通过',
content: uniq.join('\n'),
showCancel: false,
confirmText: '知道了'
})
},
toggleTag(tag) {
const index = this.form.tags.indexOf(tag)
if (index > -1) {
this.form.tags.splice(index, 1)
} else {
this.form.tags.push(tag)
}
},
syncEditorContent() {
return new Promise((resolve) => {
if (!this.editorIns || !this.editorIns.getContents) {
resolve()
return
}
this.editorIns.getContents({
success: (res) => {
this.form.content = res.html || this.form.content || ''
this.form.contentText = res.text || this.form.contentText || ''
resolve()
},
fail: () => resolve()
})
})
},
validate() {
const title = String(this.form.title || '').trim()
const price = String(this.form.price || '').trim()
const originPrice = String(this.form.originPrice || '').trim()
const contentText = String(this.form.contentText || '').replace(/[ \t\r\n]/g, '')
const wechat = String(this.form.wechat || '').trim()
const phone = String(this.form.phone || '').trim()
if (!title) return '请填写标题'
if (title.length < 2) return '标题至少2个字'
if (title.length > 30) return '标题最多30个字'
if (this.selectedCategoryIndex < 0) return '请选择分类'
if (!price) return this.publishType == 'sell' ? '请填写售价' : '请填写预算'
if (!this.isValidPrice(price)) return this.publishType == 'sell' ? '售价必须大于0且最多2位小数' : '预算必须大于0且最多2位小数'
if (originPrice && !this.isValidAmount(originPrice)) return '原价最多2位小数'
if (this.publishType == 'sell' && originPrice && Number(price) > Number(originPrice)) return '售价不得大于原价'
if (this.publishType == 'sell' && this.form.images.length == 0) return '请至少上传1张商品图片'
if (this.publishType == 'want' && this.form.images.length > 1) return '求购最多上传1张图片'
if (!contentText) return this.publishType == 'sell' ? '请填写商品介绍' : '请填写求购内容'
if (contentText.length < 5) return this.publishType == 'sell' ? '商品介绍至少5个字' : '求购内容至少5个字'
if (contentText.length > 1000) return this.publishType == 'sell' ? '商品介绍最多1000字' : '求购内容最多1000字'
if (wechat && !this.isValidWechat(wechat)) return '请输入正确的微信号'
if (phone && !this.isValidPhone(phone)) return '请输入正确的手机号'
return ''
},
isValidPrice(value) {
return /^\d+(\.\d{1,2})?$/.test(value) && Number(value) > 0
},
isValidAmount(value) {
return /^\d+(\.\d{1,2})?$/.test(value) && Number(value) >= 0
},
isValidWechat(value) {
return /^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/.test(value)
},
isValidPhone(value) {
return /^1[3-9]\d{9}$/.test(value)
},
async submit() {
if (this.submitLocked) return
this.submitLocked = true
let submitSuccess = false
try {
await this.syncEditorContent()
const error = this.validate()
if (error) {
uni.showToast({ title: error, icon: 'none' })
return
}
const profile = await getIeProfile().catch(() => null)
const verified = await ensureIeVerifiedBeforeAction({
profile: profile || {},
actionText: '发布校园二手信息',
authDialog: this.fishAuthDialogOptions(),
reload: getIeProfile
})
if (!verified) return
const area = this.getArea()
const category = this.categories[this.selectedCategoryIndex]
const payload = {
id: this.isEdit ? this.editId : undefined,
publishType: this.publishType,
regionId: area.id || '',
regionName: area.title || '',
title: this.form.title,
content: this.form.content,
contentText: this.form.contentText,
price: this.publishType == 'sell' ? Number(this.form.price) : undefined,
originPrice: this.form.originPrice ? Number(this.form.originPrice) : undefined,
expectedPrice: this.publishType == 'want' ? Number(this.form.price) : undefined,
categoryId: category.id,
categoryName: category.name,
images: this.publishType == 'want' ? this.form.images.slice(0, 1) : this.form.images,
contactInfo: {
wechat: this.form.wechat,
phone: this.form.phone
},
tags: this.form.tags,
negotiable: this.form.tags.indexOf('可小刀') > -1 ? 1 : 0
}
this.submitting = true
this.auditTitle = this.isEdit ? '保存中,等待审核' : '提交中,等待审核'
const res = this.publishType == 'sell' ? await publishFishGoods(payload) : await publishFishWant(payload)
if (res) {
submitSuccess = true
this.auditTitle = this.isEdit ? '保存成功,等待审核' : '提交成功,等待审核'
setTimeout(() => uni.navigateBack(), 800)
}
} finally {
if (!submitSuccess) {
this.submitting = false
this.submitLocked = false
}
}
},
fishAuthDialogOptions() {
return {
badge: '校园二手',
title: '实名认证',
content: '根据法律法规要求,对应流程需要调用阿里云实名认证。半径里不会存储您的实名信息。',
steps: []
}
}
}
}
</script>
<style lang="scss">
page {
background: #f7f9f7;
color: #15362f;
}
.release-page {
min-height: 100vh;
overflow: visible;
background: linear-gradient(180deg, #e7fbf4 0%, #f7f9f7 280rpx);
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
padding: 0 32rpx 28rpx;
background: linear-gradient(180deg, #e7fbf4 0%, rgba(247, 249, 247, 0.98) 100%);
box-shadow: 0 8rpx 24rpx rgba(24, 99, 78, 0.05);
box-sizing: border-box;
}
.back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
}
.header-title {
margin-top: 12rpx;
font-size: 42rpx;
font-weight: 900;
}
.header-sub {
margin-top: 8rpx;
color: #729088;
font-size: 24rpx;
}
.form-scroll {
box-sizing: border-box;
}
.tabs {
width: 360rpx;
height: 72rpx;
margin: 26rpx 0 0;
padding: 6rpx;
display: flex;
border-radius: 42rpx;
background: rgba(255, 255, 255, 0.86);
box-sizing: border-box;
}
.tab {
flex: 1;
height: 60rpx;
line-height: 60rpx;
border-radius: 34rpx;
text-align: center;
font-size: 26rpx;
font-weight: 800;
color: #7c8d88;
}
.tab.active {
background: linear-gradient(135deg, #39ca8f, #24b979);
color: #fff;
box-shadow: 0 8rpx 18rpx rgba(36, 185, 121, 0.22);
}
.tab.disabled {
opacity: .72;
}
.form-card {
margin: 0 24rpx;
padding: 4rpx 26rpx 26rpx;
border-radius: 30rpx;
background: #fff;
box-shadow: 0 12rpx 32rpx rgba(29, 74, 60, 0.06);
}
.field {
padding-top: 26rpx;
}
.label {
margin-bottom: 14rpx;
color: #18372f;
font-size: 27rpx;
font-weight: 900;
}
.label text {
color: #9aa8a4;
font-size: 21rpx;
font-weight: 400;
}
input,
textarea,
.picker-value {
width: 100%;
min-height: 78rpx;
padding: 0 22rpx;
border-radius: 18rpx;
background: #f5f8f6;
box-sizing: border-box;
color: #223b35;
font-size: 26rpx;
}
textarea {
height: 220rpx;
padding-top: 20rpx;
line-height: 38rpx;
}
.editor-box {
min-height: 420rpx;
overflow: hidden;
border-radius: 18rpx;
background: #f5f8f6;
border: 1rpx solid #edf2ef;
box-sizing: border-box;
}
.editor-box ::v-deep .sp-editor-toolbar {
background: #fff;
border-bottom: 1rpx solid #edf2ef;
}
.editor-box ::v-deep .sp-editor-wrapper {
min-height: 320rpx;
}
.editor-box ::v-deep .editor-container {
min-height: 320rpx;
padding: 18rpx 20rpx;
box-sizing: border-box;
font-size: 26rpx;
line-height: 40rpx;
}
.picker-value {
line-height: 78rpx;
}
.picker-value text {
float: right;
color: #8ca09a;
font-size: 34rpx;
}
.price-row {
display: flex;
gap: 18rpx;
}
.price-field {
flex: 1;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.image-item,
.image-add {
position: relative;
width: 150rpx;
height: 150rpx;
border-radius: 18rpx;
overflow: hidden;
background: #f2f7f4;
}
.image-item image {
width: 100%;
height: 100%;
}
.remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.48);
color: #fff;
text-align: center;
line-height: 30rpx;
}
.image-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #8aa09a;
font-size: 22rpx;
}
.contact-input {
margin-top: 14rpx;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.tag {
height: 56rpx;
padding: 0 20rpx;
border-radius: 28rpx;
background: #f2f6f4;
color: #6f817c;
font-size: 23rpx;
line-height: 56rpx;
}
.tag.checked {
background: #e5fbf2;
color: #1a9a72;
font-weight: 800;
}
.safe-tip {
margin: 22rpx 32rpx 0;
color: #84948f;
font-size: 22rpx;
line-height: 34rpx;
}
.submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -8rpx 24rpx rgba(23, 54, 47, 0.06);
}
.submit-btn {
height: 88rpx;
border-radius: 46rpx;
background: linear-gradient(135deg, #37c990, #19af7e);
color: #fff;
font-size: 30rpx;
font-weight: 900;
text-align: center;
line-height: 88rpx;
}
.submit-btn.disabled {
opacity: 0.62;
}
.audit-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(12, 31, 25, 0.34);
}
.audit-card {
width: 480rpx;
padding: 48rpx 36rpx 42rpx;
border-radius: 34rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 24rpx 60rpx rgba(19, 70, 54, 0.18);
text-align: center;
box-sizing: border-box;
}
.audit-spinner {
width: 124rpx;
height: 124rpx;
margin: 0 auto 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
border-radius: 50%;
background: linear-gradient(135deg, #e7fbf4, #f8fffc);
}
.audit-spinner view {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #20b985;
animation: audit-bounce 0.9s infinite ease-in-out;
}
.audit-spinner view:nth-child(2) {
animation-delay: 0.12s;
}
.audit-spinner view:nth-child(3) {
animation-delay: 0.24s;
}
.audit-title {
color: #17362f;
font-size: 31rpx;
font-weight: 900;
}
.audit-desc {
margin-top: 14rpx;
color: #84948f;
font-size: 23rpx;
line-height: 34rpx;
}
@keyframes audit-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.45;
}
40% {
transform: translateY(-18rpx);
opacity: 1;
}
}
.bottom-space {
height: 240rpx;
}
</style>