aff1e52e3e
「自主填报」原本只有一种表单。本次拆成两种类型:
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 共用一份,
避免两页各自维护导致解析口径不一致
1217 lines
38 KiB
Vue
1217 lines
38 KiB
Vue
<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>
|