Files
yxe-mp/pages/audit/components/AuditSelfReportItem.vue
T
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

1424 lines
48 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>
<view class="record">
<view>
<view class="record-meta" v-if="currentRecord.chainName && currentRecord.chainName !== '-'">连锁:{{ currentRecord.chainName }}</view>
<view class="record-meta-row">
<view class="record-meta">门店:{{ currentRecord.merchantName || '-' }}</view>
<view v-if="item.status === 0" class="record-meta-action" @tap="openMerchantEditPicker">修改</view>
</view>
<view class="record-meta-row">
<view class="record-meta">活动日期:{{ currentRecord.date || item.date || '-' }}</view>
<view v-if="item.status === 0" class="record-meta-action" @tap="openDateEditPicker">修改</view>
</view>
<!-- <view class="record-meta" style="color: red">奖金:¥{{ formatPrice(currentRecord.reward || 0) }}</view>-->
<view class="record-meta">评分:{{ currentRecord.rating || '-' }} 星</view>
<view class="record-meta">提交人:{{ currentRecord.owner || item.owner }}</view>
<view class="record-meta">小结:{{ currentRecord.summary || '-' }}</view>
<view class="record-meta text-red" v-if="currentRecord.rejectReason">驳回原因:{{ currentRecord.rejectReason }}</view>
<view class="record-toggle" @tap="toggleDetailExpanded">
<text>{{ detailExpanded ? '收起详情' : '展开详情' }}</text>
<u-icon :name="detailExpanded ? 'arrow-up' : 'arrow-down'" size="20" color="#2fc6a7"/>
</view>
<view v-if="detailExpanded" class="record-detail">
<view class="record-section">
<view class="record-section-title">上下班打卡</view>
<view v-if="clockImageList.length" class="media-images">
<image
v-for="(img, idx) in clockImageList"
:key="img"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, clockImageList)"
/>
</view>
<view v-else class="detail-empty">暂无打卡图片</view>
</view>
<view class="record-section">
<view class="record-section-title">门头摆台照</view>
<view v-if="boothImageList.length" class="media-images">
<image
v-for="(img, idx) in boothImageList"
:key="img"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, boothImageList)"
/>
</view>
<view v-else class="detail-empty">暂无门头摆台照</view>
</view>
<view class="record-section">
<view class="record-section-title">小票上传</view>
<view v-if="receiptImageList.length" class="media-images">
<image
v-for="(img, idx) in receiptImageList"
:key="img"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, receiptImageList)"
/>
</view>
<view v-else class="detail-empty">暂无小票</view>
</view>
<view class="record-section">
<view class="record-section-title-row">
<view class="record-section-title">销量填写</view>
<view v-if="item.status === 0" class="section-action" @tap="openEditPopup">修改销量</view>
</view>
<view v-if="salesRows.length" class="detail-list">
<view class="detail-item" v-for="(detail, idx) in salesRows" :key="detail.key">
<view class="detail-head">
<view class="detail-name">{{ detail.name }}</view>
<view class="detail-value text-red">销量:{{ detail.qty }}{{ detail.unit }}</view>
</view>
<view class="detail-grid">
<view class="detail-kv">
<text class="detail-label">分组</text>
<text class="detail-value">{{ detail.groupTitle }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">厂家</text>
<text class="detail-value">{{ detail.makerAbb || '-' }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">规格</text>
<text class="detail-value">{{ detail.spec || '-' }}</text>
</view>
</view>
</view>
</view>
<view v-else class="detail-empty">暂无销量数据</view>
</view>
</view>
</view>
<view class="record-actions" v-if="item.status === 0">
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="handleApprove">通过</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p danger-chip" @tap="$emit('reject', item)">驳回</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="openEditPopup">修改销量</view>
</view>
</view>
</view>
<view v-if="showEditPopup" class="self-report-popup-mask" @tap="closeEditPopup" @touchmove.stop.prevent></view>
<view v-if="showEditPopup" class="self-report-popup card" @tap.stop>
<view class="popup-head">
<view class="section-title">修改销量填写</view>
<view class="close-btn" @tap="closeEditPopup">关闭</view>
</view>
<!-- <view class="edit-summary">-->
<!-- <view class="edit-summary-label">奖金合计</view>-->
<!-- <view class="edit-summary-value">¥{{ editTotalRewardDisplay }}</view>-->
<!-- </view>-->
<view class="detail-actions">
<view class="ghost-btn" @tap="openProductPicker(-1)">新增商品</view>
</view>
<view v-if="editSalesItems.length === 0" class="detail-empty">暂无销量商品</view>
<view v-else class="edit-sales-list">
<view class="edit-sales-item" v-for="(detail, idx) in editSalesItems" :key="detail._key">
<view class="edit-sales-head">
<view>
<view class="edit-sales-name">{{ detail.name || '商品' }}</view>
<view class="edit-sales-group">{{ detail.groupTitle || '自定义商品' }}</view>
</view>
<view class="edit-sales-remove" @tap="removeEditSalesItem(idx)">删除</view>
</view>
<view class="form-row">
<view class="label">商品</view>
<view class="picker-input" @tap="openProductPicker(idx)">
<view class="picker-input-inner">
<text :class="detail.name ? 'picker-text' : 'picker-placeholder'">{{ detail.name || '点击选择商品' }}</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
</view>
<view class="detail-grid edit-grid">
<view class="detail-kv">
<text class="detail-label">厂家</text>
<text class="detail-value">{{ detail.makerAbb || '-' }}</text>
</view>
<view class="detail-kv">
<text class="detail-label">编码</text>
<text class="detail-value">{{ detail.goodsCode || '-' }}</text>
</view>
</view>
<view class="form-row" v-if="detail.specOptions && detail.specOptions.length">
<view class="label">规格</view>
<picker class="picker-input" mode="selector" :range="detail.specOptions" @change="onSpecChange($event, detail)">
<view class="picker-input-inner">
<text class="picker-text">{{ detail.spec || detail.specOptions[0] || '规格' }}</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</picker>
</view>
<view class="form-row" v-else>
<view class="label">规格</view>
<input v-model="detail.spec" class="input" placeholder="规格"/>
</view>
<!-- <view class="form-row">-->
<!-- <view class="label">单品奖金</view>-->
<!-- <view class="form-value bonus-text">¥{{ formatMoney(getTaskIncomeNumber(detail)) }}</view>-->
<!-- </view>-->
<view class="form-row">
<view class="label">数量</view>
<view class="qty-input-wrap">
<input :value="String(detail.qty === undefined || detail.qty === null ? '' : detail.qty)" class="input qty-input" type="digit" placeholder="0" @input="onEditQtyInput($event, idx)"/>
<text class="qty-unit">{{ detail.unit || '盒' }}</text>
</view>
</view>
</view>
</view>
<view class="popup-actions">
<view class="primary-btn submit" @tap="saveEdit">{{ editSaving ? '保存中...' : '保存' }}</view>
</view>
</view>
<u-popup :show="showMerchantEditPicker" mode="bottom" round="24rpx" @close="closeMerchantEditPicker">
<view class="merchant-edit-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantEditPicker">取消</view>
<text class="picker-title">修改门店</text>
<view class="picker-action primary" @tap="saveMerchantEdit">{{ merchantEditSaving ? '保存中...' : '保存' }}</view>
</view>
<view v-if="merchantEditLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else>
<view class="merchant-picker-search">
<input v-model="merchantEditKeyword" class="merchant-search-input" placeholder="搜索门店" placeholder-class="merchant-search-placeholder" @input="onMerchantEditSearchInput"/>
</view>
<picker-view class="merchant-picker-view" :value="merchantEditPickerValue" @change="onMerchantEditPickerChange">
<picker-view-column>
<view v-for="(chain, index) in merchantEditChains" :key="chain.id" class="merchant-picker-item">
{{ chain.text }}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(merchant, index) in merchantEditStores" :key="merchant.id" class="merchant-picker-item">
{{ merchant.text }}
</view>
</picker-view-column>
</picker-view>
</view>
</view>
</u-popup>
<u-datetime-picker
v-if="showDateEditPicker"
:key="dateEditPickerKey"
:show="showDateEditPicker"
:value="dateEditPickerValue"
mode="date"
@confirm="saveDateEdit"
@cancel="closeDateEditPicker"
@close="closeDateEditPicker"
/>
<u-popup :show="showProductPicker" mode="bottom" round="24rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeProductPicker">取消</view>
<text class="picker-title">选择商品</text>
<view class="picker-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<input
v-model="productKeyword"
class="merchant-search-input"
placeholder="搜索商品名称/编码/厂家/规格"
placeholder-class="merchant-search-placeholder"
/>
</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="detail-empty">暂无可选商品</view>
<view v-else-if="displayedProductOptions.length === 0" class="detail-empty">该分类暂无商品</view>
<view v-else class="product-picker-list">
<view
v-for="product in displayedProductOptions"
:key="product._key"
class="product-picker-item"
@tap="selectProduct(product)"
>
<view class="product-picker-main">
<view class="product-picker-name">{{ product.name || '-' }}</view>
<view class="product-picker-sub">{{ product.makerAbb || '-' }} / {{ product.spec || '规格' }}</view>
<view class="product-picker-sub">编码:{{ product.goodsCode || '-' }}</view>
<!-- <view class="product-picker-income">奖金金额:¥{{ formatMoney(getTaskIncomeNumber(product)) }}</view>-->
</view>
<view class="product-picker-tag">{{ isProductSelected(product) ? '已添加' : '添加' }}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</u-popup>
</view>
</template>
<script>
import dayjs from 'dayjs'
import {productGroupByCateReq} from "@/api/product";
import {merchantGroupByChainReq} from "@/api/shop";
import {selfReportAuditDetailReq, selfReportChangeReq} from "@/api/self-report";
export default {
name: 'AuditSelfReportItem',
props: {
item: {
type: Object,
required: true
}
},
data() {
return {
detailExpanded: false,
detailLoading: false,
detailLoaded: false,
detailData: null,
showEditPopup: false,
editSaving: false,
showProductPicker: false,
productLoading: false,
productGroups: [],
productLoadedChainName: '',
productKeyword: '',
activeProductGroupKey: '',
editSalesItems: [],
activeEditSalesIndex: -1,
showMerchantEditPicker: false,
merchantEditLoading: false,
merchantEditSaving: false,
merchantEditChains: [],
merchantEditPickerValue: [0, 0],
merchantEditKeyword: '',
showDateEditPicker: false,
dateEditPickerValue: Date.now(),
dateEditPickerKey: 0,
dateEditSaving: false
}
},
computed: {
currentRecord() {
return this.detailData || this.item
},
clockImageList() {
const source = this.currentRecord
return this.parseClockImageList(source.clockPhotos)
},
boothImageList() {
const source = this.currentRecord
return this.parseImageList(source.boothPhotos)
},
receiptImageList() {
const source = this.currentRecord
return this.parseImageList(source.receiptPhotos)
},
salesRows() {
const source = this.currentRecord
const groups = Array.isArray(source.salesGroups) ? source.salesGroups : []
const rows = []
groups.forEach((group, gIdx) => {
const items = Array.isArray(group && group.items) ? group.items : []
items.forEach((detail, iIdx) => {
rows.push({
key: `${gIdx}-${iIdx}`,
groupTitle: group && group.title ? group.title : `分组${gIdx + 1}`,
name: detail && detail.name ? detail.name : '-',
makerAbb: detail && detail.makerAbb ? detail.makerAbb : '',
spec: detail && detail.spec ? detail.spec : '',
qty: detail && detail.qty !== undefined && detail.qty !== null ? detail.qty : 0,
unit: detail && detail.unit ? detail.unit : ''
})
})
})
return rows
},
filteredProductGroups() {
const keyword = (this.productKeyword || '').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()
const code = String(item.goodsCode || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword) || code.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 exists = groups.some(group => group.key === this.activeProductGroupKey)
return exists ? 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 : []
},
editTotalRewardAmount() {
return (Array.isArray(this.editSalesItems) ? this.editSalesItems : []).reduce((sum, item) => {
return sum + (this.parseNumber(item && item.qty) * this.getTaskIncomeNumber(item))
}, 0)
},
editTotalRewardDisplay() {
return this.formatMoney(this.editTotalRewardAmount)
},
merchantEditStores() {
const chainIndex = Number(this.merchantEditPickerValue[0] || 0)
const chain = this.merchantEditChains[chainIndex] || this.merchantEditChains[0] || {}
const stores = Array.isArray(chain.merchants) ? chain.merchants : []
const keyword = this.merchantEditKeyword.trim()
const filteredStores = keyword ? stores.filter(store => String(store.text || '').includes(keyword)) : stores
return filteredStores.length ? filteredStores : [{id: '', text: keyword ? '无匹配门店' : '暂无门店'}]
}
},
methods: {
async toggleDetailExpanded() {
const nextExpanded = !this.detailExpanded
this.detailExpanded = nextExpanded
if (!nextExpanded || this.detailLoaded || this.detailLoading || !this.item.id) return
this.detailLoading = true
try {
const {data} = await selfReportAuditDetailReq(this.item.id)
this.detailData = this.normalizeDetailData(data)
this.detailLoaded = true
} catch (error) {
uni.showToast({
title: '详情加载失败',
icon: 'none'
})
} finally {
this.detailLoading = false
}
},
emitPreview(url, list) {
this.$emit('preview', url, list)
},
handleApprove() {
this.$emit('approve', this.currentRecord)
},
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 ''
},
parseNumber(value) {
const text = String(value === null || value === undefined ? '' : value).trim()
if (!text) return 0
const num = Number(text)
return Number.isFinite(num) ? num : 0
},
formatMoney(value) {
const num = Number(value)
return Number.isFinite(num) ? num.toFixed(2) : '0.00'
},
getTaskIncomeNumber(item) {
if (!item || typeof item !== 'object') return 0
const num = Number(item.taskIncome !== undefined && item.taskIncome !== null ? item.taskIncome : item.income)
return Number.isFinite(num) ? num : 0
},
normalizeSalesGroupItem(detail = {}, groupTitle = '') {
const product = detail.product && typeof detail.product === 'object' ? detail.product : {}
const specOptions = Array.isArray(detail.specOptions) && detail.specOptions.length
? detail.specOptions
: Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: []
const productId = detail.productId || detail.goodsId || product.productId || product.goodsId || product.id || ''
const goodsCode = detail.goodsCode || detail.code || detail.productCode || product.goodsCode || product.code || product.productCode || ''
const name = detail.name || detail.goodsName || detail.title || product.goodsName || product.name || product.title || ''
const makerAbb = detail.makerAbb || detail.manufacturer || product.makerAbb || product.manufacturer || ''
const spec = detail.spec || detail.specification || product.spec || specOptions[0] || ''
const taskIncome = detail.taskIncome !== undefined && detail.taskIncome !== null
? detail.taskIncome
: product.income !== undefined && product.income !== null
? product.income
: detail.income || 0
return {
_key: detail._key || `${Date.now()}_${Math.random().toString(16).slice(2)}`,
groupTitle: groupTitle || detail.groupTitle || '自定义商品',
productId,
goodsCode,
name,
makerAbb,
spec,
specOptions,
unit: detail.unit || product.unit || '盒',
qty: detail.qty !== undefined && detail.qty !== null ? detail.qty : detail.num || 0,
taskIncome
}
},
flattenSalesGroups(groups) {
const source = Array.isArray(groups) ? groups : []
const rows = []
source.forEach((group, gIdx) => {
const items = Array.isArray(group && group.items) ? group.items : []
items.forEach((detail, iIdx) => {
rows.push(this.normalizeSalesGroupItem(Object.assign({}, detail, {
_key: detail && detail._key ? detail._key : `${gIdx}_${iIdx}_${Date.now()}`
}), group && group.title ? group.title : `分组${gIdx + 1}`))
})
})
return rows
},
buildSalesGroupsFromItems(items) {
const groupsMap = {}
;(Array.isArray(items) ? items : []).forEach(item => {
const groupTitle = item.groupTitle || '自定义商品'
if (!groupsMap[groupTitle]) {
groupsMap[groupTitle] = {
title: groupTitle,
items: []
}
}
groupsMap[groupTitle].items.push({
productId: item.productId || '',
goodsCode: item.goodsCode || '',
name: item.name || '',
makerAbb: item.makerAbb || '',
spec: item.spec || '',
taskIncome: this.getTaskIncomeNumber(item),
qty: this.parseNumber(item.qty),
unit: item.unit || '盒',
specOptions: Array.isArray(item.specOptions) ? item.specOptions : []
})
})
return Object.keys(groupsMap).map(key => groupsMap[key])
},
unwrapProductList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'children', 'productGroup', 'productList', 'products']
for (let i = 0; i < keys.length; i += 1) {
if (Array.isArray(data[keys[i]])) return data[keys[i]]
}
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 = ['productGroup', '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
}, [])
},
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] : []
},
normalizeProductNode(item = {}) {
const product = item && item.product ? item.product : {}
const goodsCode = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || product.batchCode || ''
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || product.goodsId || product.skuId || ''
const name = item.goodsName || item.goods_name || item.title || item.name || product.title || product.goodsName || product.name || ''
const makerAbb = item.makerAbb || item.maker || item.manufacturer || product.makerAbb || product.maker || product.manufacturer || ''
const spec = product.spec || item.spec || item.specification || ''
return {
productId,
goodsCode,
name,
makerAbb,
spec,
unit: item.unit || product.unit || '盒',
taskIncome: item.taskIncome !== undefined && item.taskIncome !== null ? item.taskIncome : (product.income || item.income || 0),
specOptions: this.normalizeSpecOptions(Object.assign({}, product, item))
}
},
buildProductGroupsFromData(rawData) {
const rootList = this.unwrapProductList(rawData)
const tabs = []
rootList.forEach((categoryNode, categoryIndex) => {
if (!categoryNode || typeof categoryNode !== 'object') return
const label = categoryNode.cate || categoryNode.title || categoryNode.name
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) {
const allProducts = this.collectProductNodes(rootList)
if (allProducts.length) {
tabs.push({
key: 'all',
label: '全部',
rawProducts: allProducts
})
}
}
let rowSeed = 0
return tabs.map((tab, tabIndex) => {
const items = (Array.isArray(tab.rawProducts) ? tab.rawProducts : [])
.map((rawItem, itemIndex) => {
const product = this.normalizeProductNode(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: `audit_product_${tabIndex}_${itemIndex}_${rowSeed++}`
}
})
.filter(Boolean)
return {
key: tab.key || `group_${tabIndex}`,
title: tab.label || `分类${tabIndex + 1}`,
items
}
}).filter(group => group.items.length)
},
async loadProductGroups() {
const chainName = String(this.currentRecord.chainName || '').trim()
if (!chainName || chainName === '-') {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
return
}
if (this.productLoadedChainName === chainName && this.productGroups.length) return
this.productLoading = true
try {
const {data} = await productGroupByCateReq({chain: chainName, type: 0})
this.productGroups = this.buildProductGroupsFromData(data)
this.productLoadedChainName = chainName
this.activeProductGroupKey = this.productGroups[0] ? this.productGroups[0].key : ''
} catch (error) {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
uni.showToast({
title: '商品数据获取失败',
icon: 'none'
})
} finally {
this.productLoading = false
}
},
async ensureDetailLoaded() {
if (this.detailLoaded || this.detailLoading || !this.item.id) return
this.detailLoading = true
try {
const {data} = await selfReportAuditDetailReq(this.item.id)
this.detailData = this.normalizeDetailData(data)
this.detailLoaded = true
} catch (error) {
uni.showToast({
title: '详情加载失败',
icon: 'none'
})
} finally {
this.detailLoading = false
}
},
unwrapMerchantList(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.unwrapMerchantList(value)
if (nested.length) return nested
}
}
return [data]
},
normalizeMerchant(item = {}) {
return {
id: item.id ?? item.merchantId ?? item.merchant_id ?? '',
text: item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
}
},
normalizeMerchantChain(item = {}, index) {
const text = item.chainName || item.chain || item.chainAbb || item.name || item.title || `连锁${index + 1}`
// 兜底必须用连锁名,不能用 `chain_${index}` 这类位置型占位符:
// 该 id 会被 saveMerchantEdit 写进 yxe_self_report.chain_id,而按连锁筛选走的是
// 等值匹配(YxeSelfReportMapper.xml: a.chain_id = #{param.chainId}),
// 占位符一旦落库,这条记录就再也筛不出来(后端已返回稳定 chainId,此处为兜底防线)。
const id = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? text
const childKeys = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
let children = []
for (let i = 0; i < childKeys.length; i += 1) {
if (Array.isArray(item[childKeys[i]]) && item[childKeys[i]].length) {
children = item[childKeys[i]]
break
}
}
let merchants = children
.map(child => this.normalizeMerchant(child))
.filter(merchant => merchant.id !== '' && merchant.id !== null && merchant.id !== undefined)
if (!merchants.length) {
const merchant = this.normalizeMerchant(item)
if (merchant.id !== '' && merchant.id !== null && merchant.id !== undefined) {
merchants = [merchant]
}
}
return {id, text, merchants}
},
async loadMerchantEditChains() {
if (this.merchantEditChains.length) return
try {
const {data} = await merchantGroupByChainReq({})
this.merchantEditChains = this.unwrapMerchantList(data)
.map((item, index) => this.normalizeMerchantChain(item, index))
.filter(chain => chain.merchants.length)
} catch (error) {
this.merchantEditChains = []
uni.showToast({title: '门店列表获取失败', icon: 'none'})
}
},
async openMerchantEditPicker() {
if (this.item.status !== 0) return
this.showMerchantEditPicker = true
this.merchantEditKeyword = ''
this.merchantEditLoading = true
try {
await this.ensureDetailLoaded()
await this.loadMerchantEditChains()
if (!this.merchantEditChains.length) {
this.showMerchantEditPicker = false
return
}
const record = this.currentRecord
const chainIndex = this.merchantEditChains.findIndex(chain => String(chain.id) === String(record.chainId))
const safeChainIndex = chainIndex > -1 ? chainIndex : 0
const stores = this.merchantEditChains[safeChainIndex].merchants
const merchantIndex = stores.findIndex(merchant => String(merchant.id) === String(record.merchantId))
this.merchantEditPickerValue = [safeChainIndex, merchantIndex > -1 ? merchantIndex : 0]
} finally {
this.merchantEditLoading = false
}
},
closeMerchantEditPicker() {
if (!this.merchantEditSaving) {
this.showMerchantEditPicker = false
this.merchantEditKeyword = ''
}
},
onMerchantEditPickerChange(event) {
const value = Array.isArray(event.detail.value) ? event.detail.value : [0, 0]
const chainIndex = Number(value[0] || 0)
const previousChainIndex = Number(this.merchantEditPickerValue[0] || 0)
this.merchantEditPickerValue = [chainIndex, chainIndex === previousChainIndex ? Number(value[1] || 0) : 0]
},
onMerchantEditSearchInput() {
this.merchantEditPickerValue = [Number(this.merchantEditPickerValue[0] || 0), 0]
},
async saveMerchantEdit() {
if (this.merchantEditSaving) return
const chainIndex = Number(this.merchantEditPickerValue[0] || 0)
const merchantIndex = Number(this.merchantEditPickerValue[1] || 0)
const chain = this.merchantEditChains[chainIndex]
const merchant = this.merchantEditStores[merchantIndex]
if (!chain || !merchant || !merchant.id) {
uni.showToast({title: '请选择门店', icon: 'none'})
return
}
this.merchantEditSaving = true
// 写入前兜底:`chain_${index}` 这类位置型占位符不能落库(详见 normalizeMerchantChain 注释),
// 否则该记录会被按连锁等值筛选永久漏掉。这里退回用连锁名作为标识。
const chainIdText = String(chain.id ?? '').trim()
const safeChainId = !chainIdText || chainIdText.startsWith('chain_')
? String(chain.text || '').trim()
: chainIdText
try {
await selfReportChangeReq({
id: this.currentRecord.id,
chainId: safeChainId,
chainName: chain.text,
merchantId: merchant.id,
merchantName: merchant.text
})
this.detailData = Object.assign({}, this.currentRecord, {
chainId: safeChainId,
chainName: chain.text,
merchantId: merchant.id,
merchantName: merchant.text
})
this.detailLoaded = true
this.showMerchantEditPicker = false
this.$emit('updated', this.detailData)
uni.showToast({title: '保存成功', icon: 'success'})
} catch (error) {
uni.showToast({title: error && error.message ? error.message : '保存失败', icon: 'none'})
} finally {
this.merchantEditSaving = false
}
},
async openDateEditPicker() {
if (this.item.status !== 0) return
await this.ensureDetailLoaded()
const value = dayjs(this.currentRecord.date || this.item.date)
this.dateEditPickerValue = value.isValid() ? value.valueOf() : Date.now()
this.dateEditPickerKey += 1
await this.$nextTick()
this.showDateEditPicker = true
},
closeDateEditPicker() {
if (!this.dateEditSaving) this.showDateEditPicker = false
},
async saveDateEdit(event) {
if (this.dateEditSaving) return
const date = dayjs(event.value).format('YYYY-MM-DD')
this.dateEditSaving = true
try {
await selfReportChangeReq({id: this.currentRecord.id, date})
this.detailData = Object.assign({}, this.currentRecord, {date})
this.detailLoaded = true
this.showDateEditPicker = false
this.$emit('updated', this.detailData)
uni.showToast({title: '保存成功', icon: 'success'})
} catch (error) {
uni.showToast({title: error && error.message ? error.message : '保存失败', icon: 'none'})
} finally {
this.dateEditSaving = false
}
},
async openEditPopup() {
if (this.item.status !== 0) return
await this.ensureDetailLoaded()
const salesGroups = this.parseMaybeJson(this.currentRecord.salesGroups, [])
this.editSalesItems = this.flattenSalesGroups(salesGroups)
this.productKeyword = ''
this.activeEditSalesIndex = -1
this.showEditPopup = true
await this.loadProductGroups()
},
closeEditPopup() {
this.showEditPopup = false
this.showProductPicker = false
this.activeEditSalesIndex = -1
this.productKeyword = ''
},
async openProductPicker(index) {
await this.loadProductGroups()
if (!this.productGroups.length) {
uni.showToast({
title: '当前连锁暂无可选商品',
icon: 'none'
})
return
}
this.activeEditSalesIndex = Number.isInteger(index) ? index : -1
this.productKeyword = ''
this.showProductPicker = true
if (!this.activeProductGroupKey && this.filteredProductGroups.length) {
this.activeProductGroupKey = this.filteredProductGroups[0].key
}
},
closeProductPicker() {
this.showProductPicker = false
this.activeEditSalesIndex = -1
},
selectProductGroup(key) {
this.activeProductGroupKey = key
},
isProductSelected(product) {
return (Array.isArray(this.editSalesItems) ? this.editSalesItems : []).some((item, index) => {
if (index === this.activeEditSalesIndex) return false
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 next = this.normalizeSalesGroupItem(product, product.groupTitle || '自定义商品')
if (this.activeEditSalesIndex >= 0 && this.editSalesItems[this.activeEditSalesIndex]) {
const current = this.editSalesItems[this.activeEditSalesIndex]
this.$set(this.editSalesItems, this.activeEditSalesIndex, Object.assign({}, current, next, {
qty: current.qty,
groupTitle: next.groupTitle || current.groupTitle
}))
} else {
next.qty = ''
this.editSalesItems.push(next)
}
this.closeProductPicker()
},
removeEditSalesItem(index) {
this.editSalesItems.splice(index, 1)
},
onEditQtyInput(event, index) {
const current = this.editSalesItems[index]
if (!current) return
const qty = event && event.detail ? event.detail.value : ''
this.$set(this.editSalesItems, index, Object.assign({}, current, {qty}))
},
onSpecChange(event, item) {
const value = Number(event.detail.value || 0)
item.spec = item.specOptions[value] || item.spec
},
parseImageList(list) {
const source = Array.isArray(list) ? list : []
return source
.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || ''
})
.filter(Boolean)
},
parseClockImageList(list) {
const source = Array.isArray(list) ? list : []
return source
.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || ''
})
.filter(Boolean)
},
parseMaybeJson(value, fallback) {
if (value === null || value === undefined || value === '') return fallback
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch (error) {
return fallback
}
}
return value
},
normalizeDetailData(data) {
const detail = data && typeof data === 'object' ? {...data} : {}
detail.clockPhotos = this.parseMaybeJson(detail.clockPhotos, [])
detail.boothPhotos = this.parseMaybeJson(detail.boothPhotos, [])
detail.receiptPhotos = this.parseImageList(this.parseMaybeJson(detail.receiptPhoto, []))
detail.salesGroups = this.parseMaybeJson(detail.salesGroups, [])
return detail
},
async saveEdit() {
if (this.editSaving) return
const items = Array.isArray(this.editSalesItems) ? this.editSalesItems : []
if (!items.length) {
uni.showToast({
title: '请至少保留一项销量商品',
icon: 'none'
})
return
}
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!String(item.name || '').trim()) {
uni.showToast({
title: '请选择商品',
icon: 'none'
})
return
}
if (this.parseNumber(item.qty) < 0) {
uni.showToast({
title: '数量不能小于0',
icon: 'none'
})
return
}
}
this.editSaving = true
try {
const salesGroups = this.buildSalesGroupsFromItems(items)
const reward = this.editTotalRewardAmount
await selfReportChangeReq({
id: this.currentRecord.id,
salesGroups: JSON.stringify(salesGroups),
reward
})
const nextRecord = Object.assign({}, this.currentRecord, {
salesGroups,
reward
})
this.detailData = nextRecord
this.detailLoaded = true
this.showEditPopup = false
this.showProductPicker = false
this.activeEditSalesIndex = -1
this.$emit('updated', nextRecord)
uni.showToast({
title: '保存成功',
icon: 'success'
})
} catch (error) {
uni.showToast({
title: error && error.message ? error.message : '保存失败',
icon: 'none'
})
} finally {
this.editSaving = false
}
},
formatPrice(value) {
const num = Number(value || 0)
return num.toFixed(2)
}
}
}
</script>
<style lang="scss" scoped>
@import './record.scss';
.record-section {
margin-top: 18rpx;
}
.record-meta-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.record-meta-action {
margin-top: 6rpx;
color: #2fc6a7;
font-size: 24rpx;
white-space: nowrap;
}
.record-section-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
margin-bottom: 12rpx;
}
.record-toggle {
margin-top: 12rpx;
display: inline-flex;
align-items: center;
gap: 8rpx;
color: #2fc6a7;
font-size: 24rpx;
}
.detail-empty {
color: #94a3b8;
font-size: 24rpx;
}
.record-section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 12rpx;
}
.record-section-title-row .record-section-title {
margin-bottom: 0;
}
.section-action {
color: #2fc6a7;
font-size: 24rpx;
white-space: nowrap;
}
.self-report-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
background: rgba(15, 23, 42, 0.42);
}
.self-report-popup {
position: fixed;
left: 20rpx;
right: 20rpx;
bottom: 20rpx;
z-index: 60;
max-height: 86vh;
overflow-y: auto;
padding: 20rpx;
border-radius: 18rpx;
background: #ffffff;
}
.popup-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.section-title {
font-size: 30rpx;
color: #1f2937;
font-weight: 700;
}
.close-btn {
color: #64748b;
font-size: 24rpx;
}
.edit-summary {
margin-top: 12rpx;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: #f7f9fc;
}
.edit-summary-label {
color: #64748b;
font-size: 24rpx;
}
.edit-summary-value {
color: #f5576c;
font-size: 30rpx;
font-weight: 700;
}
.detail-actions {
margin-top: 12rpx;
}
.ghost-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10rpx 16rpx;
border-radius: 999rpx;
color: #2563eb;
background: #eff6ff;
font-size: 24rpx;
}
.edit-sales-list {
margin-top: 12rpx;
}
.edit-sales-item {
margin-bottom: 16rpx;
padding: 16rpx;
border-radius: 12rpx;
background: #f7f9fc;
}
.edit-sales-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 10rpx;
}
.edit-sales-name {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.edit-sales-group {
margin-top: 4rpx;
font-size: 22rpx;
color: #64748b;
}
.edit-sales-remove {
color: #f5576c;
font-size: 24rpx;
white-space: nowrap;
}
.form-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 10rpx;
}
.label {
width: 120rpx;
color: #64748b;
font-size: 24rpx;
flex-shrink: 0;
}
.picker-input,
.input {
flex: 1;
min-width: 0;
}
.input {
min-height: 72rpx;
padding: 0 20rpx;
border-radius: 12rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-sizing: border-box;
}
.picker-input-inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72rpx;
padding: 0 20rpx;
border-radius: 12rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
box-sizing: border-box;
}
.picker-text {
color: #1f2937;
}
.picker-placeholder {
color: #94a3b8;
}
.bonus-text {
color: #f5576c;
font-weight: 700;
}
.qty-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.qty-input {
min-width: 0;
}
.qty-unit {
color: #64748b;
font-size: 24rpx;
white-space: nowrap;
}
.popup-actions {
margin-top: 16rpx;
}
.product-picker {
padding: 0;
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
}
.picker-title {
font-size: 30rpx;
color: #1f2937;
font-weight: 700;
}
.picker-action {
color: #64748b;
font-size: 26rpx;
}
.picker-action.primary {
color: #2563eb;
}
.merchant-picker-search {
padding: 0 24rpx 18rpx;
}
.merchant-edit-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.merchant-picker-view {
width: 100%;
height: 420rpx;
}
.merchant-picker-item {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
padding: 0 24rpx;
text-align: center;
font-size: 26rpx;
color: #1f2937;
}
.merchant-search-input {
height: 72rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #f7f9fc;
font-size: 26rpx;
}
.merchant-picker-loading {
min-height: 360rpx;
display: flex;
align-items: center;
justify-content: center;
}
.product-panel {
display: flex;
min-height: 540rpx;
}
.category-sidebar {
width: 180rpx;
max-height: 540rpx;
background: #f8fafc;
}
.category-item {
padding: 18rpx 14rpx;
font-size: 24rpx;
color: #475569;
}
.category-item-active {
color: #2563eb;
font-weight: 700;
background: #ffffff;
}
.product-panel-main {
flex: 1;
max-height: 540rpx;
padding: 0 18rpx 20rpx 12rpx;
}
.product-picker-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.product-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 14rpx 12rpx;
border-radius: 12rpx;
background: #f8fafc;
}
.product-picker-main {
flex: 1;
min-width: 0;
}
.product-picker-name {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.product-picker-sub,
.product-picker-income {
margin-top: 4rpx;
font-size: 22rpx;
color: #64748b;
}
.product-picker-income {
color: #f5576c;
}
.product-picker-tag {
color: #2563eb;
font-size: 24rpx;
white-space: nowrap;
}
</style>