Files
yxe-mp/packageUser/self-report/create.vue
T
weicw-dshsa 876847ccbb feat(自主填报): 销量填写拆为独立页并按原型改版
甲方反馈原销量填写功能难看、不好用,按原型图改版。

改造前,销量填写是自主填报页内的一个区块(新增商品 → 底部弹层选品 →
回列表手输数量),与原型差异大。现拆为独立页面。

新页 packageUser/self-report/sales:
- 视觉对齐原型:搜索框 + 筛选、新增商品 + 批量填写、卡片式列表
  (序号徽标/封面/分类/厂家/编码/规格胶囊/数量步进器/删除)、底部提交
- 新增搜索(按名称或编码过滤清单)
- 新增分类筛选(弹层选择,可清空)
- 新增批量填写(弹层一次录入多项数量)

自主填报页(create.vue):
- 销量区块改为入口卡片 + 摘要列表,明细编辑移入新页
- 提交按钮文案「提交销量」改为「提交填报」——该页提交的是整单
  (打卡/门头照/销量/小票/小结/评分/定位),原标题名不副实
- 删除已迁移到新页的商品库选择逻辑(约 270 行)及废弃样式

页面间交接(util/sales-handoff.js):
- 工程无全局状态管理,页面间只能靠 storage 传递
- 仅负责一次性交接,消费后即清;持久化草稿统一由自主填报页的
  整单草稿负责(其 buildDraftPayload 已含 salesItems),避免两份状态互相覆盖
- 换连锁/换门店后清空销量明细与交接数据

修复:
- 数量步进器改用纯文本按钮。原用 u-icon 时点击无效——该组件根节点自带
  @tap,会吃掉落在图标上的点击;同页「删除」按钮因带文字才正常
- 数量输入限制为非负整数。type=number 在部分机型仍可输入 . / e / -,
  故增加输入清洗与失焦兜底;采用「取开头连续数字」而非「删掉所有非数字」,
  后者会把 1.5 抹成 15、1e3 抹成 13,属静默的数据污染

提交结构 salesGroups 未变,后端与审核端无需调整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-11 18:47:03 +08:00

1835 lines
59 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-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";
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('')
},
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 = []
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>