575b4a723a
门店接口 /shop/merchant/groupByChain 一次返回 8105 家门店(约 1.1MB)。
此前把门店明细原样放进页面 data,data 树膨胀到 1118KB,导致此后每次
setData 都要重新 diff/序列化整棵树——即使载荷只有 1 个字段,耗时也从
约 490ms 涨到约 1000ms,首屏因此多花约 3.5s。
改为把明细挂在实例属性 merchantStore 上(不进页面 data),
merchantChainList 只保留 {id, text, merchantCount} 骨架,
新增 getMerchantsByChainId() / getVisibleMerchants() 按需取数。
涉及页面:
- packageUser/self-report/create.vue
- packageUser/chronic/index.vue
- pages/task/detail.vue
- pages/share/create.vue
- pages/share/index.vue
- pages/audit/components/AuditShareItem.vue
实测(微信开发者工具自动化):
- 页面 data 门店明细 1118KB -> 1KB
- 单字段 setData 998ms -> 10~53ms
- self-report/create 首屏 9055ms -> 5341ms
- 门店 picker 选项数不变(大参林 2700 项),搜索与选中回填均正常
1798 lines
57 KiB
Vue
1798 lines
57 KiB
Vue
<template>
|
||
<view class="share-create-page">
|
||
<AppNavbar title="我要分享" showBack/>
|
||
|
||
<view class="form-card card">
|
||
<u-image src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/%E6%99%92%E5%8D%95%E4%BF%A1%E6%81%AF.png"
|
||
mode="heightFix"
|
||
height="50rpx"/>
|
||
|
||
<view class="form-row merchant-row">
|
||
<view class="label">连锁门店</view>
|
||
<view class="picker-input" @tap="openMerchantPicker">
|
||
<view class="picker-input-inner">
|
||
<text :class="form.merchantId ? 'picker-text' : 'picker-placeholder'">
|
||
{{ form.merchantId ? `${form.chainName || '-'}/${form.merchantName || '-'}` : '请选择连锁和门店' }}
|
||
</text>
|
||
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<u-image src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/%E9%94%80%E9%87%8F%E5%A1%AB%E5%86%99.png"
|
||
mode="heightFix"
|
||
height="50rpx"/>
|
||
<view class="product-search">
|
||
<view class="search-box">
|
||
<u-icon name="search" size="28" color="#9ca3af"/>
|
||
<input
|
||
v-model="productKeyword"
|
||
class="search-input"
|
||
placeholder="搜索商品名称/编码/厂家/规格"
|
||
placeholder-class="search-placeholder"
|
||
/>
|
||
<text class="text-25">搜索</text>
|
||
</view>
|
||
</view>
|
||
<view class="product-panel">
|
||
<view class="category-sidebar">
|
||
<view
|
||
v-for="item in categoryTabs"
|
||
:key="item.key"
|
||
class="category-item"
|
||
:class="item.key === activeCategoryKey ? 'category-item-active' : ''"
|
||
@tap="selectCategory(item.key)"
|
||
>
|
||
{{ item.label }}
|
||
</view>
|
||
</view>
|
||
<view class="product-panel-main">
|
||
<view v-if="productLoading" class="detail-loading">
|
||
<u-loading-icon mode="circle"></u-loading-icon>
|
||
</view>
|
||
<view v-else-if="form.list.length === 0" class="detail-empty">暂无商品</view>
|
||
<view v-else-if="displayedProducts.length === 0" class="detail-empty">无匹配商品</view>
|
||
<view v-else class="detail-list">
|
||
<view
|
||
v-for="detail in displayedProducts"
|
||
:key="detail._key"
|
||
class="detail-item"
|
||
>
|
||
<view class="detail-name">{{ detail.goodsName || '-' }}</view>
|
||
<view class="detail-divider"></view>
|
||
<view class="detail-body">
|
||
<view class="detail-thumb-wrap">
|
||
<image v-if="detail.goodsImage" class="detail-thumb" :src="detail.goodsImage" mode="aspectFit"/>
|
||
<view v-else class="detail-thumb-placeholder">暂无图片</view>
|
||
</view>
|
||
<view class="detail-main">
|
||
<view class="detail-head">
|
||
|
||
<input
|
||
:value="detail.num"
|
||
class="qty-input"
|
||
type="number"
|
||
placeholder="填写数量"
|
||
placeholder-style="color:#ff7a5e;"
|
||
@input="onQuantityInput(detail, $event)"
|
||
/>
|
||
</view>
|
||
<view class="detail-meta">厂家:{{ detail.makerAbb || '-' }}</view>
|
||
<view class="detail-meta">规格:{{ detail.spec || '-' }}</view>
|
||
<!-- <view class="detail-meta">-->
|
||
<!-- 分享奖励:-->
|
||
<!-- <template v-if="hasMerchantIncome(detail)">-->
|
||
<!-- <text-->
|
||
<!-- v-if="getBaseIncome(detail) > 0"-->
|
||
<!-- class="reward-value reward-value-line"-->
|
||
<!-- >¥{{ formatAmount(getBaseIncome(detail)) }}</text>-->
|
||
<!-- <text class="reward-value reward-value-merchant">¥{{ formatAmount(getMerchantIncome(detail)) }}</text>-->
|
||
<!-- </template>-->
|
||
<!-- <text v-else class="reward-value">¥{{ formatAmount(getEffectiveIncome(detail)) }}</text>-->
|
||
<!-- <u-icon-->
|
||
<!-- v-if="hasPeriod(detail.startTime, detail.endTime)"-->
|
||
<!-- class="reward-time-icon"-->
|
||
<!-- name="info-circle"-->
|
||
<!-- size="28rpx"-->
|
||
<!-- color="#4b5563"-->
|
||
<!-- @tap.stop="showRewardPeriodModal('share', detail)"-->
|
||
<!-- />-->
|
||
<!-- </view>-->
|
||
<!-- <view class="detail-meta">-->
|
||
<!-- 额外奖励:<text class="reward-value">¥{{ formatAmount(getEffectiveExtraIncome(detail)) }}</text>-->
|
||
<!-- <u-icon-->
|
||
<!-- v-if="hasExtraIncomePeriod(detail)"-->
|
||
<!-- class="reward-time-icon"-->
|
||
<!-- name="info-circle"-->
|
||
<!-- size="28rpx"-->
|
||
<!-- color="#4b5563"-->
|
||
<!-- @tap.stop="showRewardPeriodModal('extra', detail)"-->
|
||
<!-- />-->
|
||
<!-- </view>-->
|
||
<!-- <view class="detail-meta">编码:{{ detail.goodsCode || '-' }}</view>-->
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="selected-products">
|
||
<view class="selected-products-title">已选择商品列表</view>
|
||
<view v-if="selectedProductRows.length === 0" class="selected-products-empty">暂未选择商品</view>
|
||
<view v-else class="selected-products-list">
|
||
<view
|
||
v-for="item in selectedProductRows"
|
||
:key="item._key"
|
||
class="selected-product-item"
|
||
>
|
||
<view class="selected-product-name">{{ item.goodsName || '-' }}</view>
|
||
<view class="selected-product-meta">
|
||
<text class="selected-product-qty">数量:{{ item._num }}</text>
|
||
<!-- <text class="selected-product-amount">红包:¥{{ item._bonusAmount.toFixed(2) }}</text>-->
|
||
</view>
|
||
<!-- <view v-if="item._extraBonusAmount > 0" class="selected-product-extra">-->
|
||
<!-- 含额外奖励:¥{{ item._extraBonusAmount.toFixed(2) }}-->
|
||
<!-- </view>-->
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- <view class="form-row total-bonus-row mb-20">-->
|
||
<!-- <view class="label">总红包金额</view>-->
|
||
<!-- <view class="total-bonus-wrap">-->
|
||
<!-- <view class="form-value total-bonus-value">¥{{ totalBonusDisplay }}</view>-->
|
||
<!-- <view class="total-bonus-breakdown">-->
|
||
<!-- 红包金额:¥{{ totalIncomeBonusDisplay }},额外奖励:¥{{ totalExtraBonusDisplay }}-->
|
||
<!-- </view>-->
|
||
<!-- </view>-->
|
||
<!-- </view>-->
|
||
|
||
<u-image src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/%E4%B8%8A%E4%BC%A0%E5%B0%8F%E7%A5%A8.png"
|
||
mode="heightFix"
|
||
height="50rpx"/>
|
||
<view class="my-15 text-25 text-red">仅能上传一张小票</view>
|
||
<view class="receipt-upload mb-20" @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>
|
||
|
||
<u-image src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/%E7%95%99%E8%A8%80.png"
|
||
mode="heightFix"
|
||
height="50rpx"/>
|
||
<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 merchantChainOptions" :key="idx" class="merchant-picker-item">{{
|
||
item.text
|
||
}}
|
||
</view>
|
||
</picker-view-column>
|
||
<picker-view-column>
|
||
<view v-for="(item, idx) in merchantStoreOptions" :key="idx" class="merchant-picker-item">{{
|
||
item.text
|
||
}}
|
||
</view>
|
||
</picker-view-column>
|
||
</picker-view>
|
||
</view>
|
||
</u-popup>
|
||
|
||
<!-- <view class="page-side-tools">-->
|
||
<!-- <view class="page-tool-btn" @tap="scrollToTop">-->
|
||
<!-- <u-icon name="arrow-up" size="30" color="#2fc6a7"/>-->
|
||
<!-- </view>-->
|
||
<!-- <view class="page-tool-btn" @tap="scrollToBottom">-->
|
||
<!-- <u-icon name="arrow-down" size="30" color="#2fc6a7"/>-->
|
||
<!-- </view>-->
|
||
<!-- </view>-->
|
||
<view id="page-bottom-anchor" class="page-bottom-anchor"></view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import dayjs from 'dayjs'
|
||
import {calcDistanceMeters, uploadImg} from "@/util";
|
||
import {merchantGroupByChainReq} from "@/api/shop";
|
||
import {shareAddReq} from "@/api/share";
|
||
import {productGroupByCateReq} from "@/api/product";
|
||
import AppNavbar from "@/components/AppNavbar.vue";
|
||
|
||
export default {
|
||
components: {AppNavbar},
|
||
data() {
|
||
return {
|
||
lat: null,
|
||
lng: null,
|
||
currentProvince: '',
|
||
currentCity: '',
|
||
showMerchantPicker: false,
|
||
merchantLoading: false,
|
||
productLoading: false,
|
||
merchantChainList: [],
|
||
merchantSearchKeyword: '',
|
||
merchantPickerValue: [0, 0],
|
||
productKeyword: '',
|
||
categoryTabs: [],
|
||
activeCategoryKey: '',
|
||
receiptUploading: false,
|
||
form: this.buildForm()
|
||
}
|
||
},
|
||
computed: {
|
||
merchantChainOptions() {
|
||
const list = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
|
||
const keyword = (this.merchantSearchKeyword || '').trim()
|
||
if (!keyword) return list.length ? list : [{id: '', text: '暂无连锁', merchantCount: 0}]
|
||
const filtered = list
|
||
.map(chain => {
|
||
const visible = this.getVisibleMerchants(chain)
|
||
if (!visible.length) return null
|
||
return Object.assign({}, chain, {merchantCount: visible.length})
|
||
})
|
||
.filter(Boolean)
|
||
return filtered.length ? filtered : [{id: '', text: '无匹配连锁', merchantCount: 0}]
|
||
},
|
||
merchantStoreOptions() {
|
||
const chainIndex = Number(this.merchantPickerValue[0] || 0)
|
||
const currentChain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
|
||
const merchants = this.getVisibleMerchants(currentChain)
|
||
return merchants.length ? merchants : [{id: '', text: '无门店'}]
|
||
},
|
||
// 门店明细只存在实例的 merchantStore 上(不进页面 data),按连锁 id 取
|
||
merchantPickerIndexes() {
|
||
const chainList = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
|
||
if (!chainList.length) return [0, 0]
|
||
let chainIndex = chainList.findIndex(item => item.id === this.form.chainId)
|
||
if (chainIndex < 0 && this.form.merchantId) {
|
||
chainIndex = chainList.findIndex(item => {
|
||
const merchants = this.getMerchantsByChainId(item.id)
|
||
return merchants.some(merchant => merchant.id === this.form.merchantId)
|
||
})
|
||
}
|
||
if (chainIndex < 0) chainIndex = 0
|
||
const merchants = this.getMerchantsByChainId(chainList[chainIndex].id)
|
||
let merchantIndex = merchants.findIndex(item => item.id === this.form.merchantId)
|
||
if (merchantIndex < 0) merchantIndex = 0
|
||
return [chainIndex, merchantIndex]
|
||
},
|
||
merchantIndicatorStyle() {
|
||
return 'height: 64px;'
|
||
},
|
||
displayedProducts() {
|
||
const currentTab = (this.categoryTabs || []).find(item => item.key === this.activeCategoryKey)
|
||
const list = currentTab && Array.isArray(currentTab.products)
|
||
? currentTab.products
|
||
: (Array.isArray(this.form.list) ? this.form.list : [])
|
||
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)
|
||
})
|
||
},
|
||
selectedProductRows() {
|
||
const list = Array.isArray(this.form.list) ? this.form.list : []
|
||
return list
|
||
.map(item => {
|
||
const num = this.parseQuantity(item.num)
|
||
const validNum = Number.isFinite(num) && num > 0 ? num : 0
|
||
const income = this.getEffectiveIncome(item)
|
||
const extraIncome = this.getEffectiveExtraIncome(item)
|
||
const singleBonus = income + extraIncome
|
||
return {
|
||
...item,
|
||
_num: validNum || NaN,
|
||
_baseBonusAmount: income * validNum,
|
||
_extraBonusAmount: extraIncome * validNum,
|
||
_bonusAmount: singleBonus * validNum
|
||
}
|
||
})
|
||
.filter(item => Number.isFinite(item._num) && item._num > 0)
|
||
},
|
||
totalIncomeBonus() {
|
||
return this.selectedProductRows.reduce((sum, item) => sum + (Number(item._baseBonusAmount) || 0), 0)
|
||
},
|
||
totalExtraBonus() {
|
||
return this.selectedProductRows.reduce((sum, item) => sum + (Number(item._extraBonusAmount) || 0), 0)
|
||
},
|
||
totalBonus() {
|
||
return this.selectedProductRows.reduce((sum, item) => sum + (Number(item._bonusAmount) || 0), 0)
|
||
},
|
||
totalIncomeBonusDisplay() {
|
||
return this.totalIncomeBonus.toFixed(2)
|
||
},
|
||
totalExtraBonusDisplay() {
|
||
return this.totalExtraBonus.toFixed(2)
|
||
},
|
||
totalBonusDisplay() {
|
||
return this.totalBonus.toFixed(2)
|
||
}
|
||
},
|
||
watch: {
|
||
merchantSearchKeyword() {
|
||
this.merchantPickerValue = [0, 0]
|
||
}
|
||
},
|
||
created() {
|
||
// 门店明细挂实例上而非 data:data 里的一切都会进页面 data 树并被 setData 序列化
|
||
this.merchantStore = []
|
||
},
|
||
onLoad(options = {}) {
|
||
this.lat = this.parseNumber(options.lat)
|
||
this.lng = this.parseNumber(options.lng)
|
||
this.currentProvince = this.decodeText(options.province)
|
||
this.currentCity = this.decodeText(options.city)
|
||
this.init()
|
||
},
|
||
methods: {
|
||
// 门店明细只存在实例的 merchantStore 上(不进页面 data),按连锁 id 取
|
||
getMerchantsByChainId(chainId) {
|
||
const store = Array.isArray(this.merchantStore) ? this.merchantStore : []
|
||
const chain = store.find(item => String(item.id) === String(chainId))
|
||
return chain && Array.isArray(chain.merchants) ? chain.merchants : []
|
||
},
|
||
// 搜索关键词命中的门店:连锁名命中则整列保留,否则按门店名过滤
|
||
getVisibleMerchants(chain) {
|
||
const merchants = this.getMerchantsByChainId(chain && chain.id)
|
||
const keyword = (this.merchantSearchKeyword || '').trim()
|
||
if (!keyword) return merchants
|
||
if ((chain.text || '').includes(keyword)) return merchants
|
||
return merchants.filter(merchant => (merchant.text || '').includes(keyword))
|
||
},
|
||
parseNumber(value) {
|
||
if (value === '' || value === null || value === undefined) return null
|
||
const n = Number(value)
|
||
return Number.isFinite(n) ? n : null
|
||
},
|
||
decodeText(value) {
|
||
if (!value) return ''
|
||
try {
|
||
return decodeURIComponent(value)
|
||
} catch (err) {
|
||
return value
|
||
}
|
||
},
|
||
buildForm() {
|
||
return {
|
||
chainName: '',
|
||
chainId: '',
|
||
merchantName: '',
|
||
merchantId: '',
|
||
province: '',
|
||
city: '',
|
||
lat: '',
|
||
lng: '',
|
||
remark: '',
|
||
receiptLocalPath: '',
|
||
receiptUrl: '',
|
||
list: []
|
||
}
|
||
},
|
||
normalizeMerchantChain(item, index) {
|
||
const chainName = this.pickFirstText(item, ['chainName', 'chain', 'chainAbb', 'name', 'title']) || `连锁${index + 1}`
|
||
const rawChainId = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? chainName
|
||
const chainId = rawChainId === undefined || rawChainId === null || String(rawChainId).trim() === ''
|
||
? `chain_${index}`
|
||
: rawChainId
|
||
const childCandidates = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
|
||
let rawChildren = []
|
||
for (let i = 0; i < childCandidates.length; i++) {
|
||
const list = item[childCandidates[i]]
|
||
if (Array.isArray(list) && list.length) {
|
||
rawChildren = list
|
||
break
|
||
}
|
||
}
|
||
let merchants = rawChildren
|
||
.map(child => this.normalizeMerchant(child))
|
||
.filter(child => child.id !== undefined && child.id !== null && String(child.id).trim() !== '')
|
||
if (!merchants.length) {
|
||
const selfMerchant = this.normalizeMerchant(item)
|
||
if (selfMerchant.id !== undefined && selfMerchant.id !== null && String(selfMerchant.id).trim() !== '') {
|
||
merchants = [selfMerchant]
|
||
}
|
||
}
|
||
return {
|
||
id: chainId,
|
||
text: chainName,
|
||
merchants
|
||
}
|
||
},
|
||
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 || ''
|
||
}
|
||
},
|
||
flattenMerchantsFromChains(chainList) {
|
||
const rows = []
|
||
// chainList 可能只是骨架(无 merchants),明细统一从实例的 merchantStore 取
|
||
;(Array.isArray(chainList) ? chainList : []).forEach(chain => {
|
||
const merchants = this.getMerchantsByChainId(chain && chain.id)
|
||
merchants.forEach(merchant => {
|
||
rows.push({
|
||
...merchant,
|
||
chainId: chain.id,
|
||
chainName: chain.text
|
||
})
|
||
})
|
||
})
|
||
return rows
|
||
},
|
||
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(chain, merchant) {
|
||
if (!merchant) return
|
||
this.form.chainName = (chain && chain.text) || merchant.chainName || ''
|
||
this.form.chainId = (chain && chain.id) ?? merchant.chainId ?? ''
|
||
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 : ''
|
||
},
|
||
pickFirstText(source, keys = []) {
|
||
if (!source || typeof source !== 'object') return ''
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const value = source[keys[i]]
|
||
if (value !== undefined && value !== null && String(value).trim()) {
|
||
return String(value).trim()
|
||
}
|
||
}
|
||
return ''
|
||
},
|
||
unwrapList(data) {
|
||
if (Array.isArray(data)) return data
|
||
if (!data || typeof data !== 'object') return []
|
||
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const value = data[keys[i]]
|
||
if (Array.isArray(value)) return value
|
||
if (value && typeof value === 'object') {
|
||
const nested = this.unwrapList(value)
|
||
if (nested.length) return nested
|
||
}
|
||
}
|
||
return [data]
|
||
},
|
||
isProductNode(node) {
|
||
if (!node || typeof node !== 'object') return false
|
||
const product = node.product && typeof node.product === 'object' ? node.product : {}
|
||
const hasChildrenOnly = Array.isArray(node.children)
|
||
&& !this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
|
||
&& !node.product
|
||
if (hasChildrenOnly) return false
|
||
return !!(
|
||
this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode']) ||
|
||
this.pickFirstText(product, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
|
||
)
|
||
},
|
||
collectProductNodes(node, depth = 0) {
|
||
if (!node || depth > 4) return []
|
||
if (Array.isArray(node)) {
|
||
return node.reduce((acc, item) => acc.concat(this.collectProductNodes(item, depth + 1)), [])
|
||
}
|
||
if (typeof node !== 'object') return []
|
||
if (this.isProductNode(node)) return [node]
|
||
const keys = ['productList', 'products', 'list', 'records', 'rows', 'items', 'children', 'data']
|
||
return keys.reduce((acc, key) => {
|
||
const value = node[key]
|
||
if (Array.isArray(value) || (value && typeof value === 'object')) {
|
||
return acc.concat(this.collectProductNodes(value, depth + 1))
|
||
}
|
||
return acc
|
||
}, [])
|
||
},
|
||
buildCategoryTabs(rawData) {
|
||
const rootList = this.unwrapList(rawData)
|
||
const tabs = []
|
||
rootList.forEach((categoryNode, categoryIndex) => {
|
||
if (!categoryNode || typeof categoryNode !== 'object') return
|
||
const label = categoryNode.cate
|
||
const products = Array.isArray(categoryNode.children) ? categoryNode.children : []
|
||
if (!label || !products.length) return
|
||
tabs.push({
|
||
key: `cate_children_${categoryIndex}`,
|
||
label,
|
||
rawProducts: products
|
||
})
|
||
})
|
||
if (tabs.length) return tabs
|
||
rootList.forEach((chainNode, chainIndex) => {
|
||
if (!chainNode || typeof chainNode !== 'object' || this.isProductNode(chainNode)) return
|
||
const hasChainField = !!this.pickFirstText(chainNode, ['chainName', 'chain', 'chainAbb', 'chainLabel', 'chainTitle'])
|
||
const isCategoryRoot = !!this.pickFirstText(chainNode, ['categoryName', 'cateName', 'category', 'categoryTitle'])
|
||
const chainLabel = this.pickFirstText(chainNode, ['chainName', 'chain', 'chainAbb', 'chainLabel', 'chainTitle', 'name', 'title'])
|
||
if (hasChainField && chainLabel) {
|
||
const chainProducts = this.collectProductNodes(chainNode)
|
||
if (chainProducts.length) {
|
||
tabs.push({
|
||
key: `chain_${chainIndex}`,
|
||
label: chainLabel,
|
||
rawProducts: chainProducts
|
||
})
|
||
}
|
||
}
|
||
const categoryCandidates = []
|
||
;['categoryList', 'categories', 'cateList', 'children', 'list'].forEach(key => {
|
||
const value = chainNode[key]
|
||
if (!Array.isArray(value)) return
|
||
value.forEach(item => {
|
||
if (item && typeof item === 'object' && !this.isProductNode(item)) {
|
||
categoryCandidates.push(item)
|
||
}
|
||
})
|
||
})
|
||
if (!categoryCandidates.length && (!hasChainField || isCategoryRoot)) {
|
||
const label = this.pickFirstText(chainNode, ['categoryName', 'cateName', 'category', 'categoryTitle', 'name', 'title'])
|
||
const products = this.collectProductNodes(chainNode)
|
||
if (label && products.length) {
|
||
tabs.push({
|
||
key: `cate_root_${chainIndex}`,
|
||
label,
|
||
rawProducts: products
|
||
})
|
||
}
|
||
return
|
||
}
|
||
categoryCandidates.forEach((categoryNode, categoryIndex) => {
|
||
const label = this.pickFirstText(categoryNode, ['categoryName', 'cateName', 'category', 'categoryTitle', 'name', 'title'])
|
||
const products = this.collectProductNodes(categoryNode)
|
||
if (!label || !products.length) return
|
||
tabs.push({
|
||
key: `cate_${chainIndex}_${categoryIndex}`,
|
||
label,
|
||
rawProducts: products
|
||
})
|
||
})
|
||
})
|
||
if (!tabs.length) {
|
||
const allProducts = this.collectProductNodes(rootList)
|
||
if (allProducts.length) {
|
||
tabs.push({
|
||
key: 'all',
|
||
label: '全部',
|
||
rawProducts: allProducts
|
||
})
|
||
}
|
||
}
|
||
return tabs
|
||
},
|
||
normalizeProduct(item) {
|
||
const product = item && item.product ? item.product : {}
|
||
const pickValue = (...values) => {
|
||
for (let i = 0; i < values.length; i++) {
|
||
const value = values[i]
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
return value
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
const code = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || product.batchCode || ''
|
||
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || product.goodsId || product.skuId || ''
|
||
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 goodsImage = item.goodsImage || item.goodsImg || item.productImage || item.productImg || item.image || item.imageUrl || item.img || item.imgUrl || item.pic || item.picUrl || item.picture || item.cover || item.coverUrl || product.goodsImage || product.goodsImg || product.productImage || product.productImg || product.image || product.imageUrl || product.img || product.imgUrl || product.pic || product.picUrl || product.picture || product.cover || product.coverUrl || ''
|
||
const incomeRaw = product.income !== undefined && product.income !== null
|
||
? product.income
|
||
: (item.income !== undefined && item.income !== null ? item.income : (item.shareIncome || item.reward || 0))
|
||
const merchantIncomeRaw = pickValue(
|
||
item.merchantIncome,
|
||
item.merchant_income,
|
||
product.merchantIncome,
|
||
product.merchant_income
|
||
)
|
||
const extraIncomeRaw = product.extraIncome !== undefined && product.extraIncome !== null
|
||
? product.extraIncome
|
||
: (item.extraIncome !== undefined && item.extraIncome !== null ? item.extraIncome : (item.extraReward || item.extraBonus || item.otherIncome || 0))
|
||
const extraIncomeListRaw = pickValue(
|
||
item.extraIncomeList,
|
||
item.extra_income_list,
|
||
product.extraIncomeList,
|
||
product.extra_income_list
|
||
)
|
||
const startTime = pickValue(item.startTime, item.start_time, product.startTime, product.start_time)
|
||
const endTime = pickValue(item.endTime, item.end_time, product.endTime, product.end_time)
|
||
const extraStartTime = pickValue(item.extraStartTime, item.extra_start_time, product.extraStartTime, product.extra_start_time)
|
||
const extraEndTime = pickValue(item.extraEndTime, item.extra_end_time, product.extraEndTime, product.extra_end_time)
|
||
const income = Number(incomeRaw) || 0
|
||
const merchantIncome = merchantIncomeRaw === '' ? undefined : Number(merchantIncomeRaw)
|
||
const extraIncome = Number(extraIncomeRaw) || 0
|
||
const extraIncomeList = this.normalizeExtraIncomeList(extraIncomeListRaw)
|
||
return {
|
||
productId,
|
||
goodsCode: code,
|
||
goodsName: name,
|
||
makerAbb: maker,
|
||
spec,
|
||
cate: product.cate,
|
||
goodsImage,
|
||
income,
|
||
merchantIncome: Number.isFinite(merchantIncome) ? merchantIncome : undefined,
|
||
extraIncome,
|
||
extraIncomeList,
|
||
startTime,
|
||
endTime,
|
||
extraStartTime,
|
||
extraEndTime,
|
||
num: ''
|
||
}
|
||
},
|
||
normalizeExtraIncomeList(rawValue) {
|
||
const parseText = (text) => {
|
||
try {
|
||
return JSON.parse(text)
|
||
} catch (err) {
|
||
try {
|
||
const normalized = text
|
||
.replace(/^'/, '"')
|
||
.replace(/'$/, '"')
|
||
.replace(/([{,]\s*)'([^']+?)'\s*:/g, '$1"$2":')
|
||
.replace(/:\s*'([^']*?)'(\s*[},])/g, ':"$1"$2')
|
||
return JSON.parse(normalized)
|
||
} catch (error) {
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
const objectToList = (obj) => {
|
||
if (!obj || typeof obj !== 'object') return []
|
||
const wrapped = this.unwrapList(obj)
|
||
if (Array.isArray(wrapped) && wrapped.length && wrapped[0] !== obj) return wrapped
|
||
if (Array.isArray(obj.list)) return obj.list
|
||
const values = Object.keys(obj)
|
||
.sort((a, b) => Number(a) - Number(b))
|
||
.map(key => obj[key])
|
||
const objectValues = values.filter(val => val && typeof val === 'object')
|
||
if (objectValues.length) return objectValues
|
||
if (obj.startTime || obj.start_time || obj.extraStartTime || obj.extra_start_time || obj.income !== undefined) {
|
||
return [obj]
|
||
}
|
||
return []
|
||
}
|
||
let current = rawValue
|
||
for (let i = 0; i < 3; i++) {
|
||
if (Array.isArray(current)) return current
|
||
if (current === undefined || current === null) return []
|
||
if (typeof current === 'object') return objectToList(current)
|
||
const text = String(current).trim()
|
||
if (!text) return []
|
||
const parsed = parseText(text)
|
||
if (parsed === null) return []
|
||
current = parsed
|
||
}
|
||
return []
|
||
},
|
||
normalizeExtraIncomeItem(item) {
|
||
if (!item || typeof item !== 'object') {
|
||
return {
|
||
income: 0,
|
||
startTime: '',
|
||
endTime: ''
|
||
}
|
||
}
|
||
const incomeRaw = item.income ?? item.extraIncome ?? item.amount ?? item.reward ?? item.bonus ?? item.value ?? 0
|
||
const startTime = item.startTime ?? item.start_time ?? item.extraStartTime ?? item.extra_start_time ?? item.beginTime ?? item.begin_time ?? ''
|
||
const endTime = item.endTime ?? item.end_time ?? item.extraEndTime ?? item.extra_end_time ?? item.finishTime ?? item.finish_time ?? ''
|
||
const income = Number(incomeRaw)
|
||
return {
|
||
income: Number.isFinite(income) ? income : 0,
|
||
startTime,
|
||
endTime
|
||
}
|
||
},
|
||
getExtraIncomeDisplayPeriods(detail = {}) {
|
||
const list = this.normalizeExtraIncomeList(detail.extraIncomeList)
|
||
.map(item => this.normalizeExtraIncomeItem(item))
|
||
.filter(item => this.hasPeriod(item.startTime, item.endTime))
|
||
if (list.length) return list
|
||
if (!this.hasPeriod(detail.extraStartTime, detail.extraEndTime)) return []
|
||
return [{
|
||
startTime: detail.extraStartTime,
|
||
endTime: detail.extraEndTime,
|
||
income: Number(detail.extraIncome) || 0
|
||
}]
|
||
},
|
||
getExtraIncomePeriods(detail = {}) {
|
||
const list = this.getExtraIncomeDisplayPeriods(detail)
|
||
.filter(item => (Number(item.income) || 0) > 0)
|
||
if (list.length) return list
|
||
return []
|
||
},
|
||
hasExtraIncomePeriod(detail = {}) {
|
||
return this.getExtraIncomeDisplayPeriods(detail).length > 0
|
||
},
|
||
formatAmount(value) {
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num.toFixed(1) : '0.0'
|
||
},
|
||
hasPeriod(startTime, endTime) {
|
||
const hasStart = startTime !== undefined && startTime !== null && String(startTime).trim() !== ''
|
||
const hasEnd = endTime !== undefined && endTime !== null && String(endTime).trim() !== ''
|
||
return hasStart && hasEnd
|
||
},
|
||
parsePeriodToDayjs(value) {
|
||
if (value === undefined || value === null) return null
|
||
const raw = String(value).trim()
|
||
if (!raw) return null
|
||
if (/^-?\d+(\.\d+)?$/.test(raw)) {
|
||
const num = Number(raw)
|
||
if (!Number.isFinite(num)) return null
|
||
const millis = Math.abs(num) < 1e12 ? num * 1000 : num
|
||
const parsedNum = dayjs(millis)
|
||
return parsedNum.isValid() ? parsedNum : null
|
||
}
|
||
const parsedText = dayjs(raw)
|
||
return parsedText.isValid() ? parsedText : null
|
||
},
|
||
isCurrentInPeriod(startTime, endTime) {
|
||
if (!this.hasPeriod(startTime, endTime)) return true
|
||
const start = this.parsePeriodToDayjs(startTime)
|
||
const end = this.parsePeriodToDayjs(endTime)
|
||
if (!start || !end) return true
|
||
const now = dayjs()
|
||
return (now.isAfter(start) || now.isSame(start)) && (now.isBefore(end) || now.isSame(end))
|
||
},
|
||
isCurrentInPeriodStrict(startTime, endTime) {
|
||
if (!this.hasPeriod(startTime, endTime)) return false
|
||
const start = this.parsePeriodToDayjs(startTime)
|
||
const end = this.parsePeriodToDayjs(endTime)
|
||
if (!start || !end) return false
|
||
const now = dayjs()
|
||
return (now.isAfter(start) || now.isSame(start)) && (now.isBefore(end) || now.isSame(end))
|
||
},
|
||
getBaseIncome(detail = {}) {
|
||
const amount = Number(detail.income)
|
||
return Number.isFinite(amount) && amount >= 0 ? amount : 0
|
||
},
|
||
hasMerchantIncome(detail = {}) {
|
||
const value = detail.merchantIncome
|
||
return value !== undefined && value !== null && String(value).trim() !== '' && Number.isFinite(Number(value))
|
||
},
|
||
getMerchantIncome(detail = {}) {
|
||
if (!this.hasMerchantIncome(detail)) return 0
|
||
const amount = Number(detail.merchantIncome)
|
||
return Number.isFinite(amount) ? amount : 0
|
||
},
|
||
getEffectiveIncome(detail = {}) {
|
||
if (this.hasMerchantIncome(detail)) {
|
||
return this.getMerchantIncome(detail)
|
||
}
|
||
const base = this.getBaseIncome(detail)
|
||
return this.isCurrentInPeriod(detail.startTime, detail.endTime) ? base : 0
|
||
},
|
||
getSubmitAmount(detail = {}) {
|
||
if (this.hasMerchantIncome(detail)) {
|
||
return this.getMerchantIncome(detail)
|
||
}
|
||
return this.getEffectiveIncome(detail)
|
||
},
|
||
getEffectiveExtraIncome(detail = {}) {
|
||
const periods = this.getExtraIncomePeriods(detail)
|
||
if (!periods.length) return 0
|
||
return periods.reduce((sum, item) => {
|
||
if (!this.isCurrentInPeriodStrict(item.startTime, item.endTime)) return sum
|
||
return sum + (Number(item.income) || 0)
|
||
}, 0)
|
||
},
|
||
formatPeriodTime(value) {
|
||
if (value === undefined || value === null) return '-'
|
||
const raw = String(value).trim()
|
||
if (!raw) return '-'
|
||
if (/^-?\d+(\.\d+)?$/.test(raw)) {
|
||
const num = Number(raw)
|
||
if (Number.isFinite(num)) {
|
||
const absNum = Math.abs(num)
|
||
const millis = absNum < 1e12 ? num * 1000 : num
|
||
const parsedNum = dayjs(millis)
|
||
if (parsedNum.isValid()) return parsedNum.format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
}
|
||
const parsedText = dayjs(raw)
|
||
if (parsedText.isValid()) return parsedText.format('YYYY-MM-DD HH:mm:ss')
|
||
return raw
|
||
},
|
||
showRewardPeriodModal(type, detail = {}) {
|
||
const isExtra = type === 'extra'
|
||
const title = '有效期'
|
||
let content = ''
|
||
if (isExtra) {
|
||
const periods = this.getExtraIncomeDisplayPeriods(detail)
|
||
if (!periods.length) return
|
||
content = periods
|
||
.map(item => `${this.formatPeriodTime(item.startTime)} 至 ${this.formatPeriodTime(item.endTime)}(¥${this.formatAmount(item.income)})`)
|
||
.join('\n')
|
||
} else {
|
||
const startTime = detail.startTime
|
||
const endTime = detail.endTime
|
||
if (!this.hasPeriod(startTime, endTime)) return
|
||
content = `${this.formatPeriodTime(startTime)} 至 ${this.formatPeriodTime(endTime)}`
|
||
}
|
||
uni.showModal({
|
||
title,
|
||
content,
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
})
|
||
},
|
||
selectCategory(key) {
|
||
this.activeCategoryKey = key
|
||
},
|
||
async init() {
|
||
this.form = this.buildForm()
|
||
this.productKeyword = ''
|
||
this.categoryTabs = []
|
||
this.activeCategoryKey = ''
|
||
await this.getMerchantList()
|
||
if (!this.merchantChainList.length) {
|
||
return
|
||
}
|
||
const merchantRows = this.flattenMerchantsFromChains(this.merchantChainList)
|
||
if (!merchantRows.length) return
|
||
const nearest = this.findNearestMerchant(merchantRows)
|
||
const target = nearest || merchantRows[0]
|
||
const chain = this.merchantChainList.find(item => item.id === target.chainId) || null
|
||
this.applyMerchant(chain, target)
|
||
await this.getProductsByMerchant()
|
||
},
|
||
async getMerchantList() {
|
||
this.merchantLoading = true
|
||
try {
|
||
const {data} = await merchantGroupByChainReq({})
|
||
const list = this.unwrapList(data)
|
||
const chains = list
|
||
.map((item, index) => this.normalizeMerchantChain(item, index))
|
||
.filter(item => Array.isArray(item.merchants) && item.merchants.length > 0)
|
||
this.setMerchantChains(chains)
|
||
} catch (error) {
|
||
this.setMerchantChains([])
|
||
uni.showToast({
|
||
title: '门店列表获取失败',
|
||
icon: 'none'
|
||
})
|
||
} finally {
|
||
this.merchantLoading = false
|
||
}
|
||
},
|
||
// 门店明细(全量可达 8000+ 条)只放在实例上,不进页面 data。
|
||
// 一旦放进 data,页面 data 树会膨胀到 1MB+,此后每次 setData 都要重新
|
||
// diff/序列化整棵树,连只含一个字段的 setData 也会从 ~490ms 涨到 ~1000ms。
|
||
setMerchantChains(chains) {
|
||
const list = Array.isArray(chains) ? chains : []
|
||
this.merchantStore = list.map(chain => ({
|
||
id: chain.id,
|
||
text: chain.text,
|
||
merchants: Array.isArray(chain.merchants) ? chain.merchants : []
|
||
}))
|
||
this.merchantChainList = this.merchantStore.map(chain => ({
|
||
id: chain.id,
|
||
text: chain.text,
|
||
merchantCount: chain.merchants.length
|
||
}))
|
||
},
|
||
async getProductsByMerchant() {
|
||
if (!this.form.merchantId) {
|
||
this.form.list = []
|
||
this.categoryTabs = []
|
||
this.activeCategoryKey = ''
|
||
return
|
||
}
|
||
this.productLoading = true
|
||
try {
|
||
const {data} = await productGroupByCateReq({type: 1, chain: this.form.chainName, merchantId: this.form.merchantId})
|
||
const rawTabs = this.buildCategoryTabs(data)
|
||
const productMap = {}
|
||
let rowSeed = 0
|
||
const productList = []
|
||
const tabs = []
|
||
rawTabs.forEach((tab, tabIndex) => {
|
||
const refs = []
|
||
;(tab.rawProducts || []).forEach((rawItem, itemIndex) => {
|
||
const product = this.normalizeProduct(rawItem)
|
||
if (!product.goodsName) return
|
||
const mapRaw = product.productId || product.goodsCode || ''
|
||
const mapKey = mapRaw ? `product_${String(mapRaw)}` : ''
|
||
if (mapKey && productMap[mapKey]) {
|
||
refs.push(productMap[mapKey])
|
||
return
|
||
}
|
||
const row = {
|
||
...product,
|
||
_key: `row_${tabIndex}_${itemIndex}_${rowSeed++}`
|
||
}
|
||
if (mapKey) productMap[mapKey] = row
|
||
productList.push(row)
|
||
refs.push(row)
|
||
})
|
||
if (refs.length) {
|
||
tabs.push({
|
||
key: tab.key || `tab_${tabIndex}`,
|
||
label: tab.label || `分类${tabIndex + 1}`,
|
||
products: refs
|
||
})
|
||
}
|
||
})
|
||
if (!tabs.length && productList.length) {
|
||
tabs.push({
|
||
key: 'all',
|
||
label: '全部',
|
||
products: productList
|
||
})
|
||
}
|
||
this.form.list = productList
|
||
this.categoryTabs = tabs
|
||
const hasActive = tabs.some(item => item.key === this.activeCategoryKey)
|
||
this.activeCategoryKey = hasActive ? this.activeCategoryKey : (tabs[0] ? tabs[0].key : '')
|
||
} catch (error) {
|
||
this.form.list = []
|
||
this.categoryTabs = []
|
||
this.activeCategoryKey = ''
|
||
uni.showToast({
|
||
title: '商品数据获取失败',
|
||
icon: 'none'
|
||
})
|
||
} finally {
|
||
this.productLoading = false
|
||
}
|
||
},
|
||
openMerchantPicker() {
|
||
this.merchantSearchKeyword = ''
|
||
this.merchantPickerValue = [...this.merchantPickerIndexes]
|
||
this.showMerchantPicker = true
|
||
},
|
||
closeMerchantPicker() {
|
||
this.showMerchantPicker = false
|
||
},
|
||
onMerchantPickerChange(e) {
|
||
const value = e.detail && e.detail.value !== undefined ? e.detail.value : e.detail
|
||
const currentChainIndex = Number(this.merchantPickerValue[0] || 0)
|
||
let chainIndex = Array.isArray(value) ? Number(value[0] || 0) : Number(value || 0)
|
||
let merchantIndex = Array.isArray(value) ? Number(value[1] || 0) : 0
|
||
if (!Number.isFinite(chainIndex) || chainIndex < 0) chainIndex = 0
|
||
if (!Number.isFinite(merchantIndex) || merchantIndex < 0) merchantIndex = 0
|
||
const chain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
|
||
const merchants = this.getVisibleMerchants(chain)
|
||
if (!merchants.length) {
|
||
merchantIndex = 0
|
||
} else if (chainIndex !== currentChainIndex || merchantIndex > merchants.length - 1) {
|
||
merchantIndex = 0
|
||
}
|
||
this.merchantPickerValue = [chainIndex, merchantIndex]
|
||
},
|
||
async confirmMerchantPicker() {
|
||
const chainIndex = Number(this.merchantPickerValue[0] || 0)
|
||
const merchantIndex = Number(this.merchantPickerValue[1] || 0)
|
||
const chain = this.merchantChainOptions[chainIndex] || null
|
||
const merchants = this.getVisibleMerchants(chain)
|
||
const selected = merchants[merchantIndex]
|
||
if (!selected || selected.id === '' || selected.id === null || selected.id === undefined) {
|
||
return
|
||
}
|
||
this.applyMerchant(chain, selected)
|
||
this.showMerchantPicker = false
|
||
this.productKeyword = ''
|
||
await this.getProductsByMerchant()
|
||
},
|
||
scrollToTop() {
|
||
uni.pageScrollTo({
|
||
scrollTop: 0,
|
||
duration: 300
|
||
})
|
||
},
|
||
scrollToBottom() {
|
||
const query = uni.createSelectorQuery().in(this)
|
||
query.select('#page-bottom-anchor').boundingClientRect()
|
||
query.selectViewport().scrollOffset()
|
||
query.exec((res = []) => {
|
||
const anchor = res[0]
|
||
const viewport = res[1]
|
||
if (!anchor || !viewport) return
|
||
const scrollTop = Math.max(0, (viewport.scrollTop || 0) + (anchor.top || 0))
|
||
uni.pageScrollTo({
|
||
scrollTop,
|
||
duration: 300
|
||
})
|
||
})
|
||
},
|
||
normalizeNumericText(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 65248))
|
||
.replace(/,/g, ',')
|
||
.replace(/。/g, '.')
|
||
.replace(/,/g, '')
|
||
},
|
||
parseQuantity(value) {
|
||
const text = this.normalizeNumericText(value)
|
||
if (!text) return NaN
|
||
const num = Number(text)
|
||
return Number.isFinite(num) ? num : NaN
|
||
},
|
||
onQuantityInput(item, event) {
|
||
const value = event && event.detail ? event.detail.value : ''
|
||
this.$set(item, 'num', value)
|
||
},
|
||
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
|
||
}
|
||
console.log(this.form.list)
|
||
const selectedRows = (this.form.list || [])
|
||
.map(item => ({
|
||
...item,
|
||
_num: this.parseQuantity(item.num)
|
||
}))
|
||
.filter(item => Number.isFinite(item._num) && item._num > 0)
|
||
console.log(selectedRows)
|
||
if (!selectedRows.length) {
|
||
uni.showToast({
|
||
title: '请填写数量大于0的商品',
|
||
icon: 'none'
|
||
})
|
||
return null
|
||
}
|
||
for (let i = 0; i < selectedRows.length; i++) {
|
||
const current = selectedRows[i]
|
||
if (current._num > 1000) {
|
||
uni.showToast({
|
||
title: `${current.goodsName || '商品'}数量不能大于1000`,
|
||
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 => {
|
||
const num = Number(item._num) || 0
|
||
const amount = this.getSubmitAmount(item)
|
||
return {
|
||
goodsId: item.productId || '',
|
||
goodsCode: item.goodsCode || '',
|
||
goodsName: item.goodsName || '',
|
||
num,
|
||
spec: item.spec || '',
|
||
amount: amount.toFixed(2),
|
||
sourceCode: ''
|
||
}
|
||
})
|
||
const totalAmount = detailList.reduce((sum, item) => {
|
||
const amount = Number(item.amount) || 0
|
||
const num = Number(item.num) || 0
|
||
return sum + amount * num
|
||
}, 0)
|
||
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: totalAmount.toFixed(2),
|
||
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'
|
||
})
|
||
setTimeout(() => {
|
||
uni.navigateBack()
|
||
}, 500)
|
||
} catch (error) {
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: '提交失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.share-create-page {
|
||
min-height: 100vh;
|
||
background: #f5f8ff;
|
||
padding-bottom: 40rpx;
|
||
}
|
||
|
||
.form-card {
|
||
margin: 20rpx;
|
||
padding: 22rpx 20rpx 26rpx;
|
||
border-radius: 24rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16rpx 0;
|
||
border-bottom: 1rpx solid #edf1f7;
|
||
}
|
||
|
||
.merchant-row {
|
||
margin-top: 10rpx;
|
||
margin-bottom: 20rpx;
|
||
padding: 16rpx 18rpx;
|
||
border: 1rpx solid var(--brand-primary);
|
||
border-radius: 16rpx;
|
||
background: #ffffff;
|
||
|
||
.label{
|
||
color: grey;
|
||
}
|
||
}
|
||
|
||
.label {
|
||
color: #111827;
|
||
font-size: 26rpx;
|
||
min-width: 140rpx;
|
||
}
|
||
|
||
.form-value {
|
||
flex: 1;
|
||
text-align: right;
|
||
font-size: 26rpx;
|
||
color: #111827;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.total-bonus-row {
|
||
margin-top: 20rpx;
|
||
padding: 24rpx 24rpx;
|
||
border-radius: 20rpx;
|
||
border: 2rpx solid #f5a8b1;
|
||
border-bottom: 2rpx solid #f5a8b1;
|
||
|
||
.label {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
}
|
||
}
|
||
|
||
.selected-products {
|
||
margin-top: 18rpx;
|
||
padding: 20rpx 22rpx;
|
||
border-radius: 20rpx;
|
||
border: 1rpx solid #ddd;
|
||
background: #F7F7F7;
|
||
}
|
||
|
||
.selected-products-title {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.selected-products-empty {
|
||
margin-top: 12rpx;
|
||
font-size: 24rpx;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.selected-products-list {
|
||
margin-top: 12rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
.selected-product-item {
|
||
padding: 14rpx 16rpx;
|
||
border-radius: 14rpx;
|
||
background: #ffffff;
|
||
border: 1rpx solid #e7edf7;
|
||
}
|
||
|
||
.selected-product-name {
|
||
font-size: 24rpx;
|
||
color: #111827;
|
||
line-height: 1.4;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.selected-product-meta {
|
||
margin-top: 8rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
font-size: 22rpx;
|
||
}
|
||
|
||
.selected-product-qty {
|
||
color: #4b5563;
|
||
}
|
||
|
||
.selected-product-amount {
|
||
color: #ef4444;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.selected-product-extra {
|
||
margin-top: 6rpx;
|
||
font-size: 22rpx;
|
||
color: #f97316;
|
||
}
|
||
|
||
.total-bonus-wrap {
|
||
flex: 1;
|
||
}
|
||
|
||
.total-bonus-value {
|
||
color: #ff2121;
|
||
font-weight: 700;
|
||
font-size: 24rpx;
|
||
text-align: right;
|
||
}
|
||
|
||
.total-bonus-breakdown {
|
||
margin-top: 6rpx;
|
||
font-size: 22rpx;
|
||
color: #6b7280;
|
||
text-align: right;
|
||
}
|
||
|
||
.picker-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.picker-input-inner {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: flex-end;
|
||
gap: 8rpx;
|
||
min-height: 64rpx;
|
||
}
|
||
|
||
.picker-text {
|
||
display: block;
|
||
color: #111827;
|
||
font-size: 26rpx;
|
||
font-weight: bold;
|
||
line-height: 1.35;
|
||
text-align: right;
|
||
white-space: normal;
|
||
word-break: break-all;
|
||
max-width: 430rpx;
|
||
}
|
||
|
||
.picker-placeholder {
|
||
color: #9ca3af;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.product-search {
|
||
margin-top: 10rpx;
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
background: #ffffff;
|
||
border: 1rpx solid var(--brand-primary);
|
||
border-radius: 999rpx;
|
||
padding: 10rpx 14rpx;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 24rpx;
|
||
color: #111827;
|
||
}
|
||
|
||
.search-placeholder {
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.product-panel {
|
||
margin-top: 18rpx;
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 14rpx;
|
||
}
|
||
|
||
.category-sidebar {
|
||
width: 130rpx;
|
||
flex-shrink: 0;
|
||
border-radius: 20rpx;
|
||
background: #F7F7F7;
|
||
padding: 16rpx 0;
|
||
}
|
||
|
||
.category-item {
|
||
min-height: 78rpx;
|
||
padding: 0 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
font-size: 30rpx;
|
||
color: #6b7280;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.category-item-active {
|
||
color: #111827;
|
||
font-weight: 700;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.product-panel-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.detail-loading {
|
||
padding: 20rpx 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.detail-empty {
|
||
color: #6b7280;
|
||
text-align: center;
|
||
padding: 16rpx 0;
|
||
}
|
||
|
||
.detail-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
height: 800rpx;
|
||
overflow-y: scroll;
|
||
}
|
||
|
||
.detail-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20rpx 22rpx 24rpx;
|
||
border-radius: 24rpx;
|
||
background: #dcf3ef;
|
||
border: 2rpx solid #66d9c4;
|
||
}
|
||
|
||
.detail-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.detail-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 36rpx;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.qty-input {
|
||
width: 188rpx;
|
||
height: 62rpx;
|
||
line-height: 62rpx;
|
||
background: #ffffff;
|
||
border-radius: 14rpx;
|
||
border: 2rpx solid #ff9f86;
|
||
font-size: 24rpx;
|
||
color: #ff5f07;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
padding: 0 10rpx;
|
||
}
|
||
|
||
.detail-divider {
|
||
width: 100%;
|
||
margin-top: 16rpx;
|
||
border-bottom: 2rpx solid rgba(102, 217, 196, 0.5);
|
||
}
|
||
|
||
.detail-body {
|
||
width: 100%;
|
||
margin-top: 18rpx;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.detail-thumb-wrap {
|
||
width: 40%;
|
||
height: 228rpx;
|
||
border-radius: 12rpx;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
//background: #ffffff;
|
||
}
|
||
|
||
.detail-thumb {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.detail-thumb-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 12rpx;
|
||
border: 1rpx dashed rgba(156, 163, 175, 0.7);
|
||
color: #9ca3af;
|
||
font-size: 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.detail-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.detail-meta {
|
||
margin-top: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #7b8088;
|
||
line-height: 1.6;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.reward-value {
|
||
color: #ff1f1f;
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.reward-value-line {
|
||
color: #9ca3af;
|
||
font-size: 24rpx;
|
||
font-weight: 500;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.reward-value-merchant {
|
||
color: #ff1f1f;
|
||
}
|
||
|
||
.reward-time-icon {
|
||
padding-top: 2rpx;
|
||
}
|
||
|
||
.detail-meta:first-child {
|
||
margin-top: 0;
|
||
}
|
||
|
||
@media screen and (max-width: 420px) {
|
||
.detail-name {
|
||
font-size: 30rpx;
|
||
}
|
||
|
||
.detail-meta {
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.reward-value {
|
||
font-size: 36rpx;
|
||
}
|
||
}
|
||
|
||
.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: 18rpx;
|
||
height: 72rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.merchant-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 {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
height: 72rpx;
|
||
line-height: 72rpx;
|
||
background: #f3f4f6;
|
||
border: 1rpx solid var(--brand-primary);
|
||
border-radius: 12rpx;
|
||
padding: 0 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: 50vh;
|
||
}
|
||
|
||
.merchant-picker-item {
|
||
height: 64px;
|
||
padding: 0 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
font-size: 26rpx;
|
||
color: #1f2937;
|
||
line-height: 1.3;
|
||
white-space: normal;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.page-side-tools {
|
||
position: fixed;
|
||
right: 18rpx;
|
||
bottom: 220rpx;
|
||
z-index: 60;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.page-tool-btn {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 8rpx 20rpx rgba(31, 41, 55, 0.14);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.page-bottom-anchor {
|
||
height: 1rpx;
|
||
}
|
||
</style>
|