Files
yxe-mp/pages/share/components/ShareCreatePopup.vue
T
2026-09-29 15:17:13 +08:00

943 lines
26 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>
<view v-if="showPopup" class="share-popup-mask" @tap="close" @touchmove.stop.prevent></view>
<view v-if="showPopup" class="share-popup card" @tap.stop>
<view class="popup-head">
<view class="section-title">晒单信息</view>
<view class="close-btn" @tap="close">关闭</view>
</view>
<view class="form-row">
<view class="label">门店</view>
<view class="picker-input" @tap="openMerchantPicker">
<view class="picker-input-inner">
<text :class="form.merchantName ? 'picker-text' : 'picker-placeholder'">
{{ form.merchantName || '请选择门店' }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
</view>
<view class="detail-title">商品明细</view>
<view class="detail-actions">
<view class="ghost-btn" @tap="addItem">新增商品</view>
</view>
<view v-if="form.list.length === 0" class="detail-empty">暂无明细</view>
<view v-else class="detail-list">
<view class="detail-item" v-for="(detail, idx) in form.list" :key="idx">
<view class="detail-head">
<view class="detail-index">第 {{ idx + 1 }} 项</view>
<view class="detail-remove" @tap="removeItem(idx)">删除</view>
</view>
<view class="form-row">
<view class="label">商品</view>
<view class="picker-input" @tap="openProductPicker(idx)">
<view class="picker-input-inner">
<text :class="detail.goodsName ? 'picker-text' : 'picker-placeholder'">
{{ detail.goodsName || '点击选择商品' }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
</view>
<view class="form-row">
<view class="label">厂家</view>
<view class="form-value">{{ detail.makerAbb || '-' }}</view>
</view>
<view class="form-row">
<view class="label">规格</view>
<view class="form-value">{{ detail.spec || '-' }}</view>
</view>
<!-- <view class="form-row">-->
<!-- <view class="label">红包金额</view>-->
<!-- <view class="form-value">¥{{ detail.income || 0 }}</view>-->
<!-- </view>-->
<view class="form-row">
<view class="label">编码</view>
<view class="form-value">{{ detail.goodsCode || '-' }}</view>
</view>
<view class="form-row">
<view class="label">数量</view>
<input v-model="detail.num" class="input" type="number" placeholder="请输入数量"/>
</view>
</view>
</view>
<!-- <view class="form-row total-bonus-row">-->
<!-- <view class="label">总红包金额</view>-->
<!-- <view class="form-value total-bonus-value">¥{{ totalBonusDisplay }}</view>-->
<!-- </view>-->
<view class="detail-title">小票</view>
<view class="receipt-upload" @tap="chooseReceipt">
<template v-if="form.receiptLocalPath">
<image class="receipt-image" :src="form.receiptLocalPath" mode="aspectFill"/>
<view class="receipt-tip">
{{ receiptUploading ? '上传中...' : (form.receiptUrl ? '已上传,点击重新上传' : '点击重新上传') }}
</view>
</template>
<template v-else>
<u-icon name="camera" size="40" color="#9ca3af"/>
<view class="receipt-tip">{{ receiptUploading ? '上传中...' : '上传小票' }}</view>
</template>
</view>
<view class="detail-title">留言(可选)</view>
<view class="remark-wrap">
<textarea
v-model="form.remark"
class="textarea"
placeholder="请输入留言"
maxlength="200"
auto-height
/>
</view>
<view class="primary-btn submit" @tap="submit">提交</view>
</view>
<u-popup :show="showMerchantPicker" mode="bottom" round="24rpx" @close="closeMerchantPicker">
<view class="merchant-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantPicker">取消</view>
<text class="picker-title">选择门店</text>
<view class="picker-action primary" @tap="confirmMerchantPicker">确定</view>
</view>
<view class="merchant-picker-search">
<input
v-model="merchantSearchKeyword"
class="merchant-search-input"
placeholder="搜索门店"
placeholder-class="merchant-search-placeholder"
/>
</view>
<view v-if="merchantLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<picker-view
v-else
class="merchant-picker-view"
:value="merchantPickerValue"
:indicator-style="merchantIndicatorStyle"
@change="onMerchantPickerChange"
>
<picker-view-column>
<view v-for="(item, idx) in merchantFilteredOptions" :key="idx" class="merchant-picker-item">{{ item.text }}</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
<u-popup :show="showProductPicker" mode="bottom" round="24rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeProductPicker">取消</view>
<text class="picker-title">选择商品</text>
<view class="picker-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<input
v-model="productKeyword"
class="merchant-search-input"
placeholder="搜索商品名称/编码/厂家"
placeholder-class="merchant-search-placeholder"
/>
</view>
<view v-if="productLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<scroll-view v-else class="product-scroll" scroll-y>
<view v-if="filteredProducts.length === 0" class="detail-empty">暂无商品</view>
<view
v-for="(item, idx) in filteredProducts"
:key="idx"
class="product-option"
@tap="selectProduct(item)"
>
<view class="product-option-name">{{ item.goodsName || '-' }}</view>
<view class="product-option-meta">厂家:{{ item.makerAbb || '-' }}</view>
<view class="product-option-meta">规格:{{ item.spec || '-' }}</view>
<!-- <view class="product-option-meta">红包金额:¥{{ item.income || 0 }}</view>-->
<view class="product-option-meta">编码:{{ item.goodsCode || '-' }}</view>
</view>
</scroll-view>
</view>
</u-popup>
</view>
</template>
<script>
import dayjs from 'dayjs'
import {calcDistanceMeters, uploadImg} from "@/util";
import {merchantListReq} from "@/api/shop";
import {shareAddReq} from "@/api/share";
import {productListReq} from "@/api/product";
export default {
name: 'ShareCreatePopup',
props: {
lat: {
type: Number,
default: null
},
lng: {
type: Number,
default: null
},
currentProvince: {
type: String,
default: ''
},
currentCity: {
type: String,
default: ''
},
chain: {
type: String,
default: ''
}
},
data() {
return {
showPopup: false,
showMerchantPicker: false,
showProductPicker: false,
merchantLoading: false,
productLoading: false,
merchantList: [],
productList: [],
merchantSearchKeyword: '',
merchantPickerValue: [0],
productKeyword: '',
activeProductRowIndex: 0,
receiptUploading: false,
form: this.buildForm()
}
},
computed: {
merchantFilteredOptions() {
const list = Array.isArray(this.merchantList) ? this.merchantList : []
const keyword = (this.merchantSearchKeyword || '').trim()
const filtered = keyword ? list.filter(item => (item.text || '').includes(keyword)) : list
return filtered.length ? filtered : [{id: '', text: '无匹配门店'}]
},
merchantPickerIndex() {
const idx = this.merchantFilteredOptions.findIndex(item => item.id === this.form.merchantId)
return idx > -1 ? idx : 0
},
merchantIndicatorStyle() {
return 'height: 44px;'
},
filteredProducts() {
const list = Array.isArray(this.productList) ? this.productList : []
const keyword = (this.productKeyword || '').trim().toLowerCase()
if (!keyword) return list
return list.filter(item => {
const name = (item.goodsName || '').toLowerCase()
const maker = (item.makerAbb || '').toLowerCase()
const spec = (item.spec || '').toLowerCase()
const code = (item.goodsCode || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword) || code.includes(keyword)
})
},
totalBonus() {
const list = Array.isArray(this.form.list) ? this.form.list : []
return list.reduce((sum, item) => {
const hasSelectedProduct = !!(item.goodsName || item.goodsCode)
if (!hasSelectedProduct) return sum
const num = Number(item.num)
const income = Number(item.income)
if (!Number.isFinite(num) || num <= 0) return sum
if (!Number.isFinite(income) || income <= 0) return sum
return sum + (income * num)
}, 0)
},
totalBonusDisplay() {
return this.totalBonus.toFixed(2)
}
},
watch: {
merchantSearchKeyword() {
this.merchantPickerValue = [0]
}
},
methods: {
buildForm() {
return {
merchantName: '',
merchantId: '',
province: '',
city: '',
lat: '',
lng: '',
remark: '',
receiptLocalPath: '',
receiptUrl: '',
list: [this.createEmptyItem()]
}
},
createEmptyItem() {
return {
goodsCode: '',
goodsName: '',
makerAbb: '',
spec: '',
income: '',
num: ''
}
},
normalizeMerchant(item) {
const id = item.id ?? item.merchantId ?? item.merchant_id
const text = item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
const lat = Number(item.lat ?? item.latitude ?? item.merchantLat ?? item.merchant_lat)
const lng = Number(item.lng ?? item.longitude ?? item.merchantLng ?? item.merchant_lng)
return {
id,
text,
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
province: item.province || item.province_str || '',
city: item.city || item.city_str || ''
}
},
findNearestMerchant(list) {
if (!Number.isFinite(this.lat) || !Number.isFinite(this.lng)) return null
let nearest = null
let minDistance = Infinity
list.forEach(item => {
if (!Number.isFinite(item.lat) || !Number.isFinite(item.lng)) return
const meters = calcDistanceMeters(this.lat, this.lng, item.lat, item.lng)
if (!Number.isFinite(meters)) return
if (meters < minDistance) {
minDistance = meters
nearest = item
}
})
return nearest
},
applyMerchant(merchant) {
if (!merchant) return
this.form.merchantName = merchant.text || ''
this.form.merchantId = merchant.id ?? ''
this.form.province = merchant.province || this.currentProvince || ''
this.form.city = merchant.city || this.currentCity || ''
this.form.lat = Number.isFinite(merchant.lat) ? merchant.lat : ''
this.form.lng = Number.isFinite(merchant.lng) ? merchant.lng : ''
},
normalizeProduct(item) {
const product = item && item.product ? item.product : {}
const code = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || product.batchCode || ''
const name = item.goodsName || item.goods_name || item.title || item.name || product.title || product.goodsName || product.name || ''
const maker = item.makerAbb || item.maker || item.manufacturer || product.makerAbb || product.maker || product.manufacturer || ''
const spec = product.spec || item.spec || item.specification || ''
const incomeRaw = product.income !== undefined && product.income !== null ? product.income : (item.income || 0)
const income = Number(incomeRaw) || 0
return {
goodsCode: code,
goodsName: name,
makerAbb: maker,
spec,
income
}
},
uniqueProducts(list) {
const map = {}
list.forEach(item => {
const key = item.goodsCode || `${item.goodsName}_${item.spec}_${item.makerAbb}`
if (!key) return
if (!map[key]) {
map[key] = item
}
})
return Object.keys(map).map(key => map[key])
},
async getMerchantList() {
this.merchantLoading = true
try {
const params = {}
const chain = String(this.chain || this.form.chainName || '').trim()
if (chain) params.chain = chain
const {data} = await merchantListReq(params)
const list = Array.isArray(data)
? data
: (Array.isArray(data && data.records) ? data.records : (Array.isArray(data && data.list) ? data.list : []))
this.merchantList = list.map(item => this.normalizeMerchant(item)).filter(item => item.id !== undefined && item.id !== null)
} catch (error) {
this.merchantList = []
uni.showToast({
title: '门店列表获取失败',
icon: 'none'
})
} finally {
this.merchantLoading = false
}
},
async getProductsByMerchant() {
if (!this.form.merchantId) {
this.productList = []
return
}
this.productLoading = true
try {
const {data} = await productListReq({
merchantId: this.form.merchantId
})
const rawList = Array.isArray(data)
? data
: (Array.isArray(data && data.list)
? data.list
: (Array.isArray(data && data.records) ? data.records : []))
const normalized = rawList.map(item => this.normalizeProduct(item)).filter(item => !!item.goodsName)
this.productList = this.uniqueProducts(normalized)
} catch (error) {
this.productList = []
uni.showToast({
title: '商品数据获取失败',
icon: 'none'
})
} finally {
this.productLoading = false
}
},
async open() {
this.form = this.buildForm()
this.productKeyword = ''
this.merchantSearchKeyword = ''
this.merchantPickerValue = [0]
this.productList = []
this.showPopup = true
await this.getMerchantList()
if (!this.merchantList.length) {
return
}
const nearest = this.findNearestMerchant(this.merchantList)
const target = nearest || this.merchantList[0]
this.applyMerchant(target)
await this.getProductsByMerchant()
},
close() {
this.showPopup = false
this.showMerchantPicker = false
this.showProductPicker = false
},
openMerchantPicker() {
this.merchantPickerValue = [this.merchantPickerIndex]
this.showMerchantPicker = true
},
closeMerchantPicker() {
this.showMerchantPicker = false
},
onMerchantPickerChange(e) {
const value = e.detail && e.detail.value !== undefined ? e.detail.value : e.detail
const index = Array.isArray(value) ? value[0] : Number(value || 0)
this.merchantPickerValue = [index]
},
async confirmMerchantPicker() {
const index = this.merchantPickerValue[0] || 0
const options = this.merchantFilteredOptions
const selected = options[index]
if (!selected || selected.id === '' || selected.id === null || selected.id === undefined) {
return
}
this.applyMerchant(selected)
this.showMerchantPicker = false
this.form.list = [this.createEmptyItem()]
await this.getProductsByMerchant()
},
addItem() {
this.form.list.push(this.createEmptyItem())
},
removeItem(index) {
this.form.list.splice(index, 1)
if (!this.form.list.length) {
this.form.list.push(this.createEmptyItem())
}
},
async openProductPicker(index) {
if (!this.form.merchantId) {
uni.showToast({
title: '请先选择门店',
icon: 'none'
})
return
}
if (!this.productList.length) {
await this.getProductsByMerchant()
}
this.activeProductRowIndex = index
this.productKeyword = ''
this.showProductPicker = true
},
closeProductPicker() {
this.showProductPicker = false
},
selectProduct(item) {
const index = this.activeProductRowIndex
const row = this.form.list[index]
if (!row) return
this.$set(this.form.list, index, {
...row,
goodsCode: item.goodsCode || '',
goodsName: item.goodsName || '',
makerAbb: item.makerAbb || '',
spec: item.spec || '',
income: item.income || 0
})
this.showProductPicker = false
},
chooseReceipt() {
uni.chooseImage({
count: 1,
sourceType: ['album', 'camera'],
sizeType: ['original'],
success: async ({tempFilePaths}) => {
if (tempFilePaths && tempFilePaths[0]) {
const localPath = tempFilePaths[0]
this.form.receiptLocalPath = localPath
this.form.receiptUrl = ''
await this.uploadReceipt(localPath)
}
}
})
},
async uploadReceipt(localPath) {
if (!localPath) return ''
try {
this.receiptUploading = true
const uploadRes = await uploadImg(localPath)
const url = uploadRes && uploadRes.path ? uploadRes.path : ''
if (!url) {
throw new Error('upload failed')
}
this.form.receiptUrl = url
return url
} catch (error) {
this.form.receiptUrl = ''
uni.showToast({
title: '小票上传失败,请重试',
icon: 'none'
})
return ''
} finally {
this.receiptUploading = false
}
},
async ensureReceiptUrl() {
if (this.form.receiptUrl) return this.form.receiptUrl
const path = this.form.receiptLocalPath || ''
if (!path) return ''
if (/^https?:\/\//.test(path)) return path
return this.uploadReceipt(path)
},
validateBeforeSubmit() {
if (!this.form.merchantId) {
uni.showToast({
title: '请选择门店',
icon: 'none'
})
return null
}
const selectedRows = (this.form.list || [])
.map((item, index) => ({
...item,
_index: index
}))
.filter(item => item.goodsName || item.goodsCode)
if (!selectedRows.length) {
uni.showToast({
title: '请至少选择一个商品',
icon: 'none'
})
return null
}
for (let i = 0; i < selectedRows.length; i++) {
const current = selectedRows[i]
const rawNum = current.num === null || current.num === undefined ? '' : String(current.num).trim()
if (!rawNum) {
uni.showToast({
title: `请填写第${current._index + 1}项商品数量`,
icon: 'none'
})
return null
}
const num = Number(rawNum)
if (!Number.isFinite(num) || num <= 0 || num > 1000) {
uni.showToast({
title: `第${current._index + 1}项商品数量格式不正确`,
icon: 'none'
})
return null
}
}
if (!this.form.receiptLocalPath) {
uni.showToast({
title: '请上传小票',
icon: 'none'
})
return null
}
if (this.receiptUploading) {
uni.showToast({
title: '小票上传中,请稍后提交',
icon: 'none'
})
return null
}
return selectedRows
},
async submit() {
const selectedRows = this.validateBeforeSubmit()
if (!selectedRows) return
try {
uni.showLoading({
title: '提交中...'
})
const image = await this.ensureReceiptUrl()
if (!image) {
uni.hideLoading()
uni.showToast({
title: '小票上传失败',
icon: 'none'
})
return
}
const detailList = selectedRows.map(item => ({
goodsCode: item.goodsCode || '',
goodsName: item.goodsName || '',
num: Number(item.num) || 0,
spec: item.spec || '',
amount: '',
sourceCode: ''
}))
const payload = {
merchantName: this.form.merchantName,
merchantId: this.form.merchantId,
province: this.form.province,
city: this.form.city,
date: dayjs().format('YYYY-MM-DD'),
orderNo: '',
amount: '',
remark: this.form.remark || '',
lat: this.form.lat,
lng: this.form.lng,
image,
hasChange: true,
detailList
}
await shareAddReq(payload)
uni.hideLoading()
uni.showToast({
title: '提交成功',
icon: 'success'
})
this.close()
this.$emit('submitted')
} catch (error) {
uni.hideLoading()
uni.showToast({
title: '提交失败',
icon: 'none'
})
}
}
}
}
</script>
<style lang="scss" scoped>
.share-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 30;
}
.share-popup {
position: fixed;
left: 50%;
bottom: 40rpx;
transform: translateX(-50%);
width: 88%;
max-height: 70vh;
overflow: auto;
z-index: 40;
}
.popup-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.section-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.close-btn {
color: #6b7280;
font-size: 24rpx;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.label {
color: #111827;
font-size: 26rpx;
min-width: 140rpx;
}
.input {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #111827;
}
.form-value {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #111827;
word-break: break-all;
}
.total-bonus-row {
margin-top: 8rpx;
}
.total-bonus-value {
color: #ef4444;
font-weight: 700;
}
.picker-input {
flex: 1;
}
.picker-input-inner {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
min-height: 40rpx;
}
.picker-text {
color: #111827;
font-size: 26rpx;
}
.picker-placeholder {
color: #9ca3af;
font-size: 26rpx;
}
.detail-title {
margin-top: 14rpx;
font-weight: 700;
color: #111827;
}
.detail-actions {
margin-top: 8rpx;
display: flex;
justify-content: flex-end;
}
.ghost-btn {
color: #22c3a1;
border: 1rpx solid #22c3a1;
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 24rpx;
}
.detail-empty {
color: #6b7280;
text-align: center;
padding: 14rpx 0;
}
.detail-list {
margin-top: 8rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.detail-item {
border: 1rpx dashed #dfe8f5;
border-radius: 12rpx;
padding: 12rpx;
}
.detail-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
}
.detail-index {
color: #6b7280;
font-size: 24rpx;
}
.detail-remove {
color: #ff3b30;
font-size: 24rpx;
}
.receipt-upload {
margin-top: 10rpx;
border: 1rpx dashed #d1d5db;
border-radius: 12rpx;
min-height: 220rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
background: #f9fafb;
}
.receipt-image {
width: 100%;
height: 220rpx;
border-radius: 12rpx;
}
.receipt-tip {
color: #6b7280;
font-size: 24rpx;
}
.remark-wrap {
margin-top: 10rpx;
border: 1rpx solid #edf1f7;
border-radius: 12rpx;
padding: 10rpx 12rpx;
}
.textarea {
width: 100%;
min-height: 90rpx;
font-size: 26rpx;
color: #111827;
}
.submit {
margin-top: 16rpx;
padding: 14rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.merchant-picker,
.product-picker {
padding: 20rpx 24rpx 30rpx;
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.picker-action {
font-size: 26rpx;
color: #6b7280;
}
.picker-action.primary {
color: #2fc6a7;
font-weight: 700;
}
.picker-title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.merchant-picker-search {
margin: 6rpx 0 12rpx;
}
.merchant-search-input {
width: 100%;
background: #f3f4f6;
border-radius: 12rpx;
padding: 12rpx 16rpx;
font-size: 26rpx;
color: #1f2937;
}
.merchant-search-placeholder {
color: #9ca3af;
}
.merchant-picker-loading {
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.merchant-picker-view {
height: 360rpx;
}
.merchant-picker-item {
height: 44px;
line-height: 44px;
text-align: center;
font-size: 26rpx;
color: #1f2937;
}
.product-scroll {
max-height: 60vh;
}
.product-option {
padding: 16rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.product-option:last-child {
border-bottom: none;
}
.product-option-name {
font-size: 28rpx;
color: #111827;
font-weight: 700;
margin-bottom: 4rpx;
}
.product-option-meta {
font-size: 22rpx;
color: #4b5563;
margin-top: 2rpx;
}
</style>