Files
yxe-mp/packageUser/self-report/create.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

1761 lines
56 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="self-report-page">
<AppNavbar :title="isResubmit ? '重新提交填报' : '驻店填报'" showBack/>
<view class="sr-merchant-row" @tap="openChainPicker">
<view class="sr-merchant-label">连锁筛选</view>
<view class="sr-merchant-right">
<text :class="form.chainId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ selectedChainText }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="sr-merchant-row" @tap="openMerchantPicker">
<view class="sr-merchant-label">门店筛选</view>
<view class="sr-merchant-right">
<text :class="form.merchantId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ selectedMerchantText }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="sr-merchant-row" @tap="openActivityTimePicker">
<view class="sr-merchant-label">活动日期</view>
<view class="sr-merchant-right">
<text :class="form.date ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ form.date || '请选择活动日期' }}
</text>
<u-icon name="calendar" size="20" color="#9ca3af"/>
</view>
</view>
<view class="card block">
<view class="section-title">上班打卡(水印拍照)</view>
<view class="clock-grid">
<view class="clock-card primary">
<view class="clock-thumb" @tap="handleClockAction(0)">
<image v-if="clockPhotos[0] && clockPhotos[0].url" :src="clockPhotos[0].url" mode="aspectFill" class="clock-image"/>
<view v-else class="clock-placeholder">
<u-icon name="photo" size="40" color="#52627a"/>
</view>
<view v-if="clockPhotos[0] && clockPhotos[0].url" class="clock-check">
<u-icon name="checkmark-circle-fill" size="22" color="#22c55e"/>
</view>
<view class="clock-info">
<view class="clock-time">{{ (clockPhotos[0] && clockPhotos[0].time) || '--:--' }}</view>
<view class="clock-date">{{ (clockPhotos[0] && clockPhotos[0].date) || '--' }}</view>
</view>
</view>
</view>
</view>
</view>
<view class="card block">
<view class="section-title-row">
<view class="section-title">门头摆台照</view>
<view class="section-extra">最多{{ boothLimit }}张</view>
</view>
<view class="photo-grid">
<view v-for="(item, idx) in boothPhotos" :key="item" class="photo-item">
<image :src="item" mode="aspectFill" class="photo-image" @tap="previewImages(boothPhotos, item)"/>
<view class="photo-remove" @tap="removeBooth(idx)">x</view>
</view>
<view v-if="boothPhotos.length < boothLimit" class="photo-item add" @tap="uploadBooth">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
</view>
</view>
<view class="card block">
<view class="section-title-row">
<view class="section-title">销量填写</view>
<view class="section-extra">已添加{{ totalSalesItemCount }}项</view>
</view>
<view class="sales-entry" @tap="openSalesEntry">
<view class="sales-entry-left">
<view class="sales-entry-icon">
<u-icon name="list" size="22" color="#ff7a35"/>
</view>
<view class="sales-entry-text">
<view class="sales-entry-title">
{{ totalSalesItemCount > 0 ? '已填写销量商品' : '去填写销量' }}
</view>
<view class="sales-entry-sub">
{{ totalSalesItemCount > 0 ? `共 ${totalSalesItemCount} 项,点击可继续修改` : '支持搜索、分类筛选与批量填写' }}
</view>
</view>
</view>
<view class="sales-entry-right">
<text v-if="totalSalesItemCount > 0" class="sales-entry-count">{{ totalSalesItemCount }}</text>
<u-icon name="arrow-right" size="18" color="#9ca3af"/>
</view>
</view>
<view v-if="salesItems.length === 0" class="audit-empty-tip">请点击上方进入「销量填写」添加商品</view>
<view v-else class="sales-summary-list">
<view class="sales-summary-item" v-for="(item, idx) in salesSummaryRows" :key="item._key">
<text class="sales-summary-name">{{ item.name }}</text>
<text class="sales-summary-qty">{{ formatSalesQty(item.qty) }}{{ item.unit || '盒' }}</text>
</view>
<view v-if="salesItems.length > salesSummaryLimit" class="sales-summary-more">
等共 {{ totalSalesItemCount }} 项
</view>
</view>
</view>
<!-- <view class="card block reward-summary-card">-->
<!-- <text class="reward-summary-label">奖金合计</text>-->
<!-- <text class="reward-summary-value">{{ totalRewardDisplay }}</text>-->
<!-- </view>-->
<view class="card block">
<view class="section-title-row">
<view class="section-title tight">
小票上传
<text class="section-sub">(必须原图)</text>
</view>
<view class="section-extra">最多{{ receiptLimit }}张</view>
</view>
<view class="photo-grid">
<view v-for="(item, idx) in receiptPhotos" :key="item" class="photo-item">
<image :src="item" mode="aspectFill" class="photo-image" @tap="previewImages(receiptPhotos, item)"/>
<view class="photo-remove" @tap="removeReceipt(idx)">x</view>
</view>
<view v-if="receiptPhotos.length < receiptLimit" class="photo-item add" @tap="uploadReceipt">
<u-icon name="arrow-upward" size="30" color="#c6ccd8"/>
</view>
</view>
</view>
<view class="card block">
<view class="section-title">活动小结</view>
<textarea v-model="form.summary" class="summary-input" placeholder="请填写活动情况"/>
</view>
<view class="card block">
<view class="section-title">下班打卡(水印拍照)</view>
<view class="clock-grid">
<view class="clock-card warm">
<view class="clock-thumb" @tap="handleClockAction(1)">
<image v-if="clockPhotos[1] && clockPhotos[1].url" :src="clockPhotos[1].url" mode="aspectFill" class="clock-image"/>
<view v-else class="clock-placeholder">
<u-icon name="photo" size="40" color="#52627a"/>
</view>
<view v-if="clockPhotos[1] && clockPhotos[1].url" class="clock-check">
<u-icon name="checkmark-circle-fill" size="22" color="#22c55e"/>
</view>
<view class="clock-info">
<view class="clock-time">{{ (clockPhotos[1] && clockPhotos[1].time) || '--:--' }}</view>
<view class="clock-date">{{ (clockPhotos[1] && clockPhotos[1].date) || '--' }}</view>
</view>
</view>
</view>
</view>
</view>
<view class="card block">
<view class="section-title-row">
<view class="section-title warm">门店评分</view>
<view class="section-extra">点击星星评分</view>
</view>
<view class="rating-row">
<u-rate
:count="5"
:size="34"
active-color="#ff8a4c"
inactive-color="#e4e8ef"
:value="form.rating"
@input="form.rating = $event"
/>
</view>
</view>
<view class="card block" v-if="isResubmit && rejectReason">
<view class="section-title">驳回原因</view>
<view class="reject-reason-text">{{ rejectReason }}</view>
</view>
<view class="form-actions">
<view v-if="!isResubmit" class="draft-btn" @tap="saveDraft">保存草稿</view>
<view class="primary-btn submit" @tap="submit">{{ submitLoading ? '提交中...' : (isResubmit ? '重新提交' : '提交填报') }}</view>
</view>
<view class="form-actions-placeholder"/>
<u-popup :show="showChainPicker" mode="bottom" round="24rpx" @close="closeChainPicker">
<view class="merchant-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeChainPicker">取消</view>
<text class="picker-title">选择连锁</text>
<view class="picker-action primary" @tap="confirmChainPicker">确定</view>
</view>
<view v-if="merchantLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<picker-view
v-else
class="merchant-picker-view"
:value="chainPickerValue"
:indicator-style="merchantIndicatorStyle"
@change="onChainPickerChange"
>
<picker-view-column>
<view v-for="(item, idx) in merchantChainOptions" :key="item.id" class="merchant-picker-item">
{{ item.text }}
</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
<u-popup :show="showMerchantPicker" mode="bottom" round="24rpx" @close="closeMerchantPicker">
<view class="merchant-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantPicker">取消</view>
<text class="picker-title">选择门店</text>
<view class="picker-action primary" @tap="confirmMerchantPicker">确定</view>
</view>
<view class="merchant-picker-search">
<view class="merchant-search-item">
<u-input v-model="merchantStoreSearchKeyword" placeholder="搜索门店" shape="circle" :custom-style="{border:'3px solid #ff8a4c !important'}"/>
</view>
</view>
<view v-if="merchantLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<picker-view
v-else
class="merchant-picker-view"
:value="merchantPickerValue"
:indicator-style="merchantIndicatorStyle"
@change="onMerchantPickerChange"
>
<picker-view-column>
<view v-for="(item, idx) in merchantStoreOptions" :key="item.id" class="merchant-picker-item">
{{ item.text }}
</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
<u-datetime-picker
:show="showActivityTimePicker"
:value="activityTimePickerValue"
mode="date"
@confirm="onActivityTimeConfirm"
@cancel="closeActivityTimePicker"
@close="closeActivityTimePicker"
/>
<Login ref="Login"/>
</view>
</template>
<script>
import dayjs from "dayjs";
import {Base64} from "js-base64";
import Login from "@/components/Login.vue";
import AppNavbar from "@/components/AppNavbar.vue";
import {merchantGroupByChainReq} from "@/api/shop";
import {selfReportAddReq, selfReportInfoReq, selfReportResubmitReq} from "@/api/self-report";
import {getUserInfo} from "@/util/user";
import {chooseImg, getLocationOnly} from "@/util";
import {
setSalesHandoff,
getSalesHandoff,
clearSalesHandoff,
cloneSalesItems
} from "@/util/sales-handoff";
import {
normalizeMerchant as sharedNormalizeMerchant,
pickFirstText as sharedPickFirstText,
unwrapList as sharedUnwrapList,
normalizeMerchantChain as sharedNormalizeMerchantChain,
groupMerchantListByChain as sharedGroupMerchantListByChain,
flattenMerchantsFromChains as sharedFlattenMerchantsFromChains,
filterMerchantStores as sharedFilterMerchantStores
} from "@/util/store-picker";
export default {
components: {Login, AppNavbar},
data() {
return {
isLoggedIn: false,
hasInitialized: false,
merchantLoading: false,
showChainPicker: false,
showMerchantPicker: false,
showActivityTimePicker: false,
merchantStoreSearchKeyword: '',
chainPickerValue: [0],
merchantPickerValue: [0],
merchantChainList: [],
form: this.buildForm(),
clockPhotos: [
{key: 'in', label: '上班', time: '', date: '', url: '', lat: '', lng: '', location: ''},
{key: 'out', label: '下班', time: '', date: '', url: '', lat: '', lng: '', location: ''}
],
boothPhotos: [],
boothLimit: 6,
receiptPhotos: [],
receiptLimit: 9,
salesItems: [],
currentAddress: '',
lat: null,
lng: null,
currentProvince: '',
currentCity: '',
draftPrompting: false,
submitLoading: false,
// 驳回后重新提交:记录 id 与驳回原因,提交时走 resubmit 接口
isResubmit: false,
resubmitId: '',
rejectReason: '',
resubmitLoading: false
}
},
computed: {
selectedChainText() {
if (!this.form.chainId) return this.merchantLoading ? '连锁加载中...' : '请选择连锁'
return this.form.chainName || '请选择连锁'
},
selectedMerchantText() {
if (!this.form.chainId) return '请先选择连锁'
if (!this.form.merchantId) return '请选择门店'
return this.form.merchantName || '请选择门店'
},
currentAddressText() {
return this.currentAddress || '暂未获取定位'
},
activityTimePickerValue() {
const value = this.form && this.form.date
const parsed = dayjs(value)
return parsed.isValid() ? parsed.valueOf() : Date.now()
},
merchantChainOptions() {
const list = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
return list.length ? list : [{id: '', text: '暂无连锁', merchantCount: 0}]
},
merchantStoreOptions() {
// 门店明细不放在页面 data 里(见 setMerchantChains),从实例上的 store 取。
// 这里读 merchantChainList 是为了建立响应式依赖:门店数据到达后需要重算。
const chainList = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
const hasChain = chainList.some(chain => String(chain.id) === String(this.form.chainId))
const merchants = hasChain ? this.getMerchantsByChainId(this.form.chainId) : []
const keyword = (this.merchantStoreSearchKeyword || '').trim()
const filteredMerchants = this.filterMerchantStores(merchants)
return filteredMerchants.length ? filteredMerchants : [{id: '', text: keyword ? '无匹配门店' : '无门店'}]
},
merchantIndicatorStyle() {
return 'height: 64px;'
},
totalSalesItemCount() {
return Array.isArray(this.salesItems) ? this.salesItems.length : 0
},
// 填报页只做摘要展示,明细与编辑都在「销量填写」页
salesSummaryLimit() {
return 3
},
salesSummaryRows() {
const items = Array.isArray(this.salesItems) ? this.salesItems : []
// wx:key 需要稳定唯一标识;销量项正常都带 _key(销量页生成/重提回填),
// 旧草稿缺失时按序号兜底,避免出现重复 key
return items.slice(0, this.salesSummaryLimit).map((item, index) => Object.assign({}, item, {
_key: (item && item._key) ? item._key : `sales_summary_${index}`
}))
},
totalRewardAmount() {
return (Array.isArray(this.salesItems) ? this.salesItems : []).reduce((sum, item) => {
return sum + (this.parseSalesQty(item && item.qty) * this.getTaskIncomeNumber(item))
}, 0)
},
totalRewardDisplay() {
return `¥${this.formatMoney(this.totalRewardAmount)}`
}
},
watch: {
merchantStoreSearchKeyword() {
this.merchantPickerValue = [0]
}
},
created() {
// 门店明细挂实例上而非 data:data 里的一切都会进页面 data 树并被 setData 序列化。
this.merchantStore = []
},
async onLoad(options = {}) {
const id = options && options.id !== undefined && options.id !== null ? String(options.id).trim() : ''
if (id && String(options.mode || '') === 'resubmit') {
this.isResubmit = true
this.resubmitId = id
}
},
async onShow() {
this.isLoggedIn = !!getUserInfo().token
if (!this.hasInitialized) {
await this.init()
this.hasInitialized = true
if (this.isResubmit) {
await this.loadResubmitRecord()
} else {
await this.checkDraftOnEntry()
}
return
}
// 从「销量填写」页返回时接收交接数据(仅在用户主动保存/提交后才有 action)
this.consumeSalesHandoff()
},
methods: {
// 销量页只回传数据,整单的提交动作仍由本页的「提交填报」统一完成。
// action='submit' 为销量页点了它自己的「提交销量」,'merge' 为返回时同步明细,
// 两者都只做合并,不代表本页已提交。
consumeSalesHandoff() {
const handoff = getSalesHandoff()
if (!handoff || !handoff.action) return
const items = Array.isArray(handoff.items) ? handoff.items : []
this.salesItems = cloneSalesItems(items)
clearSalesHandoff()
if (handoff.action === 'submit' && !items.length) {
uni.showToast({title: '销量商品不能为空', icon: 'none'})
}
},
openSalesEntry() {
if (!this.form.chainId || !this.form.merchantId) {
uni.showToast({title: '请先选择连锁和门店', icon: 'none'})
return
}
// 销量页需要连锁名去拉商品库,进页前先把当前明细交出去
setSalesHandoff({
action: '',
chainName: this.form.chainName || '',
items: cloneSalesItems(this.salesItems)
})
uni.navigateTo({
url: '/packageUser/self-report/sales',
fail: () => {
uni.showToast({title: '页面打开失败', icon: 'none'})
}
})
},
buildForm() {
return {
chainId: '',
chainName: '',
date: '',
merchantId: '',
merchantName: '',
province: '',
city: '',
lat: '',
lng: '',
summary: '',
rating: 5
}
},
async init() {
this.isLoggedIn = !!getUserInfo().token
this.form = this.buildForm()
this.clockPhotos = [
{key: 'in', label: '上班', time: '', date: '', url: '', lat: '', lng: '', location: ''},
{key: 'out', label: '下班', time: '', date: '', url: '', lat: '', lng: '', location: ''}
]
this.boothPhotos = []
this.receiptPhotos = []
this.salesItems = []
this.showChainPicker = false
this.currentAddress = ''
this.lat = null
this.lng = null
this.currentProvince = ''
this.currentCity = ''
await this.getMerchantList()
},
getDraftStorageKey() {
const user = getUserInfo() || {}
const uid = user.uid || user.id || user.userId || 'guest'
return `yxe_self_report_create_draft_${uid}`
},
parseStoredJson(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
},
// 记录里的图片字段可能是字符串/对象/数组混用,统一取 URL
pickMediaUrl(media) {
if (!media) return ''
if (typeof media === 'string') return media
return media.url || media.path || media.image || ''
},
normalizeResubmitClockPhotos(value) {
const list = this.parseStoredJson(value, [])
const source = Array.isArray(list) ? list : []
const defaults = [
{key: 'in', label: '上班'},
{key: 'out', label: '下班'}
]
return defaults.map((item, index) => {
const raw = source[index] && typeof source[index] === 'object' ? source[index] : {}
return {
key: raw.key || item.key,
label: raw.label || item.label,
time: raw.time || '',
date: raw.date || '',
url: this.pickMediaUrl(raw.url || raw) || '',
lat: raw.lat || '',
lng: raw.lng || '',
location: raw.location || ''
}
})
},
normalizeResubmitPhotos(value) {
const list = this.parseStoredJson(value, [])
return (Array.isArray(list) ? list : []).map(item => this.pickMediaUrl(item)).filter(Boolean)
},
// 销量条目回填成 create 页的 salesItems 结构,specOptions 缺失时用已有 spec 兜底
normalizeResubmitSalesItems(value) {
const groups = this.parseStoredJson(value, [])
const items = []
;(Array.isArray(groups) ? groups : []).forEach((group, groupIndex) => {
const rows = Array.isArray(group && group.items) ? group.items : []
rows.forEach((detail, rowIndex) => {
if (!detail || typeof detail !== 'object') return
const spec = detail.spec || detail.specification || ''
const specOptions = Array.isArray(detail.specOptions) && detail.specOptions.length
? detail.specOptions.map(item => `${item}`.trim()).filter(Boolean)
: (spec ? [spec] : [])
items.push({
_key: `resubmit_${groupIndex}_${rowIndex}`,
productId: detail.productId || detail.goodsId || '',
goodsCode: detail.goodsCode || detail.code || '',
name: detail.name || detail.goodsName || '',
cover: detail.cover || '',
makerAbb: detail.makerAbb || detail.manufacturer || '',
groupTitle: (group && group.title) || detail.groupTitle || '自定义商品',
spec: spec || specOptions[0] || '',
specOptions,
unit: detail.unit || '盒',
taskIncome: detail.taskIncome !== undefined && detail.taskIncome !== null ? detail.taskIncome : '',
qty: detail.qty !== undefined && detail.qty !== null ? `${detail.qty}` : ''
})
})
})
return items
},
async loadResubmitRecord() {
if (!this.resubmitId || this.resubmitLoading) return
this.resubmitLoading = true
try {
const res = await selfReportInfoReq(this.resubmitId)
// get 请求在业务失败时也会 resolve,需要显式看 code
if (res && res.code !== 0) {
await this.toast(res.message || '填报记录加载失败')
uni.navigateBack()
return
}
const record = res && res.data && typeof res.data === 'object' ? res.data : null
if (!record) {
await this.toast('填报记录不存在')
uni.navigateBack()
return
}
if (Number(record.status) !== 2) {
await this.toast('该记录不是已驳回状态,无法重新提交')
uni.navigateBack()
return
}
this.rejectReason = record.rejectReason || record.reject_reason || ''
this.form = Object.assign(this.buildForm(), {
chainId: record.chainId || record.chain_id || '',
chainName: record.chainName || record.chain_name || '',
merchantId: record.merchantId || record.merchant_id || '',
merchantName: record.merchantName || record.merchant_name || '',
date: record.date || '',
province: record.province || '',
city: record.city || '',
summary: this.parseStoredJson(record.summary, record.summary || ''),
rating: Number(record.rating) || 5
})
if (typeof this.form.summary !== 'string') {
this.form.summary = ''
}
this.clockPhotos = this.normalizeResubmitClockPhotos(record.clockPhotos)
this.boothPhotos = this.normalizeResubmitPhotos(record.boothPhotos)
this.receiptPhotos = this.normalizeResubmitPhotos(record.receiptPhoto || record.receiptPhotos)
this.salesItems = this.normalizeResubmitSalesItems(record.salesGroups)
this.currentAddress = record.location || ''
this.lat = record.lat === '' || record.lat === null || record.lat === undefined ? null : Number(record.lat)
this.lng = record.lng === '' || record.lng === null || record.lng === undefined ? null : Number(record.lng)
this.currentProvince = record.province || ''
this.currentCity = record.city || ''
// 原记录没有定位时用当前位置兜底,否则校验会卡在“请先完成定位”且用户无从修改
if (!this.currentAddress || !this.lat || !this.lng) {
await this.fetchCurrentLocation()
}
} catch (error) {
await this.toast(error && error.message ? error.message : '填报记录加载失败')
} finally {
this.resubmitLoading = false
}
},
cloneValue(value) {
if (value === undefined || value === null) {
return value
}
return JSON.parse(JSON.stringify(value))
},
normalizeClockPhotoItem(item, fallbackKey, fallbackLabel) {
if (!item || typeof item !== 'object') {
return {
key: fallbackKey,
label: fallbackLabel,
time: '',
date: '',
url: '',
lat: '',
lng: '',
location: ''
}
}
return {
key: item.key || fallbackKey,
label: item.label || fallbackLabel,
time: item.time || '',
date: item.date || '',
url: item.url || '',
lat: item.lat || '',
lng: item.lng || '',
location: item.location || ''
}
},
normalizeDraftClockPhotos(list) {
const source = Array.isArray(list) ? list : []
const inClock = this.normalizeClockPhotoItem(source[0], 'in', '上班')
const outClock = this.normalizeClockPhotoItem(source[1], 'out', '下班')
return [inClock, outClock]
},
hasDraftContent() {
const clockPhotos = Array.isArray(this.clockPhotos) ? this.clockPhotos : []
const hasClock = clockPhotos.some(item => item && item.url)
const hasBooth = Array.isArray(this.boothPhotos) && this.boothPhotos.length > 0
const hasReceipt = Array.isArray(this.receiptPhotos) && this.receiptPhotos.length > 0
const hasSales = Array.isArray(this.salesItems) && this.salesItems.length > 0
const hasSummary = String(this.form && this.form.summary ? this.form.summary : '').trim().length > 0
const hasMerchant = !!(this.form && (this.form.chainId || this.form.merchantId))
const hasActivityDate = !!(this.form && this.form.date)
const hasRating = Number(this.form && this.form.rating) !== 5
return hasClock || hasBooth || hasReceipt || hasSales || hasSummary || hasMerchant || hasActivityDate || hasRating
},
buildDraftPayload() {
return {
version: 1,
savedAt: Date.now(),
form: this.cloneValue(this.form),
clockPhotos: this.cloneValue(this.clockPhotos),
boothPhotos: this.cloneValue(this.boothPhotos),
receiptPhotos: this.cloneValue(this.receiptPhotos),
salesItems: this.cloneValue(this.salesItems),
currentAddress: this.currentAddress,
lat: this.lat,
lng: this.lng,
currentProvince: this.currentProvince,
currentCity: this.currentCity
}
},
async saveDraft() {
if (!this.hasDraftContent()) {
uni.removeStorageSync(this.getDraftStorageKey())
uni.showToast({
title: '请先填写内容',
icon: 'none'
})
return
}
uni.setStorageSync(this.getDraftStorageKey(), this.buildDraftPayload())
uni.showToast({
title: '已保存',
icon: 'success'
})
},
clearDraft() {
uni.removeStorageSync(this.getDraftStorageKey())
},
async checkDraftOnEntry() {
if (this.draftPrompting) return
const draft = uni.getStorageSync(this.getDraftStorageKey())
if (!draft || typeof draft !== 'object') return
const hasDraftContent = this.hasDraftPayloadContent(draft)
if (!hasDraftContent) {
this.clearDraft()
return
}
this.draftPrompting = true
try {
const confirm = await new Promise(resolve => {
uni.showModal({
title: '提示',
content: '您有保存数据,是否立即加载',
confirmText: '是',
cancelText: '否',
success: res => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
if (confirm) {
await this.restoreDraft(draft)
} else {
this.clearDraft()
}
} finally {
this.draftPrompting = false
}
},
hasDraftPayloadContent(draft) {
if (!draft || typeof draft !== 'object') {
return false
}
const form = draft.form || {}
const clockPhotos = Array.isArray(draft.clockPhotos) ? draft.clockPhotos : []
const boothPhotos = Array.isArray(draft.boothPhotos) ? draft.boothPhotos : []
const receiptPhotos = Array.isArray(draft.receiptPhotos) ? draft.receiptPhotos : []
const salesItems = Array.isArray(draft.salesItems) ? draft.salesItems : []
return !!(
String(form.chainId || '').trim() ||
String(form.merchantId || '').trim() ||
String(form.date || form.activityTime || '').trim() ||
clockPhotos.some(item => item && item.url) ||
boothPhotos.length ||
receiptPhotos.length ||
salesItems.length ||
String(form.summary || '').trim() ||
Number(form.rating) !== 5
)
},
async restoreDraft(draft) {
const nextForm = Object.assign(this.buildForm(), this.cloneValue(draft.form || {}))
if (!nextForm.date && nextForm.activityTime) {
nextForm.date = nextForm.activityTime
}
delete nextForm.activityTime
if (!nextForm.chainId && nextForm.chainName) {
const matchedChain = this.merchantChainList.find(item => String(item.text) === String(nextForm.chainName))
if (matchedChain) nextForm.chainId = matchedChain.id
}
this.form = nextForm
this.clockPhotos = this.normalizeDraftClockPhotos(draft.clockPhotos)
this.boothPhotos = Array.isArray(draft.boothPhotos) ? [...draft.boothPhotos] : []
this.receiptPhotos = Array.isArray(draft.receiptPhotos) ? [...draft.receiptPhotos] : []
this.salesItems = Array.isArray(draft.salesItems)
? this.cloneValue(draft.salesItems).map(item => Object.assign({}, item))
: []
this.currentAddress = draft.currentAddress || ''
this.lat = draft.lat
this.lng = draft.lng
this.currentProvince = draft.currentProvince || ''
this.currentCity = draft.currentCity || ''
this.showChainPicker = false
this.showMerchantPicker = false
},
async fetchCurrentLocation() {
const location = await getLocationOnly().catch(() => null)
if (!location) return
this.syncLocation(location)
},
syncLocation(location) {
this.lat = Number(location.latitude)
this.lng = Number(location.longitude)
this.currentProvince = location.province_str || ''
this.currentCity = location.city_str || ''
this.currentAddress = location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join('')
},
// 连锁/门店解析统一走 util/store-picker:跑店填报(visit.vue)用同一份逻辑,
// 两页各自维护一份迟早会出现解析口径不一致。
normalizeMerchant(item) {
return sharedNormalizeMerchant(item)
},
pickFirstText(source, keys = []) {
return sharedPickFirstText(source, keys)
},
unwrapList(data) {
return sharedUnwrapList(data)
},
normalizeMerchantChain(item, index) {
return sharedNormalizeMerchantChain(item, index)
},
groupMerchantListByChain(list) {
return sharedGroupMerchantListByChain(list)
},
flattenMerchantsFromChains(chainList) {
return sharedFlattenMerchantsFromChains(chainList)
},
// 本页的门店搜索关键词存在 data 里(merchantStoreSearchKeyword),
// 与 visit.vue 由调用方传入关键词的签名不同,这里保留本页签名。
filterMerchantStores(merchants) {
return sharedFilterMerchantStores(merchants, this.merchantStoreSearchKeyword)
},
normalizeChainIdForApi(rawChainId) {
const text = String(rawChainId || '').trim()
if (!text || text.startsWith('chain_')) return ''
return text
},
applyMerchant(chain, merchant) {
if (!merchant) return
this.form.chainName = (chain && chain.text) || merchant.chainName || ''
this.form.chainId = (chain && chain.id) ?? merchant.chainId ?? ''
this.form.merchantName = merchant.text || ''
this.form.merchantId = merchant.id ?? ''
this.form.province = merchant.province || this.currentProvince || ''
this.form.city = merchant.city || this.currentCity || ''
this.form.lat = Number.isFinite(merchant.lat) ? merchant.lat : ''
this.form.lng = Number.isFinite(merchant.lng) ? merchant.lng : ''
},
async getMerchantList() {
this.merchantLoading = true
try {
const {data} = await merchantGroupByChainReq({})
const list = this.unwrapList(data)
const groupedList = this.groupMerchantListByChain(list)
const chains = (groupedList.length
? groupedList
: list.map((item, index) => this.normalizeMerchantChain(item, index)))
.filter(item => Array.isArray(item.merchants) && item.merchants.length > 0)
this.setMerchantChains(chains)
} catch (error) {
this.setMerchantChains([])
uni.showToast({
title: '门店列表获取失败',
icon: 'none'
})
} finally {
this.merchantLoading = false
}
},
// 门店明细(全量可达 8000+ 条)只放在实例上,不进页面 data。
// 一旦放进 data,页面 data 树会膨胀到 1MB+,此后每次 setData 都要重新
// diff/序列化整棵树,连只含一个字段的 setData 也会从 ~490ms 涨到 ~1000ms。
setMerchantChains(chains) {
const list = Array.isArray(chains) ? chains : []
this.merchantStore = list.map(chain => ({
id: chain.id,
text: chain.text,
merchants: Array.isArray(chain.merchants) ? chain.merchants : []
}))
this.merchantChainList = this.merchantStore.map(chain => ({
id: chain.id,
text: chain.text,
merchantCount: chain.merchants.length
}))
},
getMerchantsByChainId(chainId) {
const store = Array.isArray(this.merchantStore) ? this.merchantStore : []
const chain = store.find(item => String(item.id) === String(chainId))
return chain && Array.isArray(chain.merchants) ? chain.merchants : []
},
openChainPicker() {
if (!this.merchantChainList.length) return
const chainIndex = this.merchantChainOptions.findIndex(item => String(item.id) === String(this.form.chainId))
this.chainPickerValue = [chainIndex > -1 ? chainIndex : 0]
this.showChainPicker = true
},
closeChainPicker() {
this.showChainPicker = false
},
onChainPickerChange(e) {
let chainIndex = Array.isArray(e.detail.value) ? Number(e.detail.value[0] || 0) : 0
if (!Number.isFinite(chainIndex) || chainIndex < 0 || chainIndex > this.merchantChainOptions.length - 1) {
chainIndex = 0
}
this.chainPickerValue = [chainIndex]
},
async confirmChainPicker() {
const chain = this.merchantChainOptions[this.chainPickerValue[0]] || null
if (!chain || !chain.id) {
uni.showToast({title: '请选择连锁', icon: 'none'})
return
}
const changed = String(this.form.chainId || '') !== String(chain.id)
this.form.chainId = chain.id
this.form.chainName = chain.text || ''
if (changed) {
this.form.merchantId = ''
this.form.merchantName = ''
this.form.province = ''
this.form.city = ''
this.form.lat = ''
this.form.lng = ''
this.merchantStoreSearchKeyword = ''
this.merchantPickerValue = [0]
// 商品库按连锁过滤,换连锁后原销量明细已不适用。
// 草稿不清:按规则只有「提交成功」与「恢复询问时放弃」才丢弃草稿。
this.salesItems = []
clearSalesHandoff()
}
this.closeChainPicker()
},
openMerchantPicker() {
if (!this.form.chainId) {
uni.showToast({title: '请先选择连锁', icon: 'none'})
return
}
if (!this.merchantChainList.length) return
this.merchantStoreSearchKeyword = ''
const merchants = this.merchantStoreOptions
const merchantIndex = merchants.findIndex(item => String(item.id) === String(this.form.merchantId))
this.merchantPickerValue = [merchantIndex > -1 ? merchantIndex : 0]
this.showMerchantPicker = true
},
closeMerchantPicker() {
this.showMerchantPicker = false
},
openActivityTimePicker() {
this.showActivityTimePicker = true
},
closeActivityTimePicker() {
this.showActivityTimePicker = false
},
onActivityTimeConfirm(e) {
this.form.date = dayjs(e.value).format('YYYY-MM-DD')
this.closeActivityTimePicker()
},
onMerchantPickerChange(e) {
let merchantIndex = Array.isArray(e.detail.value) ? Number(e.detail.value[0] || 0) : 0
if (!Number.isFinite(merchantIndex) || merchantIndex < 0) merchantIndex = 0
const merchants = this.merchantStoreOptions
if (!merchants.length || !merchants[0].id) {
merchantIndex = 0
} else if (merchantIndex > merchants.length - 1) {
merchantIndex = 0
}
this.merchantPickerValue = [merchantIndex]
},
async confirmMerchantPicker() {
const chain = this.merchantChainOptions.find(item => String(item.id) === String(this.form.chainId)) || null
const selected = this.merchantStoreOptions[this.merchantPickerValue[0]] || null
if (!selected || !selected.id) {
uni.showToast({
title: '请选择门店',
icon: 'none'
})
return
}
const prevChainName = String(this.form.chainName || '').trim()
this.applyMerchant(chain, selected)
const nextChainName = String(this.form.chainName || '').trim()
this.closeMerchantPicker()
if (nextChainName && nextChainName !== prevChainName) {
// 换连锁后商品库与销量明细不再适用。
// 草稿不清:按规则只有「提交成功」与「恢复询问时放弃」才丢弃草稿。
this.salesItems = []
clearSalesHandoff()
}
},
async ensureLogin() {
if (this.isLoggedIn) return true
this.$refs.Login.open()
return false
},
async handleClockAction(index) {
const canContinue = await this.ensureLogin()
if (!canContinue) return
await this.uploadClock(index)
},
formatTime(date) {
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${hour}:${minute}`
},
formatDate(date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
},
makeTextBase64(text) {
const base64 = Base64.encode(text)
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
},
buildWatermarkImageUrl(path, address, date, time) {
let url = `${path}?x-oss-process=image/watermark,text_${this.makeTextBase64(address)},g_sw,color_FFFFFF,x_10,y_120`
url += `/watermark,text_${this.makeTextBase64(date)},g_sw,color_FFFFFF,x_10,y_60`
url += `/watermark,text_${this.makeTextBase64(time)},g_sw,color_FFFFFF,x_10,y_10`
return url
},
async uploadClock(index) {
uni.showLoading({title: '上传中'})
try {
const res = await chooseImg(1, ['camera']).catch(() => [])
const next = res[0] && res[0].path ? res[0].path : ''
if (!next) return
const location = await getLocationOnly()
if (!location) return
this.syncLocation(location)
const now = new Date()
const time = this.formatTime(now)
const date = this.formatDate(now)
const address = `${location.province_str}${location.city_str}${location.area_str}`
const url = this.buildWatermarkImageUrl(next, address, date, time)
this.$set(this.clockPhotos, index, Object.assign({}, this.clockPhotos[index], {
url,
time,
date,
lat: location.latitude,
lng: location.longitude,
location: location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join('')
}))
} finally {
uni.hideLoading()
}
},
previewImages(urls = [], current = '') {
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
if (!list.length) return
uni.previewImage({
urls: list,
current: current || list[0]
})
},
async uploadBooth() {
const canContinue = await this.ensureLogin()
if (!canContinue) return
const remain = this.boothLimit - this.boothPhotos.length
if (remain <= 0) return
const res = await chooseImg(remain)
this.boothPhotos.push(...res.map(item => item.path).filter(Boolean))
},
removeBooth(index) {
this.boothPhotos.splice(index, 1)
},
async uploadReceipt() {
const canContinue = await this.ensureLogin()
if (!canContinue) return
const remain = this.receiptLimit - this.receiptPhotos.length
if (remain <= 0) return
const res = await chooseImg(remain)
this.receiptPhotos.push(...res.map(item => item.path).filter(Boolean))
},
removeReceipt(index) {
this.receiptPhotos.splice(index, 1)
},
toast(message, title = '提示') {
return new Promise(resolve => {
uni.showModal({
title,
content: String(message || ''),
showCancel: false,
success: resolve,
fail: resolve
})
})
},
fillEmptySalesQtyWithZero() {
const items = Array.isArray(this.salesItems) ? this.salesItems : []
items.forEach(item => {
const qty = item.qty
const emptyQty = qty === '' || qty === null || qty === undefined || (typeof qty === 'string' && qty.trim() === '')
if (emptyQty) item.qty = 0
})
},
hasDisplayValue(value) {
return value !== undefined && value !== null && String(value).trim() !== ''
},
resolveTaskIncome(item) {
if (!item || typeof item !== 'object') return ''
return this.hasDisplayValue(item.taskIncome) ? item.taskIncome : ''
},
parseSalesQty(value) {
const num = Number(value)
return Number.isFinite(num) ? num : 0
},
getTaskIncomeNumber(item) {
const num = Number(this.resolveTaskIncome(item))
return Number.isFinite(num) ? num : 0
},
formatMoney(value) {
const num = Number(value)
if (!Number.isFinite(num)) return '0.00'
return num.toFixed(2)
},
formatSalesQty(value) {
const empty = value === '' || value === null || value === undefined
if (empty) return '0'
const num = Number(value)
return Number.isFinite(num) ? String(num) : '0'
},
buildSubmitSalesGroups() {
const groupsMap = {}
;(Array.isArray(this.salesItems) ? this.salesItems : []).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.resolveTaskIncome(item),
qty: item.qty,
unit: item.unit || '盒',
specOptions: Array.isArray(item.specOptions) ? item.specOptions : []
})
})
return Object.keys(groupsMap).map(key => groupsMap[key])
},
validateSubmit() {
if (!this.form.chainId) {
this.toast('请选择连锁')
return false
}
if (!this.form.merchantId) {
this.toast('请选择门店')
return false
}
if (!this.form.date) {
this.toast('请选择活动日期')
return false
}
if (!this.currentAddress || !this.lat || !this.lng) {
this.toast('请先完成定位')
return false
}
if (!this.clockPhotos[0] || !this.clockPhotos[0].url) {
this.toast('请完成上班打卡')
return false
}
if (!this.clockPhotos[1] || !this.clockPhotos[1].url) {
this.toast('请完成下班打卡')
return false
}
if (!this.receiptPhotos.length) {
this.toast('请上传小票原图')
return false
}
const items = Array.isArray(this.salesItems) ? this.salesItems : []
if (!items.length) {
this.toast('请至少添加一项销量商品')
return false
}
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
const qty = item.qty
const emptyQty = qty === '' || qty === null || qty === undefined || (typeof qty === 'string' && qty.trim() === '')
if (emptyQty) {
this.toast(`请填写${item.name || '商品'}销量`)
return false
}
if (Number(qty) < 0) {
this.toast(`${item.name || '商品'}销量不能小于0`)
return false
}
}
const summaryText = (this.form.summary || '').trim()
if (!summaryText) {
this.toast('请填写活动小结')
return false
}
return true
},
buildPayload() {
const summaryText = (this.form.summary || '').trim()
const imageList = [
...this.clockPhotos.map(item => item && item.url).filter(Boolean),
...this.boothPhotos,
...this.receiptPhotos
]
return {
merchantId: this.form.merchantId,
merchantName: this.form.merchantName,
chainId: this.form.chainId,
chainName: this.form.chainName,
date: this.form.date,
province: this.form.province || this.currentProvince || '',
city: this.form.city || this.currentCity || '',
lat: this.lat,
lng: this.lng,
location: this.currentAddress,
image: imageList.length ? JSON.stringify(imageList) : null,
clockPhotos: JSON.stringify(this.clockPhotos || []),
boothPhotos: JSON.stringify(this.boothPhotos || []),
salesGroups: JSON.stringify(this.buildSubmitSalesGroups()),
receiptPhoto: JSON.stringify(this.receiptPhotos || []),
summary: JSON.stringify(summaryText),
reward: this.totalRewardAmount,
rating: this.form.rating,
status: 0
}
},
async submit() {
const canContinue = await this.ensureLogin()
if (!canContinue || this.submitLoading) return
this.fillEmptySalesQtyWithZero()
if (!this.validateSubmit()) return
this.submitLoading = true
try {
if (this.isResubmit) {
const payload = this.buildPayload()
// 状态与驳回原因由服务端决定,这里不传,避免被当成审核端改状态
delete payload.status
payload.id = this.resubmitId
await selfReportResubmitReq(payload)
clearSalesHandoff()
await this.toast('重新提交成功,请等待审核')
} else {
await selfReportAddReq(this.buildPayload())
this.clearDraft()
// 整页已提交,交接数据不该留到下次
clearSalesHandoff()
await this.toast('提交成功,请等待审核')
}
uni.navigateBack({
fail: () => {
uni.switchTab({url: '/pages/mine/index'})
}
})
} catch (error) {
await this.toast(error && error.message ? error.message : '提交失败')
} finally {
this.submitLoading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.self-report-page {
--form-actions-height: calc(118rpx + env(safe-area-inset-bottom));
min-height: 100vh;
background: #f5f5f5;
}
.sr-merchant-row {
margin: 20rpx 20rpx 0;
padding: 16rpx 18rpx;
border: 3px solid #ff8a4c;
border-radius: 16rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
}
.sr-merchant-label {
color: grey;
font-size: 26rpx;
min-width: 140rpx;
}
.sr-merchant-right {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
min-height: 64rpx;
}
.sr-merchant-text {
display: block;
color: #111827;
font-size: 26rpx;
font-weight: bold;
line-height: 1.35;
text-align: right;
white-space: normal;
word-break: break-all;
max-width: 430rpx;
}
.sr-merchant-placeholder {
color: #9ca3af;
font-size: 26rpx;
}
.store-card {
margin: 24rpx 20rpx 0;
padding: 28rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #2fc6a7, #1db896);
color: #ffffff;
box-shadow: 0 18rpx 36rpx rgba(47, 198, 167, 0.25);
}
.store-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 24rpx;
}
.store-title {
font-size: 36rpx;
font-weight: 700;
}
.store-desc {
margin-top: 10rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
.store-tag {
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: rgba(255, 255, 255, 0.16);
}
.store-field {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
padding: 18rpx 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.16);
}
.store-label {
width: 140rpx;
color: rgba(255, 255, 255, 0.72);
font-size: 26rpx;
}
.store-value {
flex: 1;
text-align: right;
font-size: 28rpx;
font-weight: 600;
}
.store-value.placeholder {
color: rgba(255, 255, 255, 0.65);
}
.store-value.address {
font-size: 24rpx;
line-height: 1.5;
}
.block {
margin: 20rpx;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.section-extra {
color: #7a8495;
font-size: 24rpx;
}
.section-title.tight {
margin-bottom: 0;
}
.reject-reason-text {
margin-top: 14rpx;
padding: 18rpx 20rpx;
border-radius: 16rpx;
background: #fef2f2;
border: 1rpx solid #fecaca;
color: #dc2626;
font-size: 26rpx;
line-height: 1.6;
word-break: break-word;
}
.section-sub {
margin-left: 8rpx;
font-size: 24rpx;
color: #7a8495;
}
.clock-grid {
display: flex;
gap: 18rpx;
}
.clock-card {
flex: 1;
}
.clock-card.primary .clock-thumb {
border-color: #4c7bff;
}
.clock-card.warm .clock-thumb {
border-color: #f3a45c;
}
.clock-thumb {
position: relative;
height: 220rpx;
border-radius: 20rpx;
overflow: hidden;
background: linear-gradient(180deg, #1f2c3c, #151e29);
border: 2rpx solid #2b3950;
}
.clock-image {
width: 100%;
height: 100%;
}
.clock-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.25);
}
.clock-check {
position: absolute;
top: 10rpx;
right: 10rpx;
background: #ffffff;
border-radius: 50%;
padding: 2rpx;
}
.clock-info {
position: absolute;
left: 16rpx;
bottom: 16rpx;
color: #ffffff;
}
.clock-time {
font-size: 34rpx;
font-weight: 600;
}
.clock-date {
font-size: 22rpx;
opacity: 0.9;
margin-top: 4rpx;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.photo-item {
width: calc((100% - 32rpx) / 3);
height: 200rpx;
position: relative;
border-radius: 18rpx;
overflow: hidden;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
}
.photo-item.add {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #fafafa, #f3f4f6);
}
.photo-image {
width: 100%;
height: 100%;
}
.photo-remove {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: rgba(15, 23, 42, 0.65);
color: #ffffff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
}
.sales-entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 26rpx 24rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #fff6ef, #fffaf6);
border: 2rpx solid #ffd9c2;
}
.sales-entry-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 18rpx;
}
.sales-entry-icon {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 20rpx;
background: #ffffff;
border: 1rpx solid #ffd9c2;
display: flex;
align-items: center;
justify-content: center;
}
.sales-entry-text {
flex: 1;
min-width: 0;
}
.sales-entry-title {
font-size: 29rpx;
font-weight: 700;
color: #0f172a;
}
.sales-entry-sub {
margin-top: 6rpx;
font-size: 23rpx;
color: #9aa3b2;
}
.sales-entry-right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10rpx;
}
.sales-entry-count {
min-width: 40rpx;
height: 40rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8a4c, #ff6b4a);
color: #ffffff;
font-size: 22rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.sales-summary-list {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.sales-summary-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 16rpx 20rpx;
border-radius: 14rpx;
background: #f8fafc;
}
.sales-summary-name {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #374151;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sales-summary-qty {
flex-shrink: 0;
font-size: 26rpx;
font-weight: 700;
color: #ff7a35;
}
.sales-summary-more {
padding-top: 4rpx;
text-align: center;
font-size: 23rpx;
color: #9aa3b2;
}
.summary-input {
min-height: 180rpx;
width: 100%;
padding: 20rpx;
box-sizing: border-box;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid #dbe3ef;
color: #0f172a;
font-size: 28rpx;
line-height: 1.6;
}
.rating-row {
display: flex;
justify-content: center;
padding-top: 6rpx;
}
.form-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
height: var(--form-actions-height);
padding: 20rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
display: flex;
gap: 16rpx;
background: #ffffff;
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.08);
}
.form-actions-placeholder {
height: var(--form-actions-height);
}
.draft-btn {
flex: 1;
min-height: 78rpx;
border-radius: 999rpx;
border: 1rpx solid #ff8a4c;
background: #fff7f2;
color: #ff7a35;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
}
.submit {
flex: 1;
margin: 0;
min-height: 78rpx;
padding: 0 24rpx;
font-size: 28rpx;
font-weight: 700;
}
.merchant-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 20rpx;
}
.picker-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
}
.picker-action {
font-size: 28rpx;
color: #7a8495;
}
.picker-action.primary {
color: #2563eb;
font-weight: 700;
}
.merchant-picker-search {
padding: 0 24rpx 20rpx;
}
.merchant-picker-search-row {
display: flex;
gap: 14rpx;
}
.merchant-search-item {
flex: 1;
min-width: 0;
}
.merchant-picker-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
}
.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;
}
.audit-empty-tip {
padding: 30rpx 0;
text-align: center;
color: #94a3b8;
font-size: 26rpx;
}
.reward-summary-card {
display: flex;
align-items: center;
justify-content: space-between;
}
.reward-summary-label {
font-size: 28rpx;
font-weight: 600;
color: #0f172a;
}
.reward-summary-value {
font-size: 32rpx;
font-weight: 700;
color: #dc2626;
}
</style>