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

349 lines
9.8 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<view class="record">
<view>
<view class="record-meta" @click="goDetail" v-if="item.taskTitle">任务:{{ item.taskTitle }}</view>
<view class="record-meta" @click="goDetail" v-if="item.chainName && item.chainName !== '-'">连锁:{{ item.chainName }}</view>
<view class="record-meta" @click="goDetail" v-if="item.merchantName">门店:{{ item.merchantName }}</view>
<view class="record-meta" @click="goDetail">提交人:{{ item.owner }} · {{ item.submitTime }}</view>
<view v-if="item.imageList && item.imageList.length" class="record-media">
<view v-if="item.imageList && item.imageList.length" class="media-images">
<image
v-for="(img, idx) in item.imageList"
:key="idx"
class="media-image"
:src="resolveMediaUrl(img)"
mode="aspectFill"
@click="emitPreview(resolveMediaUrl(img), buildPreviewList(item.imageList))"
/>
</view>
</view>
</view>
<view class="record-actions" v-if="item.status === 1">
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="$emit('approve', item)">通过</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="openCopyPopup">抄送</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="goDetail">查看详情</view>
</view>
</view>
<view class="record-actions" v-if="item.status === 3">
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="openCopyPopup">抄送</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="goDetail">查看详情</view>
</view>
</view>
</view>
<view v-if="showCopyPopup" class="copy-popup-mask" @tap="closeCopyPopup" @touchmove.stop.prevent></view>
<view v-if="showCopyPopup" class="copy-popup card" @tap.stop>
<view class="copy-popup-head">
<view class="section-title">抄送</view>
<view class="close-btn" @tap="closeCopyPopup">关闭</view>
</view>
<view class="copy-search-row">
<input
v-model="copyKeyword"
class="copy-search-input"
type="text"
placeholder="输入姓名/手机号搜索用户"
@confirm="searchCopyUsers"
/>
<view class="copy-search-btn" @tap="searchCopyUsers">搜索</view>
</view>
<view v-if="copySearching" class="copy-loading">搜索中...</view>
<scroll-view v-else class="copy-user-list" scroll-y>
<view
v-for="user in copyUsers"
:key="user.id"
:class="['copy-user-item', selectedCopyUserId === user.id ? 'copy-user-item-active' : '']"
@tap="selectedCopyUserId = user.id"
>
<text class="copy-user-name">{{ user.label }}</text>
<text class="copy-user-phone">{{ user.phone || '' }}</text>
</view>
<view v-if="copyUsers.length === 0" class="copy-empty">暂无用户</view>
</scroll-view>
<view class="primary-btn copy-submit" @tap="submitCopy">确认抄送</view>
</view>
</view>
</template>
<script>
import {userSearchReq} from "@/api/user";
import {copyUserTaskReq} from "@/api/task";
export default {
name: 'AuditStoreVisitItem',
props: {
item: {
type: Object,
required: true
}
},
data() {
return {
showCopyPopup: false,
copyKeyword: '',
copyUsers: [],
copySearching: false,
selectedCopyUserId: ''
}
},
methods: {
emitPreview(url, list) {
this.$emit('preview', url, list)
},
resolveMediaUrl(media) {
if (!media) return ''
if (typeof media === 'string') return media
return media.url || media.path || media.image || ''
},
buildPreviewList(list) {
const items = Array.isArray(list) ? list : []
return items.map(item => this.resolveMediaUrl(item)).filter(Boolean)
},
goDetail() {
const taskId = this.item.taskId
if (taskId === '' || taskId === null || taskId === undefined) {
uni.showToast({
title: '任务ID缺失',
icon: 'none'
})
return
}
const title = this.item.merchantName || this.item.taskTitle || ''
const reward = this.item.taskReward !== undefined && this.item.taskReward !== null ? this.item.taskReward : ''
const target = this.item.taskTarget || ''
let url = `/pages/task/detail?id=${taskId}`
url += `&fromAudit=1`
if (this.item.id !== undefined && this.item.id !== null && this.item.id !== '') {
url += `&auditId=${encodeURIComponent(String(this.item.id))}`
}
if (this.item.taskId !== undefined && this.item.taskId !== null && this.item.taskId !== '') {
url += `&auditTaskId=${encodeURIComponent(String(this.item.taskId))}`
}
if (this.item.userId !== undefined && this.item.userId !== null && this.item.userId !== '') {
url += `&auditUserId=${encodeURIComponent(String(this.item.userId))}`
}
if (this.item.status !== undefined && this.item.status !== null && this.item.status !== '') {
url += `&auditStatus=${encodeURIComponent(String(this.item.status))}`
}
if (title) url += `&title=${encodeURIComponent(title)}`
if (reward !== '') url += `&reward=${encodeURIComponent(String(reward))}`
if (target) url += `&target=${encodeURIComponent(target)}`
uni.navigateTo({url})
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result']
for (let i = 0; i < keys.length; i++) {
if (Array.isArray(data[keys[i]])) return data[keys[i]]
}
return []
},
normalizeUser(item) {
const id = item.id ?? item.userId ?? item.uid
const nickname = item.nickname || item.nickName || ''
const realName = item.realName || item.real_name || item.name || ''
const phone = item.phone || item.mobile || item.tel || ''
const label = realName || nickname || phone || `用户${id || ''}`
return {
id,
label,
phone
}
},
openCopyPopup() {
this.copyKeyword = ''
this.copyUsers = []
this.selectedCopyUserId = ''
this.showCopyPopup = true
},
closeCopyPopup() {
this.showCopyPopup = false
},
async searchCopyUsers() {
const keyword = (this.copyKeyword || '').trim()
if (!keyword) {
uni.showToast({
title: '请输入keyword',
icon: 'none'
})
return
}
this.copySearching = true
try {
const {data} = await userSearchReq({
keywords: keyword
})
const list = this.unwrapList(data)
this.copyUsers = list
.map(item => this.normalizeUser(item))
.filter(item => item.id !== undefined && item.id !== null && String(item.id).trim() !== '')
this.selectedCopyUserId = this.copyUsers[0] ? this.copyUsers[0].id : ''
} catch (error) {
this.copyUsers = []
uni.showToast({
title: '搜索失败',
icon: 'none'
})
} finally {
this.copySearching = false
}
},
async submitCopy() {
const targetUserId = this.selectedCopyUserId
if (targetUserId === '' || targetUserId === null || targetUserId === undefined) {
uni.showToast({
title: '请选择用户',
icon: 'none'
})
return
}
try {
await copyUserTaskReq({
id: this.item.id,
taskId: this.item.taskId,
sourceUserId: this.item.userId,
userId: targetUserId,
isCopy: 1
})
uni.showToast({
title: '抄送成功',
icon: 'success'
})
this.closeCopyPopup()
this.$emit('copied')
} catch (error) {
uni.showToast({
title: '抄送失败',
icon: 'none'
})
}
}
}
}
</script>
<style lang="scss" scoped>
@import './record.scss';
.copy-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 30;
}
.copy-popup {
position: fixed;
left: 50%;
bottom: 40rpx;
transform: translateX(-50%);
width: 88%;
max-height: 70vh;
overflow: auto;
z-index: 40;
}
.copy-popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.close-btn {
color: #6b7280;
font-size: 24rpx;
}
.copy-search-row {
display: flex;
gap: 10rpx;
align-items: center;
}
.copy-search-input {
flex: 1;
height: 70rpx;
line-height: 70rpx;
border-radius: 12rpx;
background: #f3f4f6;
padding: 0 16rpx;
font-size: 26rpx;
}
.copy-search-btn {
min-width: 110rpx;
height: 70rpx;
border-radius: 12rpx;
background: #2fc6a7;
color: #ffffff;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.copy-loading,
.copy-empty {
margin-top: 14rpx;
font-size: 24rpx;
color: #6b7280;
}
.copy-user-list {
margin-top: 14rpx;
max-height: 420rpx;
}
.copy-user-item {
padding: 12rpx;
border-radius: 12rpx;
background: #f9fafb;
margin-bottom: 10rpx;
border: 1rpx solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.copy-user-item-active {
border-color: #2fc6a7;
background: #ecfdf5;
}
.copy-user-name {
font-size: 26rpx;
color: #111827;
}
.copy-user-phone {
font-size: 22rpx;
color: #6b7280;
}
.copy-submit {
margin-top: 16rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
</style>