Files
yxe-mp/packageUser/self-report/sales.vue
T
weicw-dshsa 876847ccbb feat(自主填报): 销量填写拆为独立页并按原型改版
甲方反馈原销量填写功能难看、不好用,按原型图改版。

改造前,销量填写是自主填报页内的一个区块(新增商品 → 底部弹层选品 →
回列表手输数量),与原型差异大。现拆为独立页面。

新页 packageUser/self-report/sales:
- 视觉对齐原型:搜索框 + 筛选、新增商品 + 批量填写、卡片式列表
  (序号徽标/封面/分类/厂家/编码/规格胶囊/数量步进器/删除)、底部提交
- 新增搜索(按名称或编码过滤清单)
- 新增分类筛选(弹层选择,可清空)
- 新增批量填写(弹层一次录入多项数量)

自主填报页(create.vue):
- 销量区块改为入口卡片 + 摘要列表,明细编辑移入新页
- 提交按钮文案「提交销量」改为「提交填报」——该页提交的是整单
  (打卡/门头照/销量/小票/小结/评分/定位),原标题名不副实
- 删除已迁移到新页的商品库选择逻辑(约 270 行)及废弃样式

页面间交接(util/sales-handoff.js):
- 工程无全局状态管理,页面间只能靠 storage 传递
- 仅负责一次性交接,消费后即清;持久化草稿统一由自主填报页的
  整单草稿负责(其 buildDraftPayload 已含 salesItems),避免两份状态互相覆盖
- 换连锁/换门店后清空销量明细与交接数据

修复:
- 数量步进器改用纯文本按钮。原用 u-icon 时点击无效——该组件根节点自带
  @tap,会吃掉落在图标上的点击;同页「删除」按钮因带文字才正常
- 数量输入限制为非负整数。type=number 在部分机型仍可输入 . / e / -,
  故增加输入清洗与失焦兜底;采用「取开头连续数字」而非「删掉所有非数字」,
  后者会把 1.5 抹成 15、1e3 抹成 13,属静默的数据污染

提交结构 salesGroups 未变,后端与审核端无需调整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-11 18:47:03 +08:00

1259 lines
36 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="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.item._key || row.index">
<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 : [])
.map((item, index) => ({item, 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>