Files
yxe-mp/packageUser/self-report/visit.vue
T
weicw-dshsa aff1e52e3e feat(自主填报): 拆分驻店/跑店填报并新增跑店填报页
「自主填报」原本只有一种表单。本次拆成两种类型:

1. 新增选择填报类型页 packageUser/self-report/select
   - 按设计稿:hero 页头 + 两张类型卡(驻店填报 / 跑店填报)+ 确认选择
   - 入口(首页、我的)由直接进 create 改为先进 select

2. 原自主填报页改为「驻店填报」
   - 仅改导航标题,表单内容不变

3. 新增跑店填报页 packageUser/self-report/visit
   - 复制现有跑店/巡店任务(pages/task/detail 的 cateId=4 分支)的填报页并改造:
     保留巡店列表与下方内容,去掉列表上方的任务卡片/总进度/任务详情;
     打卡、摆台照、销量、小票、门店评分一并不保留
   - 巡店列表内「连锁筛选」「门店筛选」是两个并列的独立输入框,
     各自弹各自的选择器(不串联),样式与驻店填报逐项对齐;
     换连锁会清空已选门店
   - 每条巡店可新增/删除,独立获取定位 + 打卡拍照(最多 9 张)
   - 定位不强制(巡店任务里是强制的,这里放开)
   - 底部「保存草稿」+「提交填报」,草稿交互与驻店填报一致:
     进页面询问是否加载、提交成功后清除;storage key 与驻店分开
   - 驳回后重新提交复用同一页面(mode=resubmit)

4. 填报记录页加「驻店 / 跑店」类型切换
   - 两表分表查询,各自分页;跑店卡片展示门店数、巡店列表(门店/定位/照片)
   - 连锁与门店关键词筛选落到巡店列表的每一行

5. 药审核加「跑店填报」类型
   - 新增 AuditVisitReportItem,展开时按 id 拉详情(列表不下发 store_list)
   - 跑店没有销量小计,审核通过需手填奖金,故补奖金输入弹层

顺带重构:
- 门店解析(扁平列表 / 连锁嵌套两种结构的兼容、门店明细不入页面 data
  的性能处理)抽到 util/store-picker,create 与 visit 共用一份,
  避免两页各自维护导致解析口径不一致
2026-10-11 22:50:15 +08:00

1217 lines
38 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="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="`store-${idx}`" 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="`store-${idx}-photo-${photoIdx}`" 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="`chain-${idx}`" 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="`merchant-${idx}`" 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 = []
},
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()
},
// ---------- 巡店列表 ----------
createStoreItem() {
return {
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 = 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 = (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>