feat(自主填报): 拆分驻店/跑店填报并新增跑店填报页

「自主填报」原本只有一种表单。本次拆成两种类型:

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

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

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

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

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

顺带重构:
- 门店解析(扁平列表 / 连锁嵌套两种结构的兼容、门店明细不入页面 data
  的性能处理)抽到 util/store-picker,create 与 visit 共用一份,
  避免两页各自维护导致解析口径不一致
This commit is contained in:
2026-10-11 22:50:15 +08:00
parent 4fa29ea4c6
commit aff1e52e3e
11 changed files with 2264 additions and 140 deletions
+33 -111
View File
@@ -1,6 +1,6 @@
<template>
<view class="self-report-page">
<AppNavbar :title="isResubmit ? '重新提交填报' : '自主填报'" showBack/>
<AppNavbar :title="isResubmit ? '重新提交填报' : '驻店填报'" showBack/>
<view class="sr-merchant-row" @tap="openChainPicker">
<view class="sr-merchant-label">连锁筛选</view>
@@ -268,6 +268,15 @@ import {
clearSalesHandoff,
cloneSalesItems
} from "@/util/sales-handoff";
import {
normalizeMerchant as sharedNormalizeMerchant,
pickFirstText as sharedPickFirstText,
unwrapList as sharedUnwrapList,
normalizeMerchantChain as sharedNormalizeMerchantChain,
groupMerchantListByChain as sharedGroupMerchantListByChain,
flattenMerchantsFromChains as sharedFlattenMerchantsFromChains,
filterMerchantStores as sharedFilterMerchantStores
} from "@/util/store-picker";
export default {
components: {Login, AppNavbar},
@@ -761,123 +770,36 @@ export default {
this.currentCity = location.city_str || ''
this.currentAddress = location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join('')
},
// 连锁/门店解析统一走 util/store-picker:跑店填报(visit.vue)用同一份逻辑,
// 两页各自维护一份迟早会出现解析口径不一致。
normalizeMerchant(item) {
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
}
return sharedNormalizeMerchant(item)
},
pickFirstText(source, keys = []) {
return sharedPickFirstText(source, keys)
},
unwrapList(data) {
return sharedUnwrapList(data)
},
normalizeMerchantChain(item, index) {
return sharedNormalizeMerchantChain(item, index)
},
groupMerchantListByChain(list) {
return sharedGroupMerchantListByChain(list)
},
flattenMerchantsFromChains(chainList) {
return sharedFlattenMerchantsFromChains(chainList)
},
// 本页的门店搜索关键词存在 data 里(merchantStoreSearchKeyword),
// 与 visit.vue 由调用方传入关键词的签名不同,这里保留本页签名。
filterMerchantStores(merchants) {
return sharedFilterMerchantStores(merchants, this.merchantStoreSearchKeyword)
},
normalizeChainIdForApi(rawChainId) {
const text = String(rawChainId || '').trim()
if (!text || text.startsWith('chain_')) return ''
return text
},
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 || ''