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
@@ -0,0 +1,185 @@
<template>
<view>
<view class="record">
<view>
<view class="record-meta">门店数:{{ currentRecord.storeCount || storeRows.length || 0 }} 家</view>
<view class="record-meta">提交人:{{ currentRecord.owner || item.owner }}</view>
<view class="record-meta">小结:{{ currentRecord.summary || '-' }}</view>
<view class="record-meta text-red" v-if="currentRecord.rejectReason">驳回原因:{{ currentRecord.rejectReason }}</view>
<view class="record-toggle" @tap="toggleDetailExpanded">
<text>{{ detailExpanded ? '收起详情' : '展开详情' }}</text>
<u-icon :name="detailExpanded ? 'arrow-up' : 'arrow-down'" size="20" color="#2fc6a7"/>
</view>
<view v-if="detailExpanded" class="record-detail">
<view v-if="loadingDetail" class="detail-empty">加载中...</view>
<view v-else-if="!storeRows.length" class="detail-empty">暂无巡店记录</view>
<view v-for="(store, idx) in storeRows" :key="`store-${idx}`" class="visit-store-block">
<view class="visit-store-head">
<text class="visit-store-title">巡店{{ idx + 1 }}</text>
<text class="visit-store-time">{{ store.visitTime || '-' }}</text>
</view>
<view class="record-meta">连锁:{{ store.chainName || '-' }}</view>
<view class="record-meta">门店:{{ store.merchantName || '-' }}</view>
<view class="record-meta">
定位:{{ store.location || '-' }}
<text v-if="store.lat && store.lng">({{ store.lat }}, {{ store.lng }})</text>
</view>
<view v-if="store.photos.length" class="media-images">
<image
v-for="(img, imgIdx) in store.photos"
:key="`store-${idx}-photo-${imgIdx}`"
class="media-image"
:src="img"
mode="aspectFill"
@click="emitPreview(img, store.photos)"
/>
</view>
<view v-else class="detail-empty">暂无巡店照片</view>
</view>
</view>
</view>
<view class="record-actions" v-if="item.status === 0">
<view class="flex-1 mx-15">
<view class="chip w-90p" @tap="handleApprove">通过</view>
</view>
<view class="flex-1 mx-15">
<view class="chip w-90p danger-chip" @tap="$emit('reject', item)">驳回</view>
</view>
</view>
</view>
</view>
</template>
<script>
import {visitReportInfoReq} from "@/api/visit-report";
export default {
name: 'AuditVisitReportItem',
props: {
item: {
type: Object,
required: true
}
},
emits: ['preview', 'approve', 'reject'],
data() {
return {
detailExpanded: false,
loadingDetail: false,
detailLoaded: false,
detailData: null
}
},
computed: {
currentRecord() {
return this.detailData || this.item
},
// 列表接口不下发 storeList 大字段,展开时再拉详情;列表里已有则直接用
storeRows() {
const source = this.currentRecord
const list = this.parseMaybeJson(source.storeList, [])
return (Array.isArray(list) ? list : []).map(store => ({
merchantName: store && (store.merchantName || store.storeName) ? (store.merchantName || store.storeName) : '',
chainName: store && store.chainName ? store.chainName : '',
visitTime: store && (store.visitTime || store.time) ? (store.visitTime || store.time) : '',
location: store && store.location ? store.location : '',
lat: store && store.lat !== undefined ? store.lat : '',
lng: store && store.lng !== undefined ? store.lng : '',
photos: this.parseImageList(store && store.photos)
}))
}
},
methods: {
parseMaybeJson(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
},
parseImageList(value) {
const list = Array.isArray(value) ? value : []
return list
.map(item => (typeof item === 'string' ? item : (item && (item.url || item.path || item.image)) || ''))
.filter(Boolean)
},
emitPreview(url, list) {
this.$emit('preview', url, list)
},
handleApprove() {
this.$emit('approve', this.currentRecord)
},
async toggleDetailExpanded() {
this.detailExpanded = !this.detailExpanded
if (!this.detailExpanded || this.detailLoaded) return
const id = this.item && this.item.id
if (id === undefined || id === null) return
this.loadingDetail = true
try {
const res = await visitReportInfoReq(id)
if (res && res.code !== 0) {
uni.showToast({title: res.message || '详情加载失败', icon: 'none'})
return
}
this.detailData = res && res.data && typeof res.data === 'object' ? res.data : null
this.detailLoaded = true
} catch (error) {
uni.showToast({
title: error && error.message ? error.message : '详情加载失败',
icon: 'none'
})
} finally {
this.loadingDetail = false
}
}
}
}
</script>
<style lang="scss" scoped>
@import './record.scss';
.record-toggle {
margin-top: 12rpx;
display: inline-flex;
align-items: center;
gap: 8rpx;
color: #2fc6a7;
font-size: 24rpx;
}
.detail-empty {
color: #94a3b8;
font-size: 24rpx;
}
.visit-store-block {
margin-top: 16rpx;
padding: 16rpx;
border-radius: 16rpx;
background: #f9fafb;
border: 1rpx solid #eef2f7;
}
.visit-store-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.visit-store-title {
font-size: 26rpx;
font-weight: 700;
color: #1f2937;
}
.visit-store-time {
font-size: 22rpx;
color: #9ca3af;
}
</style>