40fed38f83
非 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 数量逐文件相等。
1261 lines
36 KiB
Vue
1261 lines
36 KiB
Vue
<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>
|