Files
yxe-mp/packageUser/self-report/create.vue
T
weicw-dshsa 9eaa168d72 feat(填报记录): 被驳回记录支持修改后重新提交
- records.vue:已驳回记录卡片底部新增“重新提交”按钮,跳转
  create?id=<id>&mode=resubmit;同时补上驳回原因展示
  (原 normalizeRecord 未提取 rejectReason,列表页看不到驳回理由)
- create.vue:复用同一表单页做编辑模式,onLoad 读 id/mode,
  回填门店、日期、打卡照、摆台照、小票、销量、小结、评分、定位;
  标题与按钮切换为“重新提交填报/重新提交”,隐藏保存草稿
  (草稿 key 与新增共用,会互相覆盖)
- 提交走 resubmit 接口,成功后返回列表,onShow 自动刷新为待审核
- api/self-report.js:新增 selfReportResubmitReq
2026-10-11 17:05:12 +08:00

2284 lines
74 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="self-report-page">
<AppNavbar :title="isResubmit ? '重新提交填报' : '自主填报'" showBack/>
<view class="sr-merchant-row" @tap="openChainPicker">
<view class="sr-merchant-label">连锁筛选</view>
<view class="sr-merchant-right">
<text :class="form.chainId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ selectedChainText }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="sr-merchant-row" @tap="openMerchantPicker">
<view class="sr-merchant-label">门店筛选</view>
<view class="sr-merchant-right">
<text :class="form.merchantId ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ selectedMerchantText }}
</text>
<u-icon name="arrow-right" size="20" color="#9ca3af"/>
</view>
</view>
<view class="sr-merchant-row" @tap="openActivityTimePicker">
<view class="sr-merchant-label">活动日期</view>
<view class="sr-merchant-right">
<text :class="form.date ? 'sr-merchant-text' : 'sr-merchant-placeholder'">
{{ form.date || '请选择活动日期' }}
</text>
<u-icon name="calendar" size="20" color="#9ca3af"/>
</view>
</view>
<view class="card block">
<view class="section-title">上班打卡(水印拍照)</view>
<view class="clock-grid">
<view class="clock-card primary">
<view class="clock-thumb" @tap="handleClockAction(0)">
<image v-if="clockPhotos[0] && clockPhotos[0].url" :src="clockPhotos[0].url" mode="aspectFill" class="clock-image"/>
<view v-else class="clock-placeholder">
<u-icon name="photo" size="40" color="#52627a"/>
</view>
<view v-if="clockPhotos[0] && clockPhotos[0].url" class="clock-check">
<u-icon name="checkmark-circle-fill" size="22" color="#22c55e"/>
</view>
<view class="clock-info">
<view class="clock-time">{{ (clockPhotos[0] && clockPhotos[0].time) || '--:--' }}</view>
<view class="clock-date">{{ (clockPhotos[0] && clockPhotos[0].date) || '--' }}</view>
</view>
</view>
</view>
</view>
</view>
<view class="card block">
<view class="section-title-row">
<view class="section-title">门头摆台照</view>
<view class="section-extra">最多{{ boothLimit }}张</view>
</view>
<view class="photo-grid">
<view v-for="(item, idx) in boothPhotos" :key="`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-actions">
<view class="sales-add-btn" @tap="openProductPicker">
<u-icon name="plus" size="18" color="#ffffff"/>
<text>新增商品</text>
</view>
</view>
<view v-if="salesItems.length === 0" class="audit-empty-tip">请点击“新增商品”逐个添加销量商品</view>
<view v-else class="selected-sales-list">
<view class="selected-sales-item" v-for="(item, idx) in salesItems" :key="item._key">
<view class="selected-sales-head">
<view>
<view class="selected-sales-name">{{ item.name }}</view>
<view class="selected-sales-group">{{ item.groupTitle }}</view>
</view>
<view class="selected-sales-remove" @tap="removeSalesItem(idx)">删除</view>
</view>
<view class="selected-sales-meta">
<view class="selected-sales-meta-item" v-if="item.makerAbb">
<text class="meta-label">厂家</text>
<text class="meta-value">{{ item.makerAbb }}</text>
</view>
<view class="selected-sales-meta-item">
<text class="meta-label">编码</text>
<text class="meta-value">{{ item.goodsCode || '-' }}</text>
</view>
</view>
<view class="sales-controls">
<view class="spec-wrap">
<picker class="spec-picker" mode="selector" :range="item.specOptions" @change="onSpecChange($event, item)">
<view class="spec-pill">
<text>{{ item.spec || '规格' }}</text>
</view>
</picker>
<!-- <view v-if="hasTaskIncome(item)" class="sales-income">奖金金额:¥{{ formatTaskIncome(item) }}</view>-->
</view>
<view class="qty-box">
<input v-model="item.qty" class="qty-input" type="number" placeholder="0"/>
<text class="qty-unit">{{ item.unit || '盒' }}</text>
</view>
</view>
</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-popup :show="showProductPicker" mode="bottom" round="24rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeProductPicker">取消</view>
<text class="picker-title">选择商品</text>
<view class="picker-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<u-input v-model="salesKeyword" placeholder="搜索商品名称/厂家/规格" shape="circle" :custom-style="{border:'3px solid #ff8a4c !important'}"/>
</view>
<view v-if="productLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else class="product-panel">
<scroll-view class="category-sidebar" scroll-y>
<view
v-for="group in filteredProductGroups"
:key="group.key"
class="category-item"
:class="currentProductGroupKey === group.key ? 'category-item-active' : ''"
@tap="selectProductGroup(group.key)"
>
{{ group.title }}
</view>
</scroll-view>
<scroll-view class="product-panel-main" scroll-y>
<view v-if="filteredProductGroups.length === 0" class="audit-empty-tip">暂无可选商品</view>
<view v-else-if="displayedProductOptions.length === 0" class="audit-empty-tip">该分类暂无可选商品</view>
<view v-else class="product-picker-list">
<view
class="product-picker-item"
v-for="item in displayedProductOptions"
:key="item._key"
@tap="selectProduct(item)"
>
<image
v-if="item.cover"
:src="item.cover"
mode="aspectFill"
class="product-picker-cover"
/>
<view class="product-picker-main">
<view class="product-picker-name">{{ item.name }}</view>
<view class="product-picker-sub">{{ item.makerAbb || '-' }} / {{ item.spec || '规格' }}</view>
<!-- <view v-if="hasTaskIncome(item)" class="product-picker-income">奖金金额:¥{{ formatTaskIncome(item) }}</view>-->
</view>
<view class="product-picker-tag">{{ isProductSelected(item) ? '已添加' : '添加' }}</view>
</view>
</view>
</scroll-view>
</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 {productGroupByCateReq} from "@/api/product";
import {selfReportAddReq, selfReportInfoReq, selfReportResubmitReq} from "@/api/self-report";
import {getUserInfo} from "@/util/user";
import {chooseImg, getLocationOnly} from "@/util";
export default {
components: {Login, AppNavbar},
data() {
return {
isLoggedIn: false,
hasInitialized: false,
merchantLoading: false,
productLoading: 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,
productGroups: [],
productLoadedChainName: '',
salesItems: [],
salesKeyword: '',
showProductPicker: false,
activeProductGroupKey: '',
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;'
},
filteredProductGroups() {
const keyword = (this.salesKeyword || '').trim().toLowerCase()
const groups = Array.isArray(this.productGroups) ? this.productGroups : []
if (!keyword) return groups
return groups
.map(group => {
const items = Array.isArray(group.items) ? group.items : []
const nextItems = items.filter(item => {
const name = String(item.name || '').toLowerCase()
const maker = String(item.makerAbb || '').toLowerCase()
const spec = String(item.spec || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword)
})
return Object.assign({}, group, {items: nextItems})
})
.filter(group => Array.isArray(group.items) && group.items.length)
},
currentProductGroupKey() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
if (!groups.length) return ''
const hasActive = groups.some(group => group.key === this.activeProductGroupKey)
return hasActive ? this.activeProductGroupKey : groups[0].key
},
displayedProductOptions() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
const current = groups.find(group => group.key === this.currentProductGroupKey)
return current && Array.isArray(current.items) ? current.items : []
},
totalSalesItemCount() {
return Array.isArray(this.salesItems) ? this.salesItems.length : 0
},
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()
}
}
},
methods: {
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.productGroups = []
this.salesItems = []
this.salesKeyword = ''
this.showProductPicker = false
this.showChainPicker = false
this.activeProductGroupKey = ''
this.currentAddress = ''
this.lat = null
this.lng = null
this.currentProvince = ''
this.currentCity = ''
await this.getMerchantList()
await this.getProductGroups()
},
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()
}
// 销量商品按连锁重新拉取,保证规格选项与当前连锁一致
if (this.form.chainName) {
await this.getProductGroups()
}
} 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.salesKeyword = ''
this.showProductPicker = false
this.showChainPicker = false
this.showMerchantPicker = false
if (this.form.chainName) {
await this.getProductGroups()
}
},
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('')
},
normalizeMerchant(item) {
const id = item.id ?? item.merchantId ?? item.merchant_id
const text = item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
const lat = Number(item.lat ?? item.latitude ?? item.merchantLat ?? item.merchant_lat)
const lng = Number(item.lng ?? item.longitude ?? item.merchantLng ?? item.merchant_lng)
const address = [item.province, item.city, item.region, item.area, item.address].filter(Boolean).join('')
return {
id,
text,
chainId: item.chainId ?? item.chain_id ?? '',
chainName: item.chainName || item.chain || item.chainAbb || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
province: item.province || item.province_str || '',
city: item.city || item.city_str || '',
address
}
},
normalizeChainIdForApi(rawChainId) {
const text = String(rawChainId || '').trim()
if (!text || text.startsWith('chain_')) return ''
return text
},
pickFirstText(source, keys = []) {
if (!source || typeof source !== 'object') return ''
for (let i = 0; i < keys.length; i += 1) {
const value = source[keys[i]]
if (value !== undefined && value !== null && String(value).trim()) {
return String(value).trim()
}
}
return ''
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
for (let i = 0; i < keys.length; i += 1) {
const value = data[keys[i]]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const nested = this.unwrapList(value)
if (nested.length) return nested
}
}
return [data]
},
normalizeMerchantChain(item, index) {
const chainName = this.pickFirstText(item, ['chainName', 'chain', 'chainAbb', 'name', 'title']) || `连锁${index + 1}`
const rawChainId = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? chainName
const chainId = rawChainId === undefined || rawChainId === null || String(rawChainId).trim() === ''
? `chain_${index}`
: rawChainId
const childCandidates = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
let rawChildren = []
for (let i = 0; i < childCandidates.length; i += 1) {
const list = item[childCandidates[i]]
if (Array.isArray(list) && list.length) {
rawChildren = list
break
}
}
let merchants = rawChildren
.map(child => this.normalizeMerchant(child))
.filter(merchant => merchant.id !== undefined && merchant.id !== null && String(merchant.id).trim() !== '')
.map(merchant => Object.assign({}, merchant, {chainId, chainName}))
if (!merchants.length) {
const selfMerchant = this.normalizeMerchant(item)
if (selfMerchant.id !== undefined && selfMerchant.id !== null && String(selfMerchant.id).trim() !== '') {
merchants = [Object.assign({}, selfMerchant, {chainId, chainName})]
}
}
return {
id: chainId,
text: chainName,
merchants
}
},
groupMerchantListByChain(list) {
const chainMap = {}
;(Array.isArray(list) ? list : []).forEach((item, index) => {
const merchant = this.normalizeMerchant(item)
if (merchant.id === undefined || merchant.id === null || String(merchant.id).trim() === '') return
const chainName = this.pickFirstText(item, ['chainName', 'chain', 'chainAbb', 'chain_name'])
if (!chainName) return
const rawChainId = item.chainId ?? item.chain_id ?? item.chainCode ?? item.chain_code ?? chainName
const chainId = rawChainId === undefined || rawChainId === null || String(rawChainId).trim() === ''
? `chain_${index}`
: rawChainId
const key = String(chainId)
if (!chainMap[key]) {
chainMap[key] = {id: chainId, text: chainName, merchants: []}
}
chainMap[key].merchants.push(Object.assign({}, merchant, {chainId, chainName}))
})
return Object.keys(chainMap).map(key => chainMap[key])
},
flattenMerchantsFromChains(chainList) {
const list = []
;(Array.isArray(chainList) ? chainList : []).forEach(chain => {
const merchants = Array.isArray(chain.merchants) ? chain.merchants : []
merchants.forEach(merchant => {
if (!merchant || merchant.id === '' || merchant.id === null || merchant.id === undefined) return
list.push(Object.assign({}, merchant, {
chainId: merchant.chainId || chain.id || '',
chainName: merchant.chainName || chain.text || ''
}))
})
})
return list
},
filterMerchantStores(merchants) {
const list = Array.isArray(merchants) ? merchants : []
const keyword = (this.merchantStoreSearchKeyword || '').trim()
if (!keyword) return list
return list.filter(merchant => (merchant.text || '').includes(keyword))
},
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 = []
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
this.salesKeyword = ''
}
this.closeChainPicker()
if (changed) await this.getProductGroups()
},
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.salesKeyword = ''
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
this.closeMerchantPicker()
if (nextChainName && nextChainName !== prevChainName) {
await this.getProductGroups()
}
},
isProductNode(node) {
if (!node || typeof node !== 'object') return false
const product = node.product && typeof node.product === 'object' ? node.product : {}
const hasChildrenOnly = Array.isArray(node.children)
&& !this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
&& !node.product
if (hasChildrenOnly) return false
return !!(
this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode']) ||
this.pickFirstText(product, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
)
},
collectProductNodes(node, depth = 0) {
if (!node || depth > 4) return []
if (Array.isArray(node)) {
return node.reduce((acc, item) => acc.concat(this.collectProductNodes(item, depth + 1)), [])
}
if (typeof node !== 'object') return []
if (this.isProductNode(node)) return [node]
const keys = ['productList', 'products', 'list', 'records', 'rows', 'items', 'children', 'data']
return keys.reduce((acc, key) => {
const value = node[key]
if (Array.isArray(value) || (value && typeof value === 'object')) {
return acc.concat(this.collectProductNodes(value, depth + 1))
}
return acc
}, [])
},
buildCategoryTabs(rawData) {
const rootList = this.unwrapList(rawData)
const tabs = []
rootList.forEach((categoryNode, categoryIndex) => {
if (!categoryNode || typeof categoryNode !== 'object') return
const label = categoryNode.cate
const products = Array.isArray(categoryNode.children) ? categoryNode.children : []
if (!label || !products.length) return
tabs.push({
key: `cate_children_${categoryIndex}`,
label,
rawProducts: products
})
})
if (tabs.length) return tabs
const allProducts = this.collectProductNodes(rootList)
if (allProducts.length) {
tabs.push({
key: 'all',
label: '全部',
rawProducts: allProducts
})
}
return tabs
},
normalizeSpecOptions(item) {
const raw = item.specOptions || item.specList || item.specs || []
if (Array.isArray(raw)) {
return raw.map(value => `${value}`.trim()).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(/[、,,]/).map(value => value.trim()).filter(Boolean)
}
const spec = item.spec || item.specification || item.standard || ''
return spec ? [spec] : []
},
normalizeProduct(item) {
const product = item && item.product ? item.product : {}
const code = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || ''
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || ''
const name = item.goodsName || item.goods_name || item.title || item.name || product.title || product.goodsName || product.name || ''
const maker = item.makerAbb || item.maker || item.manufacturer || product.makerAbb || product.maker || product.manufacturer || ''
const spec = product.spec || item.spec || item.specification || ''
const cover = item.cover || product.cover || ''
const chainId = item.chainId || item.chain_id || product.chainId || product.chain_id || ''
const chainName = item.chainName || item.chain_name || item.chain || product.chainName || product.chain_name || product.chain || ''
const merchantId = item.merchantId || item.merchant_id || product.merchantId || product.merchant_id || ''
const merchantName = item.merchantName || item.merchant_name || item.shopName || item.shop_name || product.merchantName || product.merchant_name || ''
return {
productId,
goodsCode: code,
name,
cover,
makerAbb: maker,
spec,
chainId,
chainName,
merchantId,
merchantName,
unit: item.unit || product.unit || '盒',
taskIncome: this.resolveTaskIncome(item),
specOptions: this.normalizeSpecOptions(Object.assign({}, product, item)),
qty: ''
}
},
hasDisplayValue(value) {
return value !== undefined && value !== null && String(value).trim() !== ''
},
resolveTaskIncome(item) {
if (!item || typeof item !== 'object') return ''
const product = item.product && typeof item.product === 'object' ? item.product : {}
const candidates = [
item.taskIncome
]
for (let i = 0; i < candidates.length; i += 1) {
const value = candidates[i]
if (this.hasDisplayValue(value)) return value
}
return ''
},
hasTaskIncome(item) {
return this.hasDisplayValue(this.resolveTaskIncome(item))
},
formatTaskIncome(item) {
const value = this.resolveTaskIncome(item)
if (!this.hasDisplayValue(value)) return ''
const num = Number(value)
if (!Number.isFinite(num)) return String(value)
return Number.isInteger(num) ? String(num) : num.toFixed(2)
},
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)
},
filterProductGroupsByChain(groups = [], rawChainId = '') {
const chainId = this.normalizeChainIdForApi(rawChainId)
if (!chainId) return Array.isArray(groups) ? groups : []
return (Array.isArray(groups) ? groups : [])
.map(group => Object.assign({}, group, {
items: (Array.isArray(group.items) ? group.items : []).filter(item => {
const productChainId = String(item.chainId || '').trim()
return !productChainId || productChainId === chainId
})
}))
.filter(group => Array.isArray(group.items) && group.items.length)
},
buildSalesGroupsFromProductData(rawData) {
const tabs = this.buildCategoryTabs(rawData)
let rowSeed = 0
const groups = tabs.map((tab, tabIndex) => {
const items = (Array.isArray(tab.rawProducts) ? tab.rawProducts : [])
.map((rawItem, itemIndex) => {
const product = this.normalizeProduct(rawItem)
if (!product.name) return null
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: [product.spec || '规格']
return {
...product,
groupTitle: tab.label || `分类${tabIndex + 1}`,
spec: product.spec || specOptions[0] || '规格',
specOptions,
_key: `sales_${tabIndex}_${itemIndex}_${rowSeed++}`
}
})
.filter(Boolean)
return {
key: tab.key || `group_${tabIndex}`,
title: tab.label || `分类${tabIndex + 1}`,
items
}
}).filter(group => group.items.length)
return groups
},
async getProductGroups() {
const chainName = String(this.form.chainName || '').trim()
if (!chainName) {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
return
}
this.productLoading = true
try {
const {data} = await productGroupByCateReq({chain: chainName, type: 0})
this.productGroups = this.buildSalesGroupsFromProductData(data)
this.productLoadedChainName = chainName
} catch (error) {
this.productGroups = []
this.productLoadedChainName = ''
uni.showToast({
title: '商品数据获取失败',
icon: 'none'
})
} finally {
this.activeProductGroupKey = this.productGroups[0] ? this.productGroups[0].key : ''
this.productLoading = false
}
},
async openProductPicker() {
if (!this.form.chainName || !this.form.merchantId) {
uni.showToast({
title: '请先选择连锁和门店',
icon: 'none'
})
return
}
const currentChainName = String(this.form.chainName || '').trim()
if (!currentChainName) {
uni.showToast({
title: '当前连锁无效',
icon: 'none'
})
return
}
if (this.productLoadedChainName !== currentChainName || !this.productGroups.length) {
await this.getProductGroups()
}
if (!this.filteredProductGroups.length) {
uni.showToast({
title: '当前连锁暂无可选商品',
icon: 'none'
})
return
}
if (!this.activeProductGroupKey) {
this.activeProductGroupKey = this.filteredProductGroups[0] ? this.filteredProductGroups[0].key : ''
}
this.showProductPicker = true
},
closeProductPicker() {
this.showProductPicker = false
},
selectProductGroup(key) {
this.activeProductGroupKey = key
},
isProductSelected(product) {
return (Array.isArray(this.salesItems) ? this.salesItems : []).some(item => {
if (product.productId && item.productId) {
return String(item.productId) === String(product.productId)
}
return item.name === product.name
})
},
selectProduct(product) {
if (this.isProductSelected(product)) {
uni.showToast({
title: '该商品已添加',
icon: 'none'
})
return
}
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? [...product.specOptions]
: [product.spec || '规格']
this.salesItems.push({
...product,
groupTitle: product.groupTitle || '',
specOptions,
spec: product.spec || specOptions[0] || '规格',
qty: ''
})
this.showProductPicker = false
this.salesKeyword = ''
},
removeSalesItem(index) {
this.salesItems.splice(index, 1)
},
onSpecChange(event, item) {
const value = Number(event.detail.value || 0)
const nextSpec = item.specOptions[value] || item.spec
item.spec = nextSpec
},
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
})
},
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)
await this.toast('重新提交成功,请等待审核')
} else {
await selfReportAddReq(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>
.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-search {
margin-bottom: 18rpx;
}
.sales-actions {
margin-bottom: 18rpx;
}
.sales-add-btn {
width: 240rpx;
min-height: 76rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ff8a4c, #ff6b4a);
box-shadow: 0 12rpx 24rpx rgba(255, 107, 74, 0.2);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.selected-sales-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.selected-sales-item {
padding: 22rpx;
border-radius: 22rpx;
background: linear-gradient(180deg, #fffaf5, #ffffff);
border: 1rpx solid #fde7d8;
}
.selected-sales-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.selected-sales-name {
font-size: 30rpx;
font-weight: 700;
color: #0f172a;
}
.selected-sales-group {
margin-top: 8rpx;
font-size: 22rpx;
color: #c2410c;
}
.selected-sales-remove {
color: #ef4444;
font-size: 24rpx;
}
.selected-sales-meta {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.selected-sales-meta-item {
min-width: 220rpx;
padding: 12rpx 16rpx;
border-radius: 16rpx;
background: #fff7ed;
}
.meta-label {
color: #9a3412;
font-size: 22rpx;
}
.meta-value {
display: block;
margin-top: 4rpx;
color: #7c2d12;
font-size: 24rpx;
}
.sales-controls {
margin-top: 16rpx;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.spec-wrap {
flex: 1;
min-width: 0;
}
.spec-picker {
display: block;
}
.spec-pill {
min-height: 72rpx;
border-radius: 999rpx;
background: #fff7ed;
border: 1rpx solid #fdba74;
color: #c2410c;
display: flex;
align-items: center;
justify-content: center;
padding: 0 22rpx;
font-size: 24rpx;
}
.sales-income {
margin-top: 10rpx;
padding-left: 8rpx;
color: #dc2626;
font-size: 24rpx;
line-height: 1.4;
}
.qty-box {
width: 240rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
padding: 0 20rpx;
min-height: 72rpx;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid #dbe3ef;
}
.qty-input {
flex: 1;
font-size: 30rpx;
color: #0f172a;
}
.qty-unit {
color: #7a8495;
font-size: 24rpx;
}
.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;
}
.product-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.product-panel {
display: flex;
height: 62vh;
border-top: 1rpx solid #eef2f7;
}
.category-sidebar {
width: 220rpx;
background: linear-gradient(180deg, #f8fafc, #fff7ed);
border-right: 1rpx solid #eef2f7;
}
.category-item {
min-height: 88rpx;
padding: 18rpx 16rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #64748b;
font-size: 24rpx;
border-bottom: 1rpx solid rgba(226, 232, 240, 0.8);
}
.category-item-active {
background: #ffffff;
color: #ea580c;
font-weight: 700;
}
.product-panel-main {
flex: 1;
padding: 20rpx 24rpx 24rpx;
box-sizing: border-box;
}
.product-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 18rpx 20rpx;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
}
.product-picker-item + .product-picker-item {
margin-top: 12rpx;
}
.product-picker-main {
flex: 1;
min-width: 0;
}
.product-picker-cover {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
background: #ffffff;
}
.product-picker-name {
color: #0f172a;
font-size: 28rpx;
font-weight: 600;
}
.product-picker-sub {
margin-top: 6rpx;
color: #64748b;
font-size: 22rpx;
}
.product-picker-income {
margin-top: 6rpx;
color: #dc2626;
font-size: 22rpx;
}
.product-picker-tag {
flex-shrink: 0;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #dcfce7;
color: #15803d;
font-size: 22rpx;
font-weight: 700;
}
.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>