Files
yxe-mp/pages/audit/components/AuditVisitReportItem.vue
T
weicw-dshsa aff1e52e3e 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 共用一份,
  避免两页各自维护导致解析口径不一致
2026-10-11 22:50:15 +08:00

186 lines
5.9 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>
<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>