Files
weicw-dshsa 40fed38f83 fix(小程序): 修正 v-for :key 表达式告警,恢复列表 wx:key
非 h5 平台的 :key 只接受简单属性名,模板字面量与运算表达式会被编译器
整条丢弃(uni-template-compiler 的 getForKey:Identifier 走 *this 或原名,
MemberExpression 取属性名,其余一律 warning + 返回空串)。

所以这批告警不只是文案问题:产物里对应的 <block wx:for> 后面确实没有
wx:key,列表项因此失去复用标识,增删时会整段重建节点。本次按编译器
接受的写法逐一改正,共 13 个文件。

改法:
- 纯 URL 字符串数组(打卡/门头/小票/凭证/巡店照片)直接用元素自身
  :key="img",编译为 wx:key="*this"
- 对象数组用其已有唯一字段:连锁/门店 item.id、抄送用户 user.id、
  审计销量行 detail.key、编辑销量项 detail._key
- 本身无唯一字段的巡店列表补 _key:task/detail 沿用 visit.vue 已验证的
  _rowKey(自增 seed,增删稳定),records 与 AuditVisitReportItem 用序号

sales.vue 另有一处隐蔽问题:row 是 {item, index} 包装对象,
:key="row.item._key || row.index" 即便写成简单形式也会生成 wx:key="_key",
在 row 上取不到值。改为在 visibleSalesItems 里把 key 提到包装对象顶层,
_key 缺失时用序号兜底。create.vue 的销量摘要同样补 __key 兜底,避免重复 key。

注:task/detail 的 storeList 提交走 sanitizeStoreList() 显式白名单,
_rowKey 不会外泄到后端。

验证:HBuilderX cli 重新编译 —— 编译成功,非 h5 平台 :key 告警 0 条
(修改前满屏 40+ 条),产物中 wx:for 与 wx:key 数量逐文件相等。
2026-10-12 18:18:11 +08:00

1261 lines
36 KiB
Vue
Raw Permalink 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="sales-page">
<AppNavbar title="销量填写" showBack/>
<view class="sp-toolbar">
<view class="sp-search">
<u-icon name="search" size="18" color="#c0c4cc"/>
<input
v-model="keyword"
class="sp-search-input"
type="text"
placeholder="请输入商品名称/编码"
placeholder-class="sp-search-ph"
/>
<view v-if="keyword" class="sp-search-clear" @tap="keyword = ''">
<u-icon name="close-circle-fill" size="16" color="#c0c4cc"/>
</view>
</view>
<view class="sp-filter-btn" :class="filterCate ? 'sp-filter-btn-active' : ''" @tap="openFilter">
<text>筛选</text>
<u-icon name="grid" size="16" :color="filterCate ? '#ffffff' : '#ff7a35'"/>
</view>
</view>
<view class="sp-actions">
<view class="sp-add-btn" @tap="openProductPicker">
<u-icon name="plus" size="18" color="#ffffff"/>
<text>新增商品</text>
</view>
<view class="sp-batch-btn" @tap="openBatchFill">
<u-icon name="list" size="18" color="#ff7a35"/>
<text>批量填写</text>
</view>
</view>
<view v-if="salesItems.length === 0" class="sp-empty">
<u-icon name="empty-list" size="60" color="#d8dee9"/>
<view class="sp-empty-title">还没有添加商品</view>
<view class="sp-empty-sub">点击上方「新增商品」从商品库中选择</view>
</view>
<view v-else-if="visibleSalesItems.length === 0" class="sp-empty">
<u-icon name="empty-search" size="60" color="#d8dee9"/>
<view class="sp-empty-title">没有匹配的商品</view>
<view class="sp-empty-sub">换个关键词或清空筛选条件试试</view>
</view>
<view v-else class="sp-list">
<view class="sp-card" v-for="row in visibleSalesItems" :key="row.key">
<view class="sp-badge">{{ formatBadge(row.index) }}</view>
<image v-if="row.item.cover" :src="row.item.cover" mode="aspectFill" class="sp-cover"/>
<view v-else class="sp-cover sp-cover-empty">
<u-icon name="photo" size="26" color="#c6ccd8"/>
</view>
<view class="sp-content">
<view class="sp-head">
<view class="sp-name">{{ row.item.name }}</view>
<view class="sp-remove" @tap="removeSalesItem(row.index)">
<text>删除</text>
<u-icon name="trash" size="14" color="#ff5a5a"/>
</view>
</view>
<view class="sp-group">{{ row.item.groupTitle || '自定义商品' }}</view>
<view class="sp-meta">
<view class="sp-meta-row">
<text class="sp-meta-label">厂家</text>
<text class="sp-meta-value">{{ row.item.makerAbb || '-' }}</text>
</view>
<view class="sp-meta-row">
<text class="sp-meta-label">编码</text>
<text class="sp-meta-value">{{ row.item.goodsCode || '-' }}</text>
</view>
</view>
<view class="sp-foot">
<picker
class="sp-spec-picker"
mode="selector"
:range="specRange(row.item)"
@change="onSpecChange($event, row.item)"
>
<view class="sp-spec-pill">
<text>{{ row.item.spec || '规格' }}</text>
</view>
</picker>
<view class="sp-qty-wrap">
<view class="sp-qty">
<view class="sp-qty-btn" @tap.stop="changeQty(row.index, -1)">−</view>
<input
class="sp-qty-input"
type="number"
:value="row.item.qty"
placeholder="0"
@input="onQtyInput($event, row.index)"
@blur="onQtyBlur($event, row.index)"
/>
<view class="sp-qty-btn" @tap.stop="changeQty(row.index, 1)">+</view>
</view>
<text class="sp-qty-unit">{{ row.item.unit || '盒' }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="sp-footer-placeholder"/>
<view class="sp-footer">
<view class="sp-submit-btn" @tap="submitAndBack">
<u-icon name="checkmark-circle" size="18" color="#ffffff"/>
<text>提交销量</text>
</view>
</view>
<u-popup :show="showFilter" mode="bottom" round="24rpx" @close="closeFilter">
<view class="sp-panel">
<view class="sp-panel-head">
<view class="sp-panel-action" @tap="closeFilter">取消</view>
<text class="sp-panel-title">按分类筛选</text>
<view class="sp-panel-action primary" @tap="closeFilter">完成</view>
</view>
<scroll-view class="sp-panel-body" scroll-y>
<view
class="sp-option"
:class="!filterCate ? 'sp-option-active' : ''"
@tap="selectFilter('')"
>
<text>全部分类</text>
<u-icon v-if="!filterCate" name="checkmark" size="16" color="#ff7a35"/>
</view>
<view
v-for="option in filterOptions"
:key="option"
class="sp-option"
:class="filterCate === option ? 'sp-option-active' : ''"
@tap="selectFilter(option)"
>
<text>{{ option }}</text>
<u-icon v-if="filterCate === option" name="checkmark" size="16" color="#ff7a35"/>
</view>
</scroll-view>
</view>
</u-popup>
<u-popup :show="showBatchFill" mode="bottom" round="24rpx" @close="closeBatchFill">
<view class="sp-panel">
<view class="sp-panel-head">
<view class="sp-panel-action" @tap="closeBatchFill">取消</view>
<text class="sp-panel-title">批量填写</text>
<view class="sp-panel-action primary" @tap="confirmBatchFill">保存</view>
</view>
<view v-if="salesItems.length === 0" class="sp-empty sp-empty-inline">
<view class="sp-empty-title">还没有添加商品</view>
<view class="sp-empty-sub">请先新增商品再来批量填写</view>
</view>
<scroll-view v-else class="sp-panel-body" scroll-y>
<view class="sp-batch-row" v-for="row in batchRows" :key="row.key">
<view class="sp-batch-main">
<view class="sp-batch-name">{{ row.name }}</view>
<view class="sp-batch-sub">{{ row.groupTitle || '自定义商品' }} · {{ row.spec || '规格' }}</view>
</view>
<input
class="sp-batch-input"
type="number"
:value="batchQtyMap[row.key]"
placeholder="0"
@input="onBatchInput($event, row.key)"
/>
<text class="sp-batch-unit">{{ row.unit || '盒' }}</text>
</view>
</scroll-view>
</view>
</u-popup>
<u-popup :show="showProductPicker" mode="bottom" round="24rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="sp-panel-head">
<view class="sp-panel-action" @tap="closeProductPicker">取消</view>
<text class="sp-panel-title">选择商品</text>
<view class="sp-panel-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<u-input v-model="salesKeyword" placeholder="搜索商品名称/厂家/规格" shape="circle"/>
</view>
<view v-if="productLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else class="product-panel">
<scroll-view class="category-sidebar" scroll-y>
<view
v-for="group in filteredProductGroups"
:key="group.key"
class="category-item"
:class="currentProductGroupKey === group.key ? 'category-item-active' : ''"
@tap="selectProductGroup(group.key)"
>
{{ group.title }}
</view>
</scroll-view>
<scroll-view class="product-panel-main" scroll-y>
<view v-if="filteredProductGroups.length === 0" class="audit-empty-tip">暂无可选商品</view>
<view v-else-if="displayedProductOptions.length === 0" class="audit-empty-tip">该分类暂无可选商品</view>
<view v-else class="product-picker-list">
<view
class="product-picker-item"
v-for="item in displayedProductOptions"
:key="item._key"
@tap="selectProduct(item)"
>
<image v-if="item.cover" :src="item.cover" mode="aspectFill" class="product-picker-cover"/>
<view class="product-picker-main">
<view class="product-picker-name">{{ item.name }}</view>
<view class="product-picker-sub">{{ item.makerAbb || '-' }} / {{ item.spec || '规格' }}</view>
</view>
<view class="product-picker-tag" :class="isProductSelected(item) ? 'product-picker-tag-done' : ''">
{{ isProductSelected(item) ? '已添加' : '添加' }}
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import AppNavbar from "@/components/AppNavbar.vue";
import {productGroupByCateReq} from "@/api/product";
import {
setSalesHandoff,
getSalesHandoff,
cloneSalesItems
} from "@/util/sales-handoff";
export default {
components: {AppNavbar},
data() {
return {
chainName: '',
salesItems: [],
keyword: '',
filterCate: '',
showFilter: false,
showBatchFill: false,
batchQtyMap: {},
showProductPicker: false,
salesKeyword: '',
productGroups: [],
productLoading: false,
activeProductGroupKey: '',
// 底部按钮已写过交接数据,onUnload 不再兜底覆盖
handoffWritten: false
}
},
computed: {
visibleSalesItems() {
const keyword = (this.keyword || '').trim().toLowerCase()
const cate = this.filterCate
return (Array.isArray(this.salesItems) ? this.salesItems : [])
// wx:key 只认 item 上的属性名,表达式会被编译丢弃;
// 这里把 key 提到包装对象顶层,_key 缺失时用序号兜底保证唯一
.map((item, index) => ({item, index, key: (item && item._key) || `sales_row_${index}`}))
.filter(row => {
if (cate && String(row.item.groupTitle || '') !== cate) return false
if (!keyword) return true
const name = String(row.item.name || '').toLowerCase()
const code = String(row.item.goodsCode || '').toLowerCase()
return name.includes(keyword) || code.includes(keyword)
})
},
filterOptions() {
const titles = []
;(Array.isArray(this.salesItems) ? this.salesItems : []).forEach(item => {
const title = String(item.groupTitle || '').trim()
if (title && !titles.includes(title)) titles.push(title)
})
return titles
},
batchRows() {
return (Array.isArray(this.salesItems) ? this.salesItems : []).map((item, index) => ({
key: item._key || `batch_${index}`,
name: item.name || '商品',
groupTitle: item.groupTitle || '',
spec: item.spec || '',
unit: item.unit || '盒'
}))
},
filteredProductGroups() {
const keyword = (this.salesKeyword || '').trim().toLowerCase()
const groups = Array.isArray(this.productGroups) ? this.productGroups : []
if (!keyword) return groups
return groups
.map(group => {
const items = Array.isArray(group.items) ? group.items : []
const nextItems = items.filter(item => {
const name = String(item.name || '').toLowerCase()
const maker = String(item.makerAbb || '').toLowerCase()
const spec = String(item.spec || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword)
})
return Object.assign({}, group, {items: nextItems})
})
.filter(group => Array.isArray(group.items) && group.items.length)
},
currentProductGroupKey() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
if (!groups.length) return ''
const hasActive = groups.some(group => group.key === this.activeProductGroupKey)
return hasActive ? this.activeProductGroupKey : groups[0].key
},
displayedProductOptions() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
const current = groups.find(group => group.key === this.currentProductGroupKey)
return current && Array.isArray(current.items) ? current.items : []
}
},
async onLoad() {
const handoff = getSalesHandoff()
this.chainName = String((handoff && handoff.chainName) || '').trim()
this.salesItems = handoff && Array.isArray(handoff.items) ? cloneSalesItems(handoff.items) : []
await this.getProductGroups()
},
// 返回时把明细交回填报页。
// 本页不单独存草稿:填报页已有整单草稿(含 salesItems、打卡、小票、小结),
// 在销量页再存一套会造成两份状态互相覆盖,持久化统一交给填报页。
onUnload() {
if (this.handoffWritten) return
setSalesHandoff({
action: 'merge',
chainName: this.chainName,
items: cloneSalesItems(this.salesItems)
})
},
methods: {
formatBadge(index) {
return `${index + 1}`.padStart(2, '0')
},
parseSalesQty(value) {
const num = Number(value)
return Number.isFinite(num) ? num : 0
},
changeQty(index, delta) {
const item = this.salesItems[index]
if (!item) return
item.qty = Math.max(0, this.parseSalesQty(item.qty) + delta)
},
// 数量只收非负整数:取开头连续数字,而不是删掉所有非数字字符。
// 后者会把 "1.5" 抹成 "15"、"1e3" 抹成 "13",属于静默的数据污染。
sanitizeQty(raw) {
const match = `${raw}`.trim().match(/^\d+/)
if (!match) return ''
// 最多 6 位,避免超出安全整数范围后显示成科学计数法
return String(parseInt(match[0].slice(0, 6), 10))
},
onQtyInput(event, index) {
const item = this.salesItems[index]
if (!item) return
const raw = event && event.detail ? event.detail.value : ''
const next = this.sanitizeQty(raw)
if (`${item.qty}` === next) {
// 数据没变但输入框里是非法字符(如 "1.5"),强制刷新回合法值
this.forceQtyRefresh(index, next)
return
}
item.qty = next
},
// 失焦时兜底:此时强制回写不会打断输入
onQtyBlur(event, index) {
const item = this.salesItems[index]
if (!item) return
const raw = event && event.detail ? event.detail.value : ''
const next = this.sanitizeQty(raw)
if (`${item.qty}` === next) {
this.forceQtyRefresh(index, next)
return
}
item.qty = next
},
// 用新对象替换数组项,强制 :value 重新下发到输入框
forceQtyRefresh(index, value) {
this.$nextTick(() => {
const item = this.salesItems[index]
if (!item) return
this.salesItems.splice(index, 1, Object.assign({}, item, {qty: value}))
})
},
specRange(item) {
const options = Array.isArray(item && item.specOptions) ? item.specOptions : []
return options.length ? options : [item && item.spec ? item.spec : '规格']
},
onSpecChange(event, item) {
const options = this.specRange(item)
const value = Number(event.detail.value || 0)
item.spec = options[value] || item.spec
},
removeSalesItem(index) {
this.salesItems.splice(index, 1)
if (this.filterCate && !this.filterOptions.includes(this.filterCate)) {
this.filterCate = ''
}
},
openFilter() {
if (!this.salesItems.length) {
uni.showToast({title: '请先添加商品', icon: 'none'})
return
}
this.showFilter = true
},
closeFilter() {
this.showFilter = false
},
selectFilter(cate) {
this.filterCate = cate || ''
},
openBatchFill() {
if (!this.salesItems.length) {
uni.showToast({title: '请先添加商品', icon: 'none'})
return
}
const map = {}
this.batchRows.forEach((row, index) => {
const item = this.salesItems[index]
map[row.key] = item && item.qty !== '' && item.qty !== null && item.qty !== undefined ? `${item.qty}` : ''
})
this.batchQtyMap = map
this.showBatchFill = true
},
closeBatchFill() {
this.showBatchFill = false
},
// 批量填写同样只收非负整数
onBatchInput(event, key) {
const raw = event && event.detail ? event.detail.value : ''
this.batchQtyMap = Object.assign({}, this.batchQtyMap, {[key]: this.sanitizeQty(raw)})
},
confirmBatchFill() {
this.batchRows.forEach((row, index) => {
const item = this.salesItems[index]
if (!item) return
const value = this.batchQtyMap[row.key]
const empty = value === '' || value === null || value === undefined || `${value}`.trim() === ''
if (empty) return
const num = parseInt(`${value}`.replace(/[^\d]/g, ''), 10)
item.qty = Number.isFinite(num) && num >= 0 ? num : 0
})
this.showBatchFill = false
uni.showToast({title: '已批量填入', icon: 'none'})
},
pickFirstText(source, keys = []) {
if (!source || typeof source !== 'object') return ''
for (let i = 0; i < keys.length; i += 1) {
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 += 1) {
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
const allProducts = this.collectProductNodes(rootList)
if (allProducts.length) {
tabs.push({
key: 'all',
label: '全部',
rawProducts: allProducts
})
}
return tabs
},
normalizeSpecOptions(item) {
const raw = item.specOptions || item.specList || item.specs || []
if (Array.isArray(raw)) {
return raw.map(value => `${value}`.trim()).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(/[、,,]/).map(value => value.trim()).filter(Boolean)
}
const spec = item.spec || item.specification || item.standard || ''
return spec ? [spec] : []
},
hasDisplayValue(value) {
return value !== undefined && value !== null && String(value).trim() !== ''
},
resolveTaskIncome(item) {
if (!item || typeof item !== 'object') return ''
const candidates = [item.taskIncome]
for (let i = 0; i < candidates.length; i += 1) {
if (this.hasDisplayValue(candidates[i])) return candidates[i]
}
return ''
},
normalizeProduct(item) {
const product = item && item.product ? item.product : {}
const code = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || ''
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || ''
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 cover = item.cover || product.cover || ''
const chainId = item.chainId || item.chain_id || product.chainId || product.chain_id || ''
const chainName = item.chainName || item.chain_name || item.chain || product.chainName || product.chain_name || product.chain || ''
const merchantId = item.merchantId || item.merchant_id || product.merchantId || product.merchant_id || ''
const merchantName = item.merchantName || item.merchant_name || item.shopName || item.shop_name || product.merchantName || product.merchant_name || ''
return {
productId,
goodsCode: code,
name,
cover,
makerAbb: maker,
spec,
chainId,
chainName,
merchantId,
merchantName,
unit: item.unit || product.unit || '盒',
taskIncome: this.resolveTaskIncome(item),
specOptions: this.normalizeSpecOptions(Object.assign({}, product, item)),
qty: ''
}
},
buildSalesGroupsFromProductData(rawData) {
const tabs = this.buildCategoryTabs(rawData)
let rowSeed = 0
return tabs.map((tab, tabIndex) => {
const items = (Array.isArray(tab.rawProducts) ? tab.rawProducts : [])
.map((rawItem, itemIndex) => {
const product = this.normalizeProduct(rawItem)
if (!product.name) return null
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: [product.spec || '规格']
return {
...product,
groupTitle: tab.label || `分类${tabIndex + 1}`,
spec: product.spec || specOptions[0] || '规格',
specOptions,
_key: `sales_${tabIndex}_${itemIndex}_${rowSeed++}`
}
})
.filter(Boolean)
return {
key: tab.key || `group_${tabIndex}`,
title: tab.label || `分类${tabIndex + 1}`,
items
}
}).filter(group => group.items.length)
},
async getProductGroups() {
if (!this.chainName) {
this.productGroups = []
this.activeProductGroupKey = ''
return
}
this.productLoading = true
try {
const {data} = await productGroupByCateReq({chain: this.chainName, type: 0})
this.productGroups = this.buildSalesGroupsFromProductData(data)
} catch (error) {
this.productGroups = []
uni.showToast({title: '商品数据获取失败', icon: 'none'})
} finally {
this.activeProductGroupKey = this.productGroups[0] ? this.productGroups[0].key : ''
this.productLoading = false
}
},
async openProductPicker() {
if (!this.chainName) {
uni.showToast({title: '缺少连锁信息,请返回重新选择', icon: 'none'})
return
}
if (!this.productGroups.length) {
await this.getProductGroups()
}
if (!this.filteredProductGroups.length) {
uni.showToast({title: '当前连锁暂无可选商品', icon: 'none'})
return
}
if (!this.activeProductGroupKey) {
this.activeProductGroupKey = this.filteredProductGroups[0] ? this.filteredProductGroups[0].key : ''
}
this.showProductPicker = true
},
closeProductPicker() {
this.showProductPicker = false
},
selectProductGroup(key) {
this.activeProductGroupKey = key
},
isProductSelected(product) {
return (Array.isArray(this.salesItems) ? this.salesItems : []).some(item => {
if (product.productId && item.productId) {
return String(item.productId) === String(product.productId)
}
return item.name === product.name
})
},
selectProduct(product) {
if (this.isProductSelected(product)) {
uni.showToast({title: '该商品已添加', icon: 'none'})
return
}
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? [...product.specOptions]
: [product.spec || '规格']
this.salesItems.push(Object.assign({}, product, {
groupTitle: product.groupTitle || '',
specOptions,
spec: product.spec || specOptions[0] || '规格',
qty: ''
}))
this.showProductPicker = false
this.salesKeyword = ''
},
buildHandoffPayload(action) {
return {
action,
chainName: this.chainName,
items: cloneSalesItems(this.salesItems)
}
},
submitAndBack() {
if (!this.salesItems.length) {
uni.showToast({title: '请至少添加一项销量商品', icon: 'none'})
return
}
const invalid = this.salesItems.find(item => {
const num = Number(item.qty)
return this.hasDisplayValue(item.qty) && (!Number.isFinite(num) || num < 0)
})
if (invalid) {
uni.showToast({title: `${invalid.name || '商品'}销量不能小于0`, icon: 'none'})
return
}
setSalesHandoff(this.buildHandoffPayload('submit'))
this.handoffWritten = true
// 这里只是把销量交回填报页,整条填报尚未提交(还要打卡/小票/小结),
// 因此草稿必须保留:用户中途放弃填报时仍能找回。
uni.navigateBack()
}
}
}
</script>
<style lang="scss" scoped>
.sales-page {
--sp-footer-height: calc(120rpx + env(safe-area-inset-bottom));
min-height: 100vh;
background: #f5f6f8;
}
.sp-toolbar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 24rpx 0;
}
.sp-search {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10rpx;
height: 76rpx;
padding: 0 22rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #e8ebf0;
}
.sp-search-input {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #1f2937;
}
.sp-search-ph {
color: #b6bcc7;
font-size: 26rpx;
}
.sp-search-clear {
padding-left: 6rpx;
}
.sp-filter-btn {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8rpx;
height: 76rpx;
padding: 0 26rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #ffd9c2;
color: #ff7a35;
font-size: 26rpx;
font-weight: 600;
}
.sp-filter-btn-active {
background: linear-gradient(135deg, #ff8a4c, #ff6b4a);
border-color: #ff6b4a;
color: #ffffff;
}
.sp-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 8rpx;
}
.sp-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10rpx;
min-height: 78rpx;
padding: 0 40rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8a4c, #ff6b4a);
box-shadow: 0 12rpx 24rpx rgba(255, 107, 74, 0.22);
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
}
.sp-batch-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 8rpx;
color: #ff7a35;
font-size: 26rpx;
font-weight: 600;
}
.sp-list {
padding: 16rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.sp-card {
position: relative;
display: flex;
gap: 20rpx;
padding: 30rpx 24rpx 24rpx;
border-radius: 24rpx;
background: #ffffff;
border: 2rpx solid #ffe2cf;
box-shadow: 0 8rpx 20rpx rgba(255, 138, 76, 0.08);
}
.sp-badge {
position: absolute;
top: 0;
left: 0;
min-width: 74rpx;
height: 44rpx;
padding: 0 14rpx;
border-radius: 24rpx 0 24rpx 0;
background: linear-gradient(135deg, #ffa25c, #ff7a35);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.sp-cover {
flex-shrink: 0;
width: 150rpx;
height: 150rpx;
margin-top: 18rpx;
border-radius: 16rpx;
background: #f8fafc;
}
.sp-cover-empty {
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #eef2f7;
}
.sp-content {
flex: 1;
min-width: 0;
}
.sp-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.sp-name {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.4;
}
.sp-remove {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
color: #ff5a5a;
font-size: 24rpx;
}
.sp-group {
margin-top: 8rpx;
font-size: 24rpx;
color: #ff7a35;
}
.sp-meta {
margin-top: 16rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.sp-meta-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.sp-meta-label {
width: 80rpx;
color: #9aa3b2;
font-size: 24rpx;
}
.sp-meta-value {
flex: 1;
min-width: 0;
color: #1f2937;
font-size: 24rpx;
word-break: break-all;
}
.sp-foot {
margin-top: 20rpx;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16rpx;
}
.sp-spec-picker {
flex: 1;
min-width: 0;
}
.sp-spec-pill {
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 62rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #fff4ec;
border: 1rpx solid #ffd0b0;
color: #ff7a35;
font-size: 24rpx;
}
.sp-qty-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6rpx;
}
.sp-qty {
display: flex;
align-items: center;
gap: 10rpx;
}
.sp-qty-btn {
width: 60rpx;
height: 60rpx;
border-radius: 16rpx;
background: #fff4ec;
border: 1rpx solid #ffd0b0;
display: flex;
align-items: center;
justify-content: center;
color: #ff7a35;
font-size: 36rpx;
font-weight: 700;
line-height: 1;
}
.sp-qty-input {
width: 88rpx;
height: 60rpx;
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: #0f172a;
background: #ffffff;
border-radius: 12rpx;
}
.sp-qty-unit {
font-size: 22rpx;
color: #9aa3b2;
padding-right: 4rpx;
}
.sp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 12rpx;
}
.sp-empty-inline {
padding: 60rpx 40rpx;
}
.sp-empty-title {
margin-top: 16rpx;
font-size: 28rpx;
color: #6b7280;
font-weight: 600;
}
.sp-empty-sub {
font-size: 24rpx;
color: #9aa3b2;
}
.sp-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
height: var(--sp-footer-height);
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
display: flex;
gap: 20rpx;
background: #ffffff;
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.08);
}
.sp-footer-placeholder {
height: var(--sp-footer-height);
}
.sp-submit-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
min-height: 80rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8a4c, #ff6b4a);
box-shadow: 0 12rpx 24rpx rgba(255, 107, 74, 0.22);
color: #ffffff;
font-size: 28rpx;
font-weight: 700;
}
.sp-panel {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.sp-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 20rpx;
}
.sp-panel-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
}
.sp-panel-action {
font-size: 28rpx;
color: #7a8495;
}
.sp-panel-action.primary {
color: #ff7a35;
font-weight: 700;
}
.sp-panel-body {
max-height: 56vh;
padding: 0 24rpx 12rpx;
box-sizing: border-box;
}
.sp-option {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
padding: 0 24rpx;
border-radius: 16rpx;
color: #1f2937;
font-size: 28rpx;
}
.sp-option + .sp-option {
margin-top: 8rpx;
}
.sp-option-active {
background: #fff4ec;
color: #ff7a35;
font-weight: 700;
}
.sp-batch-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 16rpx;
border-bottom: 1rpx solid #f1f4f8;
}
.sp-batch-main {
flex: 1;
min-width: 0;
}
.sp-batch-name {
font-size: 27rpx;
color: #0f172a;
font-weight: 600;
}
.sp-batch-sub {
margin-top: 6rpx;
font-size: 22rpx;
color: #9aa3b2;
}
.sp-batch-input {
flex-shrink: 0;
width: 130rpx;
height: 64rpx;
text-align: center;
font-size: 28rpx;
color: #0f172a;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
border-radius: 14rpx;
}
.sp-batch-unit {
flex-shrink: 0;
width: 44rpx;
font-size: 22rpx;
color: #9aa3b2;
}
.product-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.merchant-picker-search {
padding: 0 24rpx 20rpx;
}
.merchant-picker-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
}
.product-panel {
display: flex;
height: 62vh;
border-top: 1rpx solid #eef2f7;
}
.category-sidebar {
width: 220rpx;
background: linear-gradient(180deg, #f8fafc, #fff7ed);
border-right: 1rpx solid #eef2f7;
}
.category-item {
min-height: 88rpx;
padding: 18rpx 16rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #64748b;
font-size: 24rpx;
border-bottom: 1rpx solid rgba(226, 232, 240, 0.8);
}
.category-item-active {
background: #ffffff;
color: #ea580c;
font-weight: 700;
}
.product-panel-main {
flex: 1;
padding: 20rpx 24rpx 24rpx;
box-sizing: border-box;
}
.product-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 18rpx 20rpx;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
}
.product-picker-item + .product-picker-item {
margin-top: 12rpx;
}
.product-picker-main {
flex: 1;
min-width: 0;
}
.product-picker-cover {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
background: #ffffff;
}
.product-picker-name {
color: #0f172a;
font-size: 28rpx;
font-weight: 600;
}
.product-picker-sub {
margin-top: 6rpx;
color: #64748b;
font-size: 22rpx;
}
.product-picker-tag {
flex-shrink: 0;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #ffedd5;
color: #ea580c;
font-size: 22rpx;
font-weight: 700;
}
.product-picker-tag-done {
background: #dcfce7;
color: #15803d;
}
.audit-empty-tip {
padding: 30rpx 0;
text-align: center;
color: #94a3b8;
font-size: 26rpx;
}
</style>