Files
yxe-mp/pages/share/create.vue
T
weicw-dshsa 575b4a723a perf(门店选择): 门店明细移出页面 data,修复首屏卡顿
门店接口 /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 项),搜索与选中回填均正常
2026-10-11 15:18:06 +08:00

1798 lines
57 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 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>