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 数量逐文件相等。
188 lines
6.0 KiB
Vue
188 lines
6.0 KiB
Vue
<template>
|
||
<view>
|
||
<view class="record">
|
||
<view>
|
||
<view class="record-meta">门店数:{{ currentRecord.storeCount || storeRows.length || 0 }} 家</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 v-if="loadingDetail" class="detail-empty">加载中...</view>
|
||
<view v-else-if="!storeRows.length" class="detail-empty">暂无巡店记录</view>
|
||
<view v-for="(store, idx) in storeRows" :key="store._key" class="visit-store-block">
|
||
<view class="visit-store-head">
|
||
<text class="visit-store-title">巡店{{ idx + 1 }}</text>
|
||
<text class="visit-store-time">{{ store.visitTime || '-' }}</text>
|
||
</view>
|
||
<view class="record-meta">连锁:{{ store.chainName || '-' }}</view>
|
||
<view class="record-meta">门店:{{ store.merchantName || '-' }}</view>
|
||
<view class="record-meta">
|
||
定位:{{ store.location || '-' }}
|
||
<text v-if="store.lat && store.lng">({{ store.lat }}, {{ store.lng }})</text>
|
||
</view>
|
||
<view v-if="store.photos.length" class="media-images">
|
||
<image
|
||
v-for="(img, imgIdx) in store.photos"
|
||
:key="img"
|
||
class="media-image"
|
||
:src="img"
|
||
mode="aspectFill"
|
||
@click="emitPreview(img, store.photos)"
|
||
/>
|
||
</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>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import {visitReportInfoReq} from "@/api/visit-report";
|
||
|
||
export default {
|
||
name: 'AuditVisitReportItem',
|
||
props: {
|
||
item: {
|
||
type: Object,
|
||
required: true
|
||
}
|
||
},
|
||
emits: ['preview', 'approve', 'reject'],
|
||
data() {
|
||
return {
|
||
detailExpanded: false,
|
||
loadingDetail: false,
|
||
detailLoaded: false,
|
||
detailData: null
|
||
}
|
||
},
|
||
computed: {
|
||
currentRecord() {
|
||
return this.detailData || this.item
|
||
},
|
||
// 列表接口不下发 storeList 大字段,展开时再拉详情;列表里已有则直接用
|
||
storeRows() {
|
||
const source = this.currentRecord
|
||
const list = this.parseMaybeJson(source.storeList, [])
|
||
return (Array.isArray(list) ? list : []).map((store, sIdx) => ({
|
||
// wx:key 只认属性名,表达式会被编译丢弃;巡店记录本身没有 id,按序号补一个
|
||
_key: `store_${sIdx}`,
|
||
merchantName: store && (store.merchantName || store.storeName) ? (store.merchantName || store.storeName) : '',
|
||
chainName: store && store.chainName ? store.chainName : '',
|
||
visitTime: store && (store.visitTime || store.time) ? (store.visitTime || store.time) : '',
|
||
location: store && store.location ? store.location : '',
|
||
lat: store && store.lat !== undefined ? store.lat : '',
|
||
lng: store && store.lng !== undefined ? store.lng : '',
|
||
photos: this.parseImageList(store && store.photos)
|
||
}))
|
||
}
|
||
},
|
||
methods: {
|
||
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
|
||
},
|
||
parseImageList(value) {
|
||
const list = Array.isArray(value) ? value : []
|
||
return list
|
||
.map(item => (typeof item === 'string' ? item : (item && (item.url || item.path || item.image)) || ''))
|
||
.filter(Boolean)
|
||
},
|
||
emitPreview(url, list) {
|
||
this.$emit('preview', url, list)
|
||
},
|
||
handleApprove() {
|
||
this.$emit('approve', this.currentRecord)
|
||
},
|
||
async toggleDetailExpanded() {
|
||
this.detailExpanded = !this.detailExpanded
|
||
if (!this.detailExpanded || this.detailLoaded) return
|
||
const id = this.item && this.item.id
|
||
if (id === undefined || id === null) return
|
||
this.loadingDetail = true
|
||
try {
|
||
const res = await visitReportInfoReq(id)
|
||
if (res && res.code !== 0) {
|
||
uni.showToast({title: res.message || '详情加载失败', icon: 'none'})
|
||
return
|
||
}
|
||
this.detailData = res && res.data && typeof res.data === 'object' ? res.data : null
|
||
this.detailLoaded = true
|
||
} catch (error) {
|
||
uni.showToast({
|
||
title: error && error.message ? error.message : '详情加载失败',
|
||
icon: 'none'
|
||
})
|
||
} finally {
|
||
this.loadingDetail = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import './record.scss';
|
||
|
||
.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;
|
||
}
|
||
|
||
.visit-store-block {
|
||
margin-top: 16rpx;
|
||
padding: 16rpx;
|
||
border-radius: 16rpx;
|
||
background: #f9fafb;
|
||
border: 1rpx solid #eef2f7;
|
||
}
|
||
|
||
.visit-store-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.visit-store-title {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.visit-store-time {
|
||
font-size: 22rpx;
|
||
color: #9ca3af;
|
||
}
|
||
</style>
|