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