Files
weicw-dshsa 2ff8d42207 fix(自主填报): 修正重提跳转按填报类型、补 wx:key 并清理废弃 API 调用
一、填报记录「重新提交」跳错表单(用户反馈)

records.vue 原先按 activeType(当前 tab)决定跳驻店还是跑店表单,
但列表请求无守卫:切 tab 会连发请求且响应顺序不保证,
旧响应回来会盖掉当前 tab 的数据,导致 tab 与实际数据错位。
此时点「重新提交」会拿 A 表的 id 去打开 B 表的表单
(两表 id 各自从 1 开始,撞号常见,故不报错、只静默进错页面)。

改法:把填报类型钉在记录上,而不是靠 tab 状态猜
- fetchList 拉到数据后给每条记录打 reportType
- 新增 recordType(item):优先取记录自带类型,异常数据回退当前 tab
- 归一化、筛选、卡片标题/副标题、展开详情形态、重新提交跳转全部改走它
- 列表请求加自增 listRequestId 守卫,丢弃过期响应(loading 同理)

二、visit.vue 缺 wx:key 告警

小程序 wx:key 只接受简单属性路径,模板字面量写法
(:key="`store-${idx}`")会被编译器静默丢弃,四处循环因此无 key。
按项目既有 _rowKey 模式改:
- 巡店列表 :key="item._rowKey",新增 buildStoreKey/ensureStoreKeys
  (重提与草稿的旧数据无该字段,加载时补齐;提交走显式白名单,不外泄)
- 照片 :key="photo",两个 picker :key="item.id"

三、getSystemInfoSync 废弃告警

该调用在 AppNavbar 的 render(getSafeTop)里,每渲染一次告警一次。
改为挂载时取一次并缓存,优先用新接口 uni.getWindowInfo,
取不到再回退旧接口;select.vue 同源调用一并处理。

注:uview 组件库内部 u-overlay/u-popup 的 mode 传 null 告警
(u-popup 的 position computed 无 else 分支)未改,
uni_modules 已被 gitignore 且会被依赖重装覆盖,属组件库问题。
2026-10-12 13:47:03 +08:00

1234 lines
39 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 class="visit-report-page">
<AppNavbar :title="isResubmit ? '重新提交填报' : '跑店填报'" show-back/>
<view class="card block">
<view class="section-title-row">
<view class="section-title">巡店列表</view>
<view class="section-extra">{{ storeList.length }} 家门店</view>
</view>
<view v-if="!storeList.length" class="audit-empty-tip">暂无巡店记录,请点击下方「新增巡店」</view>
<view v-for="(item, idx) in storeList" :key="item._rowKey" class="visit-store-card">
<view class="visit-store-head">
<view>
<view class="visit-store-title">巡店{{ idx + 1 }}</view>
<view v-if="item.visitTime" class="visit-store-time">{{ item.visitTime }}</view>
</view>
<view v-if="storeList.length > 1" class="visit-store-remove" @tap="removeStoreItem(idx)">删除</view>
</view>
<!-- 连锁与门店拆成两个独立输入框,各自弹各自的选择器(与驻店填报一致) -->
<view class="sr-merchant-row" @tap="openChainPicker(idx)">
<view class="sr-merchant-label">连锁筛选</view>
<view class="sr-merchant-right">
<text :class="item.chainId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ chainText(item) }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="sr-merchant-row" @tap="openMerchantPicker(idx)">
<view class="sr-merchant-label">门店筛选</view>
<view class="sr-merchant-right">
<text :class="item.merchantId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ merchantText(item) }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="visit-store-field">
<view class="visit-store-label-row">
<view class="visit-store-label">定位</view>
<view class="visit-store-action" @tap="fetchStoreLocation(idx)">获取定位</view>
</view>
<view class="location-card">
<view class="location-row">
<text class="location-label">纬度:</text>
<text class="location-value">{{ formatCoordinate(item.lat) }}</text>
</view>
<view class="location-row">
<text class="location-label">经度:</text>
<text class="location-value">{{ formatCoordinate(item.lng) }}</text>
</view>
<view class="location-row">
<text class="location-label">详细地址:</text>
<text class="location-value">{{ item.location || '暂无地址(可不填)' }}</text>
</view>
</view>
</view>
<view class="visit-store-field">
<view class="section-title-row">
<view class="visit-store-label">打卡拍照</view>
<view class="section-extra">最多{{ storePhotoLimit }}张</view>
</view>
<view v-if="!item.photos || item.photos.length === 0" class="photo-grid">
<view class="photo-item add" @tap="uploadStorePhotos(idx)">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
</view>
<view v-else class="photo-grid">
<view v-for="(photo, photoIdx) in item.photos" :key="photo" class="photo-item">
<image :src="photo" mode="aspectFill" class="photo-image" @tap="previewStorePhotos(idx, photoIdx)"/>
<view class="photo-remove" @tap="removeStorePhoto(idx, photoIdx)">x</view>
</view>
<view v-if="item.photos.length < storePhotoLimit" class="photo-item add" @tap="uploadStorePhotos(idx)">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
</view>
</view>
</view>
<view class="visit-store-add" @tap="addStoreItem">新增巡店</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" 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>
<Login ref="Login"/>
</view>
</template>
<script>
import {Base64} from "js-base64";
import Login from "@/components/Login.vue";
import AppNavbar from "@/components/AppNavbar.vue";
import {merchantGroupByChainReq} from "@/api/shop";
import {
visitReportAddReq,
visitReportInfoReq,
visitReportResubmitReq
} from "@/api/visit-report";
import {getUserInfo} from "@/util/user";
import {chooseImg, getLocationOnly} from "@/util";
import {
unwrapList,
normalizeMerchantChain,
groupMerchantListByChain
} from "@/util/store-picker";
export default {
components: {Login, AppNavbar},
data() {
return {
isLoggedIn: false,
hasInitialized: false,
merchantLoading: false,
// 当前正在选连锁/门店的巡店下标(两个选择器各自独立打开,互不串联)
activePickerIndex: -1,
showChainPicker: false,
showMerchantPicker: false,
merchantStoreSearchKeyword: '',
chainPickerValue: [0],
merchantPickerValue: [0],
merchantChainList: [],
form: this.buildForm(),
storeList: [],
storePhotoLimit: 9,
draftPrompting: false,
submitLoading: false,
// 驳回后重新提交:记录 id 与驳回原因,提交时走 resubmit 接口
isResubmit: false,
resubmitId: '',
rejectReason: '',
resubmitLoading: false
}
},
computed: {
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 chainId = this.activePickerChainId()
const hasChain = chainList.some(chain => String(chain.id) === String(chainId))
const merchants = hasChain ? this.getMerchantsByChainId(chainId) : []
const keyword = (this.merchantStoreSearchKeyword || '').trim()
const filtered = this.filterMerchantStores(merchants, keyword)
return filtered.length ? filtered : [{id: '', text: keyword ? '无匹配门店' : '无门店'}]
},
merchantIndicatorStyle() {
return 'height: 64px;'
}
},
watch: {
merchantStoreSearchKeyword() {
this.merchantPickerValue = [0]
}
},
created() {
// 门店明细挂实例上而非 data:data 里的一切都会进页面 data 树并被 setData 序列化。
this.merchantStore = []
// 巡店项 key 的自增种子,同样放实例上,不必进 data 树
this.storeKeySeed = 0
},
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()
}
}
},
methods: {
buildForm() {
return {
summary: ''
}
},
async init() {
this.form = this.buildForm()
this.storeList = []
this.showChainPicker = false
this.showMerchantPicker = false
this.activePickerIndex = -1
await this.getMerchantList()
},
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 || ''
},
cloneValue(value) {
if (value === undefined || value === null) {
return value
}
return JSON.parse(JSON.stringify(value))
},
formatCoordinate(value) {
const text = value === null || value === undefined ? '' : String(value).trim()
return text || '暂无'
},
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}`
},
formatDateTime(date) {
return `${this.formatDate(date)} ${this.formatTime(date)}`
},
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 ensureLogin() {
if (this.isLoggedIn) return true
this.$refs.Login.open()
return false
},
previewImages(urls = [], current = '') {
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
if (!list.length) return
uni.previewImage({
urls: list,
current: current || list[0]
})
},
toast(message, title = '提示') {
return new Promise(resolve => {
uni.showModal({
title,
content: String(message || ''),
showCancel: false,
success: resolve,
fail: resolve
})
})
},
// ---------- 连锁 / 门店两级选择(两个输入框各自独立弹窗)----------
normalizeMerchantChain,
unwrapList,
async getMerchantList() {
this.merchantLoading = true
try {
const {data} = await merchantGroupByChainReq({})
const list = this.unwrapList(data)
const groupedList = groupMerchantListByChain(list)
const chains = (groupedList.length
? groupedList
: list.map((item, index) => 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 : []
},
filterMerchantStores(merchants, keyword) {
const list = Array.isArray(merchants) ? merchants : []
const text = String(keyword || '').trim()
if (!text) return list
return list.filter(merchant => (merchant.text || '').includes(text))
},
activePickerChainId() {
const item = this.storeList[this.activePickerIndex]
return item ? item.chainId : ''
},
chainText(item) {
if (!item || !item.chainId) return this.merchantLoading ? '连锁加载中...' : '请选择连锁'
return item.chainName || '请选择连锁'
},
merchantText(item) {
if (!item || !item.chainId) return '请先选择连锁'
if (!item.merchantId) return '请选择门店'
return item.merchantName || '请选择门店'
},
async ensureMerchantListLoaded() {
// 门店列表是异步拉的,必须先等它到位再开弹层,否则连锁列只会显示「暂无连锁」
if (!this.merchantChainList.length && !this.merchantLoading) {
uni.showLoading({title: '加载门店列表中...'})
try {
await this.getMerchantList()
} finally {
uni.hideLoading()
}
}
},
async openChainPicker(index) {
await this.ensureMerchantListLoaded()
this.activePickerIndex = index
const item = this.storeList[index] || {}
const chainIndex = this.merchantChainOptions.findIndex(chain => String(chain.id) === String(item.chainId || ''))
this.chainPickerValue = [chainIndex > -1 ? chainIndex : 0]
this.showChainPicker = true
},
closeChainPicker() {
this.showChainPicker = false
this.activePickerIndex = -1
},
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]
},
confirmChainPicker() {
const chain = this.merchantChainOptions[this.chainPickerValue[0]] || null
if (!chain || !chain.id) {
uni.showToast({title: '请选择连锁', icon: 'none'})
return
}
const index = this.activePickerIndex
const current = this.storeList[index]
if (!current) {
this.closeChainPicker()
return
}
const changed = String(current.chainId || '') !== String(chain.id)
const next = Object.assign({}, current, {
chainId: chain.id,
chainName: chain.text || ''
})
if (changed) {
// 换连锁后原门店已不属于该连锁,清掉让用户重新选
Object.assign(next, {
merchantId: '',
merchantName: '',
province: '',
city: '',
merchantLat: '',
merchantLng: '',
merchantAddress: '',
_autoSelected: false
})
}
this.$set(this.storeList, index, next)
this.closeChainPicker()
},
async openMerchantPicker(index) {
const item = this.storeList[index]
if (!item || !item.chainId) {
uni.showToast({title: '请先选择连锁', icon: 'none'})
return
}
await this.ensureMerchantListLoaded()
this.activePickerIndex = index
this.merchantStoreSearchKeyword = ''
const merchantIndex = this.merchantStoreOptions.findIndex(option => String(option.id) === String(item.merchantId))
this.merchantPickerValue = [merchantIndex > -1 ? merchantIndex : 0]
this.showMerchantPicker = true
},
closeMerchantPicker() {
this.showMerchantPicker = false
this.activePickerIndex = -1
},
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]
},
confirmMerchantPicker() {
const index = this.activePickerIndex
const current = this.storeList[index]
if (!current) {
this.closeMerchantPicker()
return
}
const selected = this.merchantStoreOptions[this.merchantPickerValue[0]] || null
if (!selected || !selected.id) {
uni.showToast({title: '请选择门店', icon: 'none'})
return
}
this.$set(this.storeList, index, Object.assign({}, current, {
merchantId: selected.id ?? '',
merchantName: selected.text || '',
chainId: selected.chainId || current.chainId || '',
chainName: selected.chainName || current.chainName || '',
province: selected.province || '',
city: selected.city || '',
merchantLat: Number.isFinite(selected.lat) ? selected.lat : '',
merchantLng: Number.isFinite(selected.lng) ? selected.lng : '',
merchantAddress: selected.address || '',
_autoSelected: false
}))
this.closeMerchantPicker()
},
// ---------- 巡店列表 ----------
// wx:key 需要稳定且互不相同的标识;用自增序号,避免同一份数据里出现重复 key
buildStoreKey() {
this.storeKeySeed = (this.storeKeySeed || 0) + 1
return `store_${this.storeKeySeed}`
},
// 给数组里缺失 _rowKey 的项补齐(重提加载的旧数据没有该字段)
ensureStoreKeys(list) {
return (Array.isArray(list) ? list : []).map(item => {
if (item && item._rowKey) return item
return Object.assign({}, item, {_rowKey: this.buildStoreKey()})
})
},
createStoreItem() {
return {
// 小程序 wx:key 只认属性名,模板字面量会被编译丢弃;
// 该字段同时让增删巡店时的节点复用稳定(删除靠后的项不会让前面的项重建)
_rowKey: this.buildStoreKey(),
visitTime: this.formatDateTime(new Date()),
merchantId: '',
merchantName: '',
chainId: '',
chainName: '',
province: '',
city: '',
merchantLat: '',
merchantLng: '',
merchantAddress: '',
lat: '',
lng: '',
location: '',
photos: [],
_autoSelected: false
}
},
normalizeStoreItem(item) {
const photos = Array.isArray(item && item.photos)
? item.photos.map(photo => this.pickMediaUrl(photo)).filter(Boolean)
: []
return {
visitTime: item && (item.visitTime || item.time || item.createTime)
? (item.visitTime || item.time || item.createTime)
: '',
merchantId: item && (item.merchantId ?? item.storeId) !== undefined ? (item.merchantId ?? item.storeId) : '',
merchantName: item && (item.merchantName || item.storeName) ? (item.merchantName || item.storeName) : '',
chainId: item && item.chainId !== undefined ? item.chainId : '',
chainName: item && item.chainName ? item.chainName : '',
province: item && item.province ? item.province : '',
city: item && item.city ? item.city : '',
merchantLat: item && item.merchantLat !== undefined ? item.merchantLat : '',
merchantLng: item && item.merchantLng !== undefined ? item.merchantLng : '',
merchantAddress: item && (item.merchantAddress || item.address) ? (item.merchantAddress || item.address) : '',
lat: item && item.lat !== undefined ? item.lat : '',
lng: item && item.lng !== undefined ? item.lng : '',
location: item && item.location ? item.location : '',
photos,
_autoSelected: false
}
},
addStoreItem() {
this.storeList.push(this.createStoreItem())
},
removeStoreItem(index) {
this.storeList.splice(index, 1)
if (this.activePickerIndex === index) {
this.activePickerIndex = -1
}
},
async fetchStoreLocation(index) {
const canContinue = await this.ensureLogin()
if (!canContinue) return
uni.showLoading({title: '定位中'})
try {
const location = await getLocationOnly()
if (!location) return
const current = this.storeList[index] || this.createStoreItem()
this.$set(this.storeList, index, Object.assign({}, current, {
lat: location.latitude,
lng: location.longitude,
location: location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join(' ')
}))
} catch (error) {
uni.showToast({title: '定位失败', icon: 'none'})
} finally {
uni.hideLoading()
}
},
async uploadStorePhotos(index) {
const canContinue = await this.ensureLogin()
if (!canContinue) return
const current = this.storeList[index] || this.createStoreItem()
const photos = Array.isArray(current.photos) ? current.photos : []
const remain = this.storePhotoLimit - photos.length
if (remain <= 0) return
uni.showLoading({title: '上传中'})
try {
const res = await chooseImg(remain, ['camera']).catch(() => [])
if (!res || !res.length) return
const location = await getLocationOnly().catch(() => null)
const now = new Date()
const time = this.formatTime(now)
const date = this.formatDate(now)
const address = location
? `${location.province_str}${location.city_str}${location.area_str}`
: (current.location || '当前地址')
const nextPhotos = photos.concat(
res
.map(item => item && item.path ? this.buildWatermarkImageUrl(item.path, address, date, time) : '')
.filter(Boolean)
)
const nextStore = Object.assign({}, current, {photos: nextPhotos})
// 拍照时顺带把定位补上,省得用户再点一次「获取定位」
if (location) {
nextStore.lat = location.latitude
nextStore.lng = location.longitude
nextStore.location = location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join(' ')
}
this.$set(this.storeList, index, nextStore)
} finally {
uni.hideLoading()
}
},
removeStorePhoto(storeIndex, photoIndex) {
const current = this.storeList[storeIndex] || this.createStoreItem()
const photos = Array.isArray(current.photos) ? [...current.photos] : []
photos.splice(photoIndex, 1)
this.$set(this.storeList, storeIndex, Object.assign({}, current, {photos}))
},
previewStorePhotos(storeIndex, photoIndex = 0) {
const current = this.storeList[storeIndex] || {}
const photos = Array.isArray(current.photos) ? current.photos.filter(Boolean) : []
if (!photos.length) return
this.previewImages(photos, photos[photoIndex] || photos[0])
},
// ---------- 草稿(与驻店填报同一套交互)----------
getDraftStorageKey() {
const user = getUserInfo() || {}
const uid = user.uid || user.id || user.userId || 'guest'
return `yxe_visit_report_draft_${uid}`
},
hasDraftContent() {
const hasStore = (Array.isArray(this.storeList) ? this.storeList : []).some(item => {
if (!item) return false
const photos = Array.isArray(item.photos) ? item.photos : []
return !!(item.chainId || item.merchantId || item.location || photos.length)
})
const hasSummary = String(this.form && this.form.summary ? this.form.summary : '').trim().length > 0
return hasStore || hasSummary
},
buildDraftPayload() {
return {
version: 1,
savedAt: Date.now(),
form: this.cloneValue(this.form),
storeList: this.cloneValue(this.storeList)
}
},
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
if (!this.hasDraftPayloadContent(draft)) {
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 storeList = Array.isArray(draft.storeList) ? draft.storeList : []
const hasStore = storeList.some(item => {
if (!item) return false
const photos = Array.isArray(item.photos) ? item.photos : []
return !!(item.chainId || item.merchantId || item.location || photos.length)
})
return !!(hasStore || String(form.summary || '').trim())
},
async restoreDraft(draft) {
const nextForm = Object.assign(this.buildForm(), this.cloneValue(draft.form || {}))
this.form = nextForm
const storeList = Array.isArray(draft.storeList) ? draft.storeList : []
this.storeList = this.ensureStoreKeys(storeList.map(item => this.normalizeStoreItem(item)))
this.showChainPicker = false
this.showMerchantPicker = false
this.activePickerIndex = -1
},
// ---------- 提交 ----------
async loadResubmitRecord() {
if (!this.resubmitId || this.resubmitLoading) return
this.resubmitLoading = true
try {
const res = await visitReportInfoReq(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 || ''
const summary = this.parseStoredJson(record.summary, record.summary || '')
this.form.summary = typeof summary === 'string' ? summary : ''
const storeList = this.parseStoredJson(record.storeList, [])
this.storeList = this.ensureStoreKeys((Array.isArray(storeList) ? storeList : []).map(item => this.normalizeStoreItem(item)))
} catch (error) {
await this.toast(error && error.message ? error.message : '填报记录加载失败')
} finally {
this.resubmitLoading = false
}
},
buildSubmitStoreList() {
return (Array.isArray(this.storeList) ? this.storeList : []).map(item => ({
merchantId: item.merchantId ?? '',
merchantName: item.merchantName || '',
chainId: item.chainId || '',
chainName: item.chainName || '',
visitTime: item.visitTime || '',
province: item.province || '',
city: item.city || '',
merchantLat: item.merchantLat !== undefined ? item.merchantLat : '',
merchantLng: item.merchantLng !== undefined ? item.merchantLng : '',
merchantAddress: item.merchantAddress || '',
lat: item.lat !== undefined ? item.lat : '',
lng: item.lng !== undefined ? item.lng : '',
location: item.location || '',
photos: Array.isArray(item.photos) ? item.photos.filter(Boolean) : []
}))
},
validateSubmit() {
const list = Array.isArray(this.storeList) ? this.storeList : []
if (!list.length) {
this.toast('请至少新增一条巡店记录')
return false
}
for (let idx = 0; idx < list.length; idx += 1) {
const item = list[idx]
if (!item.chainId) {
this.toast(`请选择巡店${idx + 1}的连锁`)
return false
}
if (!item.merchantId) {
this.toast(`请选择巡店${idx + 1}的门店`)
return false
}
if (!item.photos || !item.photos.length) {
this.toast(`请上传巡店${idx + 1}照片`)
return false
}
}
const summaryText = (this.form.summary || '').trim()
if (!summaryText) {
this.toast('请填写活动小结')
return false
}
return true
},
buildPayload() {
const storeList = this.buildSubmitStoreList()
const imageList = []
storeList.forEach(item => {
;(item.photos || []).forEach(photo => {
if (photo) imageList.push(photo)
})
})
const first = storeList.find(item => item.lat && item.lng && item.location) || storeList[0] || {}
return {
storeList: JSON.stringify(storeList),
storeCount: storeList.length,
image: imageList.length ? JSON.stringify(imageList) : null,
summary: JSON.stringify((this.form.summary || '').trim()),
lat: first.lat || '',
lng: first.lng || '',
location: first.location || '',
status: 0
}
},
async submit() {
const canContinue = await this.ensureLogin()
if (!canContinue || this.submitLoading) return
if (!this.validateSubmit()) return
this.submitLoading = true
try {
if (this.isResubmit) {
const payload = this.buildPayload()
// 状态与驳回原因由服务端决定,这里不传,避免被当成审核端改状态
delete payload.status
payload.id = this.resubmitId
await visitReportResubmitReq(payload)
await this.toast('重新提交成功,请等待审核')
} else {
await visitReportAddReq(this.buildPayload())
this.clearDraft()
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>
.visit-report-page {
--form-actions-height: calc(118rpx + env(safe-area-inset-bottom));
min-height: 100vh;
background: #f5f5f5;
}
.block {
margin: 20rpx 20rpx 0;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title-row .section-title {
margin-bottom: 0;
}
.section-extra {
font-size: 24rpx;
color: #8f9bb3;
}
.audit-empty-tip {
padding: 24rpx 0;
text-align: center;
color: #9aa3b2;
font-size: 24rpx;
}
/* 与驻店填报(create.vue)保持同一套视觉,选择器数值逐项对齐 */
.sr-merchant-row {
margin-bottom: 20rpx;
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;
}
.visit-store-card {
background: #f8fafc;
border: 1rpx solid #edf2f7;
border-radius: 20rpx;
padding: 20rpx;
margin-bottom: 18rpx;
}
.visit-store-card:last-of-type {
margin-bottom: 0;
}
.visit-store-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.visit-store-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2937;
}
.visit-store-time {
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
}
.visit-store-remove,
.visit-store-action {
font-size: 24rpx;
color: #2fc6a7;
}
.visit-store-field {
margin-bottom: 18rpx;
}
.visit-store-field:last-of-type {
margin-bottom: 0;
}
.visit-store-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.visit-store-label {
font-size: 26rpx;
font-weight: 600;
color: #374151;
margin-bottom: 12rpx;
}
.visit-store-label-row .visit-store-label {
margin-bottom: 0;
}
.visit-store-add {
margin-top: 18rpx;
height: 76rpx;
border-radius: 16rpx;
border: 2rpx dashed #2fc6a7;
color: #2fc6a7;
background: #f3fffb;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
}
.location-card {
margin-top: 12rpx;
background: #f8fafc;
border-radius: 18rpx;
padding: 18rpx 20rpx;
color: #4b5563;
font-size: 26rpx;
border: 1rpx solid #edf2f7;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.location-row {
display: flex;
align-items: flex-start;
line-height: 1.6;
}
.location-label {
flex-shrink: 0;
color: #6b7280;
}
.location-value {
flex: 1;
word-break: break-all;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.photo-item {
width: calc((100% - 40rpx) / 2);
height: 200rpx;
border-radius: 18rpx;
background: #f6f7fb;
position: relative;
overflow: hidden;
border: 1rpx solid #edf1f7;
display: flex;
align-items: center;
justify-content: center;
}
.photo-item.add {
border: 2rpx dashed #cfd5e1;
background: #fbfcfe;
}
.photo-image {
width: 100%;
height: 100%;
display: block;
}
.photo-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
line-height: 1;
}
.summary-input {
width: 100%;
min-height: 180rpx;
font-size: 26rpx;
color: #111827;
box-sizing: border-box;
}
.reject-reason-text {
font-size: 26rpx;
color: #ef4444;
line-height: 1.6;
}
.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-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;
}
</style>