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:
@@ -0,0 +1,21 @@
|
||||
import post, {get, put, del} from "@/api/request";
|
||||
|
||||
// 跑店填报(巡店列表 + 活动小结)
|
||||
export const visitReportAddReq = data => post('/yxe/yxe-visit-report', data)
|
||||
|
||||
export const visitReportListReq = params => get('/yxe/yxe-visit-report', {params})
|
||||
|
||||
export const visitReportPageReq = params => get('/yxe/yxe-visit-report/page', {params})
|
||||
|
||||
export const visitReportAuditPageReq = params => get('/yxe/yxe-visit-report/audit/page', {params})
|
||||
|
||||
export const visitReportInfoReq = id => get(`/yxe/yxe-visit-report/${id}`)
|
||||
|
||||
export const visitReportUserListReq = params => get('/yxe/yxe-visit-report/userList', {params})
|
||||
|
||||
export const visitReportChangeReq = data => put('/yxe/yxe-visit-report', data)
|
||||
|
||||
// 驳回后重新提交:仅本人、仅已驳回记录可调用,状态由服务端置回待审核
|
||||
export const visitReportResubmitReq = data => put('/yxe/yxe-visit-report/resubmit', data)
|
||||
|
||||
export const visitReportDeleteReq = id => del(`/yxe/yxe-visit-report/${id}`)
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
<AppNavbar title="填报记录" showBack/>
|
||||
|
||||
<view class="card subsection">
|
||||
<view class="type-tabs">
|
||||
<view
|
||||
v-for="tab in typeTabs"
|
||||
:key="tab.value"
|
||||
:class="['tab', activeType === tab.value ? 'active' : '']"
|
||||
@tap="changeType(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="status-tabs">
|
||||
<view
|
||||
v-for="tab in statusTabs"
|
||||
@@ -45,21 +55,21 @@
|
||||
<view v-for="item in filteredRecords" :key="item.id" class="record-card">
|
||||
<view class="record-head">
|
||||
<view>
|
||||
<view class="record-title">{{ item.merchantName || '-' }}</view>
|
||||
<view class="record-sub" v-if="item.chainName && item.chainName !== '-'">连锁:{{ item.chainName }}</view>
|
||||
<view class="record-title">{{ cardTitle(item) }}</view>
|
||||
<view class="record-sub" v-if="cardSubTitle(item)">{{ cardSubTitle(item) }}</view>
|
||||
</view>
|
||||
<view :class="['status-badge', statusClass(item.status)]">{{ statusText(item.status) }}</view>
|
||||
</view>
|
||||
<view class="record-grid">
|
||||
<view class="record-kv">
|
||||
<view v-if="activeType === 'store'" class="record-kv">
|
||||
<text class="record-label">活动时间</text>
|
||||
<text class="record-value">{{ item.activityTime || '-' }}</text>
|
||||
</view>
|
||||
<!-- <view class="record-kv">-->
|
||||
<!-- <text class="record-label">奖金</text>-->
|
||||
<!-- <text class="record-value text-red">¥{{ formatPrice(item.reward) }}</text>-->
|
||||
<!-- </view>-->
|
||||
<view class="record-kv">
|
||||
<view v-else class="record-kv">
|
||||
<text class="record-label">巡店门店数</text>
|
||||
<text class="record-value">{{ item.storeCount || 0 }}家</text>
|
||||
</view>
|
||||
<view v-if="activeType === 'store'" class="record-kv">
|
||||
<text class="record-label">评分</text>
|
||||
<text class="record-value">{{ item.rating || '-' }}星</text>
|
||||
</view>
|
||||
@@ -77,6 +87,35 @@
|
||||
<u-icon :name="isExpanded(item.id) ? 'arrow-up' : 'arrow-down'" size="20" color="#2fc6a7"/>
|
||||
</view>
|
||||
<view v-if="isExpanded(item.id)" class="record-detail">
|
||||
<!-- 跑店填报:巡店列表(驻店填报没有门店列表,走下面的固定区块) -->
|
||||
<block v-if="activeType === 'visit'">
|
||||
<view v-if="!item.storeRows.length" class="detail-empty">暂无巡店记录</view>
|
||||
<view v-for="(store, sIdx) in item.storeRows" :key="`store-${sIdx}`" class="store-block">
|
||||
<view class="store-block-head">
|
||||
<text class="store-block-title">巡店{{ sIdx + 1 }}</text>
|
||||
<text class="store-block-time">{{ store.visitTime || '-' }}</text>
|
||||
</view>
|
||||
<view class="store-block-line">门店:{{ store.merchantName || '-' }}</view>
|
||||
<view class="store-block-line">连锁:{{ store.chainName || '-' }}</view>
|
||||
<view class="store-block-line">
|
||||
定位:{{ 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-${sIdx}-photo-${imgIdx}`"
|
||||
class="media-image"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@tap="preview(img, store.photos)"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="detail-empty">暂无巡店照片</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else>
|
||||
<view class="record-section">
|
||||
<view class="record-section-title">上下班打卡</view>
|
||||
<view v-if="item.clockImages.length" class="media-images">
|
||||
@@ -139,6 +178,7 @@
|
||||
</view>
|
||||
<view v-else class="detail-empty">暂无销量数据</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view v-if="isRejected(item.status)" class="record-actions">
|
||||
@@ -185,12 +225,19 @@ import AppNavbar from "@/components/AppNavbar.vue";
|
||||
import Login from "@/components/Login.vue";
|
||||
import {dictDataListByDictReq} from "@/api/common";
|
||||
import {selfReportUserListReq} from "@/api/self-report";
|
||||
import {visitReportUserListReq} from "@/api/visit-report";
|
||||
import {getUserInfo} from "@/util/user";
|
||||
|
||||
export default {
|
||||
components: {AppNavbar, Login},
|
||||
data() {
|
||||
return {
|
||||
// 驻店填报 = yxe_self_report,跑店填报 = yxe_visit_report(两张表,分别拉取)
|
||||
typeTabs: [
|
||||
{label: '驻店填报', value: 'store'},
|
||||
{label: '跑店填报', value: 'visit'}
|
||||
],
|
||||
activeType: 'store',
|
||||
statusTabs: [
|
||||
{label: '全部', value: 'all'},
|
||||
{label: '待审核', value: 0},
|
||||
@@ -226,10 +273,10 @@ export default {
|
||||
if (this.activeStatus !== 'all' && Number(item.status) !== Number(this.activeStatus)) {
|
||||
return false
|
||||
}
|
||||
if (this.filterForm.chainId && !this.matchesSelectedChain(item.chainIdNormalized, item.chainName, this.filterForm.chainId)) {
|
||||
if (this.filterForm.chainId && !this.matchesItemChain(item)) {
|
||||
return false
|
||||
}
|
||||
if (keyword && !String(item.merchantName || '').toLowerCase().includes(keyword)) {
|
||||
if (keyword && !this.matchesItemKeyword(item, keyword)) {
|
||||
return false
|
||||
}
|
||||
if ((startTs || endTs) && !Number.isFinite(item.submitTimestamp)) {
|
||||
@@ -291,7 +338,8 @@ export default {
|
||||
async fetchList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const {data} = await selfReportUserListReq({})
|
||||
const req = this.activeType === 'visit' ? visitReportUserListReq : selfReportUserListReq
|
||||
const {data} = await req({})
|
||||
this.records = this.unwrapList(data)
|
||||
} catch (error) {
|
||||
this.records = []
|
||||
@@ -321,6 +369,9 @@ export default {
|
||||
}
|
||||
},
|
||||
normalizeRecord(item = {}) {
|
||||
if (this.activeType === 'visit') {
|
||||
return this.normalizeVisitRecord(item)
|
||||
}
|
||||
const createRaw = item.createTime || item.createdAt || item.date || ''
|
||||
const clockPhotos = this.parseMaybeJson(item.clockPhotos, [])
|
||||
const boothPhotos = this.parseMaybeJson(item.boothPhotos, [])
|
||||
@@ -346,7 +397,48 @@ export default {
|
||||
clockImages: this.normalizeMediaList(clockPhotos),
|
||||
boothImages: this.normalizeMediaList(boothPhotos),
|
||||
receiptImages: this.normalizeMediaList(receiptPhoto),
|
||||
salesRows: this.buildSalesRows(salesGroups)
|
||||
salesRows: this.buildSalesRows(salesGroups),
|
||||
storeCount: 0,
|
||||
storeRows: []
|
||||
}
|
||||
},
|
||||
// 跑店填报没有单门店/活动时间/打卡/销量,卡片主体是巡店列表
|
||||
normalizeVisitRecord(item = {}) {
|
||||
const createRaw = item.createTime || item.createdAt || ''
|
||||
const summaryRaw = this.parseMaybeJson(item.summary, item.summary || '')
|
||||
const summary = typeof summaryRaw === 'string' ? summaryRaw : JSON.stringify(summaryRaw || '')
|
||||
const storeList = this.parseMaybeJson(item.storeList, [])
|
||||
const storeRows = (Array.isArray(storeList) ? storeList : []).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.normalizeMediaList(store && store.photos)
|
||||
}))
|
||||
return {
|
||||
id: item.id,
|
||||
merchantId: '',
|
||||
merchantName: '',
|
||||
chainName: '-',
|
||||
chainIdNormalized: '',
|
||||
activityTime: '',
|
||||
date: '',
|
||||
status: item.status,
|
||||
rejectReason: item.rejectReason || item.reject_reason || '',
|
||||
reward: item.reward,
|
||||
rating: '',
|
||||
summary,
|
||||
submitTime: this.formatDateTime(createRaw, createRaw || '-'),
|
||||
submitTimestamp: this.parseDateToTimestamp(createRaw),
|
||||
clockImages: [],
|
||||
boothImages: [],
|
||||
receiptImages: [],
|
||||
salesRows: [],
|
||||
// 门店数与主门店关键词都从列表内容推导,避免列表页再下发冗余字段
|
||||
storeCount: Number(item.storeCount) || storeRows.length,
|
||||
storeRows
|
||||
}
|
||||
},
|
||||
buildSalesRows(groups) {
|
||||
@@ -413,6 +505,21 @@ export default {
|
||||
value
|
||||
}
|
||||
},
|
||||
// 跑店记录没有单一门店/连锁,判定要落到巡店列表的每一行
|
||||
matchesItemChain(item) {
|
||||
const selectedId = this.filterForm.chainId
|
||||
if (selectedId === '' || selectedId === null || selectedId === undefined) return true
|
||||
if (this.activeType === 'visit') {
|
||||
return (item.storeRows || []).some(store => this.matchesSelectedChain('', store.chainName, selectedId))
|
||||
}
|
||||
return this.matchesSelectedChain(item.chainIdNormalized, item.chainName, selectedId)
|
||||
},
|
||||
matchesItemKeyword(item, keyword) {
|
||||
if (this.activeType === 'visit') {
|
||||
return (item.storeRows || []).some(store => String(store.merchantName || '').toLowerCase().includes(keyword))
|
||||
}
|
||||
return String(item.merchantName || '').toLowerCase().includes(keyword)
|
||||
},
|
||||
matchesSelectedChain(chainId, chainName, selectedId) {
|
||||
if (selectedId === '' || selectedId === null || selectedId === undefined) return true
|
||||
const option = this.chainOptions.find(item => String(item.id) === String(selectedId))
|
||||
@@ -477,9 +584,33 @@ export default {
|
||||
},
|
||||
handleResubmit(item) {
|
||||
if (!item || item.id === undefined || item.id === null) return
|
||||
uni.navigateTo({
|
||||
url: `/packageUser/self-report/create?id=${item.id}&mode=resubmit`
|
||||
})
|
||||
const url = this.activeType === 'visit'
|
||||
? `/packageUser/self-report/visit?id=${item.id}&mode=resubmit`
|
||||
: `/packageUser/self-report/create?id=${item.id}&mode=resubmit`
|
||||
uni.navigateTo({url})
|
||||
},
|
||||
cardTitle(item) {
|
||||
if (this.activeType === 'visit') {
|
||||
const names = (item.storeRows || [])
|
||||
.map(store => store.merchantName)
|
||||
.filter(Boolean)
|
||||
if (names.length) {
|
||||
return names.length > 2 ? `${names.slice(0, 2).join('、')} 等${names.length}家` : names.join('、')
|
||||
}
|
||||
return '跑店填报'
|
||||
}
|
||||
return item.merchantName || '-'
|
||||
},
|
||||
cardSubTitle(item) {
|
||||
if (this.activeType === 'visit') return ''
|
||||
return item.chainName && item.chainName !== '-' ? `连锁:${item.chainName}` : ''
|
||||
},
|
||||
changeType(value) {
|
||||
if (this.activeType === value) return
|
||||
this.activeType = value
|
||||
this.expandedMap = {}
|
||||
this.records = []
|
||||
this.fetchList()
|
||||
},
|
||||
changeStatus(value) {
|
||||
this.activeStatus = value
|
||||
@@ -581,6 +712,14 @@ export default {
|
||||
margin: 18rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
padding-bottom: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-bottom: 1rpx solid #eef2f7;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
@@ -839,6 +978,48 @@ export default {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.store-block {
|
||||
padding: 18rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f9fafb;
|
||||
border: 1rpx solid #eef2f7;
|
||||
}
|
||||
|
||||
.store-block:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.store-block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.store-block-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.store-block-time {
|
||||
font-size: 22rpx;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.store-block-line {
|
||||
margin-bottom: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.store-block .media-images {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.sales-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<view class="select-page">
|
||||
<!-- 本页 hero 即页头(设计如此),不套 AppNavbar;
|
||||
navigationStyle 是 custom,状态栏高度要自己让出来,否则内容冲顶。 -->
|
||||
<view class="hero" :style="{paddingTop: `${heroPaddingTop}px`}">
|
||||
<view class="hero-back" @tap="back">
|
||||
<u-icon name="arrow-left" size="20" color="#0f2f52"/>
|
||||
</view>
|
||||
<view class="hero-text">
|
||||
<view class="hero-title">自主填报</view>
|
||||
<view class="hero-sub">真实记录 · 及时上报 · 共创佳绩</view>
|
||||
</view>
|
||||
<image class="hero-avatar" :src="heroAvatar" mode="aspectFit"/>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title-row">
|
||||
<view class="section-title">请选择填报类型</view>
|
||||
</view>
|
||||
<view class="section-desc">请选择您本次要填写的工作类型</view>
|
||||
|
||||
<view
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
:class="['type-card', form.type === item.value ? 'type-card-active' : '']"
|
||||
@tap="form.type = item.value"
|
||||
>
|
||||
<image class="type-icon" :src="item.icon" mode="aspectFit"/>
|
||||
<view class="type-body">
|
||||
<view class="type-title">{{ item.title }}</view>
|
||||
<view class="type-desc">{{ item.desc }}</view>
|
||||
</view>
|
||||
<view :class="['type-radio', form.type === item.value ? 'type-radio-active' : '']">
|
||||
<view v-if="form.type === item.value" class="type-radio-dot"/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="select-actions">
|
||||
<view class="primary-btn confirm" @tap="confirm">确认选择</view>
|
||||
</view>
|
||||
<view class="select-tip">① 请选择合适的填报类型,以便更准确地记录您的工作</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 顶部插画:暂无独立设计稿资源,沿用首页自主填报入口所用的同色系插图
|
||||
const HERO_AVATAR = 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/9c6c98843c2e30247355d86ea47552aa.png'
|
||||
const ICON_STORE = 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/0fd31838127cd57b1c1db44f2c749353.png'
|
||||
const ICON_VISIT = 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260127/f5fbb2e4be31bcc830156f174e7931b8.png'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
heroAvatar: HERO_AVATAR,
|
||||
// 状态栏高度:本页自绘页头,得自己避让,否则内容会顶到状态栏
|
||||
statusBarHeight: 0,
|
||||
form: {
|
||||
type: 'store'
|
||||
},
|
||||
typeOptions: [
|
||||
{
|
||||
value: 'store',
|
||||
title: '驻店填报',
|
||||
desc: '适用于在门店驻店,进行商品陈列、销售等工作的场景',
|
||||
icon: ICON_STORE
|
||||
},
|
||||
{
|
||||
value: 'visit',
|
||||
title: '跑店填报',
|
||||
desc: '适用于外出拜访门店、客户或其他工作场景',
|
||||
icon: ICON_VISIT
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 内联 padding-top 会覆盖 .hero 里 padding 简写的上边距,
|
||||
// 所以这里把原有 24rpx(≈12px)一并算进去,视觉与设计稿一致。
|
||||
heroPaddingTop() {
|
||||
return this.statusBarHeight + 12
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = systemInfo.statusBarHeight || 0
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (getCurrentPages().length === 1) {
|
||||
uni.switchTab({url: '/pages/index/index'})
|
||||
return
|
||||
}
|
||||
uni.navigateBack()
|
||||
},
|
||||
confirm() {
|
||||
const type = this.form.type
|
||||
const url = type === 'visit'
|
||||
? '/packageUser/self-report/visit'
|
||||
: '/packageUser/self-report/create'
|
||||
uni.navigateTo({
|
||||
url,
|
||||
fail: () => {
|
||||
uni.showToast({title: '页面打开失败', icon: 'none'})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select-page {
|
||||
min-height: 100vh;
|
||||
background: #eef8f5;
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 40rpx 36rpx;
|
||||
background: linear-gradient(180deg, #d9f5ec 0%, #eef8f5 100%);
|
||||
}
|
||||
|
||||
/* 本页没有 AppNavbar,返回入口自绘;随流排布,避免与标题重叠 */
|
||||
.hero-back {
|
||||
flex-shrink: 0;
|
||||
margin-right: 20rpx;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: #0f2f52;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: #5c6c84;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 180rpx;
|
||||
height: 150rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 0 24rpx;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: -6rpx 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9aa3b2;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 26rpx 22rpx;
|
||||
margin-bottom: 22rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 3rpx solid #e6eaf0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.type-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.type-card-active {
|
||||
border-color: #2fc6a7;
|
||||
background: #f1fbf8;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 108rpx;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: #7b8794;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.type-radio {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #d3d9e2;
|
||||
background: #ffffff;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.type-radio-active {
|
||||
border-color: #2fc6a7;
|
||||
}
|
||||
|
||||
.type-radio-dot {
|
||||
width: 22rpx;
|
||||
height: 22rpx;
|
||||
border-radius: 50%;
|
||||
background: #2fc6a7;
|
||||
}
|
||||
|
||||
.select-actions {
|
||||
margin: 48rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
height: 92rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 12rpx 26rpx rgba(47, 198, 167, 0.28);
|
||||
}
|
||||
|
||||
.select-tip {
|
||||
margin-top: 26rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #9aa3b2;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -164,12 +164,26 @@
|
||||
"root": "packageUser",
|
||||
"pages": [
|
||||
{
|
||||
"path": "self-report/create",
|
||||
"path": "self-report/select",
|
||||
"style": {
|
||||
"navigationBarTitleText": "自主填报",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "self-report/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "驻店填报",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "self-report/visit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "跑店填报",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "self-report/sales",
|
||||
"style": {
|
||||
|
||||
@@ -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>
|
||||
+184
-11
@@ -106,6 +106,16 @@
|
||||
@updated="handleSelfReportUpdated"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="activeType === 'visitReport'">
|
||||
<AuditVisitReportItem
|
||||
v-for="item in currentTasks"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@preview="preview"
|
||||
@approve="handleVisitReportApprove"
|
||||
@reject="handleVisitReportReject"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AuditShareItem
|
||||
v-for="item in currentTasks"
|
||||
@@ -127,21 +137,40 @@
|
||||
<view v-if="showRejectPopup" class="income-popup-mask" @tap="closeRejectPopup"></view>
|
||||
<view v-if="showRejectPopup" class="income-popup card" @tap.stop>
|
||||
<view class="income-popup-head">
|
||||
<view class="section-title">{{ pendingRejectType === 'selfReport' ? '填写驳回原因(可选)' : '填写拒绝原因' }}</view>
|
||||
<view class="section-title">{{ isSelfReportLikeRejectType(pendingRejectType) ? '填写驳回原因(可选)' : '填写拒绝原因' }}</view>
|
||||
<view class="close-btn" @tap="closeRejectPopup">关闭</view>
|
||||
</view>
|
||||
<view class="income-form">
|
||||
<view class="income-label">{{ pendingRejectType === 'selfReport' ? '驳回原因' : '拒绝原因' }}</view>
|
||||
<view class="income-label">{{ isSelfReportLikeRejectType(pendingRejectType) ? '驳回原因' : '拒绝原因' }}</view>
|
||||
<input
|
||||
v-model="rejectReason"
|
||||
class="income-input"
|
||||
type="text"
|
||||
:placeholder="pendingRejectType === 'selfReport' ? '可不填写' : '请输入拒绝原因'"
|
||||
:placeholder="isSelfReportLikeRejectType(pendingRejectType) ? '可不填写' : '请输入拒绝原因'"
|
||||
/>
|
||||
</view>
|
||||
<view class="primary-btn income-submit" @tap="submitRejectReason">提交</view>
|
||||
</view>
|
||||
|
||||
<!-- 跑店填报没有销量小计,奖金只能审核时手填 -->
|
||||
<view v-if="showRewardPopup" class="income-popup-mask" @tap="closeRewardPopup"></view>
|
||||
<view v-if="showRewardPopup" class="income-popup card" @tap.stop>
|
||||
<view class="income-popup-head">
|
||||
<view class="section-title">输入奖金金额</view>
|
||||
<view class="close-btn" @tap="closeRewardPopup">关闭</view>
|
||||
</view>
|
||||
<view class="income-form">
|
||||
<view class="income-label">奖金金额</view>
|
||||
<input
|
||||
v-model="rewardAmount"
|
||||
class="income-input"
|
||||
type="digit"
|
||||
placeholder="请输入奖金金额,可填0"
|
||||
/>
|
||||
</view>
|
||||
<view class="primary-btn income-submit" @tap="submitRewardAmount">提交</view>
|
||||
</view>
|
||||
|
||||
<u-picker
|
||||
:show="showChainPicker"
|
||||
:columns="[chainOptions]"
|
||||
@@ -178,21 +207,24 @@ import {auditUserTaskPageReq, changeUserTaskReq, listUserTaskReq} from "@/api/ta
|
||||
import dayjs from 'dayjs'
|
||||
import {shareAuditPageReq, shareChangeReq, sharePageReq} from "@/api/share";
|
||||
import {selfReportAuditPageReq, selfReportChangeReq, selfReportPageReq} from "@/api/self-report";
|
||||
import {visitReportAuditPageReq, visitReportChangeReq, visitReportPageReq} from "@/api/visit-report";
|
||||
import AppNavbar from "@/components/AppNavbar.vue";
|
||||
import AuditMovementItem from "./components/AuditMovementItem.vue";
|
||||
import AuditStoreVisitItem from "./components/AuditStoreVisitItem.vue";
|
||||
import AuditShareItem from "./components/AuditShareItem.vue";
|
||||
import AuditSelfReportItem from "./components/AuditSelfReportItem.vue";
|
||||
import AuditVisitReportItem from "./components/AuditVisitReportItem.vue";
|
||||
import {dictDataListByDictReq} from "@/api/common";
|
||||
|
||||
export default {
|
||||
components: {AppNavbar, AuditMovementItem, AuditStoreVisitItem, AuditShareItem, AuditSelfReportItem},
|
||||
components: {AppNavbar, AuditMovementItem, AuditStoreVisitItem, AuditShareItem, AuditSelfReportItem, AuditVisitReportItem},
|
||||
data() {
|
||||
return {
|
||||
typeTabs: [
|
||||
{label: '药动销', value: 'movement'},
|
||||
{label: '跑店/巡店', value: 'storeVisit'},
|
||||
{label: '自主填报', value: 'selfReport'},
|
||||
{label: '驻店填报', value: 'selfReport'},
|
||||
{label: '跑店填报', value: 'visitReport'},
|
||||
{label: '药分享', value: 'exchange'}
|
||||
],
|
||||
statusTabs: [
|
||||
@@ -206,6 +238,7 @@ export default {
|
||||
movementTasks: [],
|
||||
storeVisitTasks: [],
|
||||
selfReportTasks: [],
|
||||
visitReportTasks: [],
|
||||
exchangeTasks: [],
|
||||
loading: false,
|
||||
chainLoading: false,
|
||||
@@ -225,6 +258,10 @@ export default {
|
||||
rejectReason: '',
|
||||
pendingReject: null,
|
||||
pendingRejectType: 'audit',
|
||||
// 跑店填报审核通过时的奖金输入
|
||||
showRewardPopup: false,
|
||||
rewardAmount: '',
|
||||
pendingRewardItem: null,
|
||||
pageNo: 1,
|
||||
pageSize: 5,
|
||||
hasMore: true,
|
||||
@@ -236,10 +273,12 @@ export default {
|
||||
if (this.activeType === 'movement') return this.movementTasks
|
||||
if (this.activeType === 'storeVisit') return this.storeVisitTasks
|
||||
if (this.activeType === 'selfReport') return this.selfReportTasks
|
||||
if (this.activeType === 'visitReport') return this.visitReportTasks
|
||||
return this.exchangeTasks
|
||||
},
|
||||
displayStatusTabs() {
|
||||
if (this.activeType === 'selfReport') {
|
||||
// 驻店/跑店填报都由审核端驳回,没有「抄送」态
|
||||
if (this.isSelfReportLikeType) {
|
||||
return this.statusTabs.filter(item => item.value !== 3)
|
||||
}
|
||||
return this.statusTabs
|
||||
@@ -247,6 +286,13 @@ export default {
|
||||
isStoreVisitType() {
|
||||
return this.activeType === 'storeVisit'
|
||||
},
|
||||
// 跑店填报的连锁在 store_list JSON 里,没有 chain_id 可匹配,只能按连锁名模糊查
|
||||
useChainNameFilter() {
|
||||
return this.activeType === 'visitReport'
|
||||
},
|
||||
isSelfReportLikeType() {
|
||||
return this.activeType === 'selfReport' || this.activeType === 'visitReport'
|
||||
},
|
||||
chainLabel() {
|
||||
const selected = this.chainOptions.find(item => item.id === this.filterForm.chainId)
|
||||
return (selected && selected.text) || '连锁筛选'
|
||||
@@ -274,12 +320,13 @@ export default {
|
||||
await this.fetchList()
|
||||
},
|
||||
getPageSize(type = this.activeType) {
|
||||
return type === 'selfReport' ? 10 : this.pageSize
|
||||
return this.isSelfReportLikeType || type === 'selfReport' || type === 'visitReport' ? 10 : this.pageSize
|
||||
},
|
||||
getTaskListByType(type = this.activeType) {
|
||||
if (type === 'movement') return this.movementTasks
|
||||
if (type === 'storeVisit') return this.storeVisitTasks
|
||||
if (type === 'selfReport') return this.selfReportTasks
|
||||
if (type === 'visitReport') return this.visitReportTasks
|
||||
return this.exchangeTasks
|
||||
},
|
||||
setTaskListByType(type = this.activeType, list = []) {
|
||||
@@ -295,6 +342,10 @@ export default {
|
||||
this.selfReportTasks = list
|
||||
return
|
||||
}
|
||||
if (type === 'visitReport') {
|
||||
this.visitReportTasks = list
|
||||
return
|
||||
}
|
||||
this.exchangeTasks = list
|
||||
},
|
||||
resetPagination() {
|
||||
@@ -450,6 +501,28 @@ export default {
|
||||
}
|
||||
return params
|
||||
},
|
||||
// 跑店填报表里没有 chain_id/merchant_id,只能按 store_list 文本模糊匹配
|
||||
buildVisitReportFilterParams() {
|
||||
const params = {}
|
||||
const userGroup = (this.filterForm.userGroup || '').trim()
|
||||
if (userGroup) params.userGroup = userGroup
|
||||
const keyword = (this.filterForm.merchantKeyword || '').trim()
|
||||
const chainText = this.selectedChainText()
|
||||
const startDate = this.filterForm.startDate
|
||||
const endDate = this.filterForm.endDate
|
||||
if (chainText) params.chainName = chainText
|
||||
if (keyword) params.merchantName = keyword
|
||||
if (startDate) params.dateStart = startDate
|
||||
if (endDate) params.dateEnd = endDate
|
||||
return params
|
||||
},
|
||||
// 下拉里选的是连锁的 id,新表按名字查,需要回查成文本
|
||||
selectedChainText() {
|
||||
const selectedId = this.filterForm.chainId
|
||||
if (selectedId === '' || selectedId === null || selectedId === undefined) return ''
|
||||
const option = this.chainOptions.find(item => String(item.id) === String(selectedId))
|
||||
return option ? (option.text || '') : ''
|
||||
},
|
||||
filterExchangeList(list = []) {
|
||||
const keyword = (this.filterForm.merchantKeyword || '').trim().toLowerCase()
|
||||
const startTs = this.filterForm.startDate ? dayjs(`${this.filterForm.startDate} 00:00:00`).valueOf() : null
|
||||
@@ -582,7 +655,15 @@ export default {
|
||||
this.filterForm.chainId = ''
|
||||
this.filterForm.merchantKeyword = ''
|
||||
}
|
||||
if (value === 'selfReport' && this.activeStatus === 3) {
|
||||
// 跑店填报没有 chain_id 列,连锁只能在已加载的选项里按名字查;
|
||||
// 切到该类型时若选中的是虚拟连锁(chain_ 前缀),清掉避免查出空列表
|
||||
if (value === 'visitReport') {
|
||||
const chainIdText = String(this.getSelectedChainApiValue(this.filterForm.chainId) || '')
|
||||
if (!chainIdText || chainIdText.startsWith('chain_')) {
|
||||
this.filterForm.chainId = ''
|
||||
}
|
||||
}
|
||||
if ((value === 'selfReport' || value === 'visitReport') && this.activeStatus === 3) {
|
||||
this.activeStatus = 0
|
||||
}
|
||||
this.activeType = value
|
||||
@@ -672,6 +753,30 @@ export default {
|
||||
this.setTaskListByType('selfReport', merged)
|
||||
this.updatePagination(pageResult, requestPage, list.length, pageSize)
|
||||
}
|
||||
} else if (this.activeType === 'visitReport') {
|
||||
if (isCopyQuery) {
|
||||
this.setTaskListByType('visitReport', [])
|
||||
this.hasMore = false
|
||||
} else {
|
||||
const params = {
|
||||
status: this.activeStatus,
|
||||
page: requestPage,
|
||||
limit: pageSize,
|
||||
auditLite: 1
|
||||
}
|
||||
Object.assign(params, this.buildVisitReportFilterParams())
|
||||
const {data} = await visitReportAuditPageReq(params)
|
||||
const pageResult = this.extractPageResult(data)
|
||||
const list = pageResult.list
|
||||
const normalized = list.map(item => this.normalizeVisitReport(item))
|
||||
const nextList = this.filterMovementList(normalized, {
|
||||
enableChainFilter: false,
|
||||
enableMerchantFilter: false
|
||||
})
|
||||
const merged = append ? this.getTaskListByType('visitReport').concat(nextList) : nextList
|
||||
this.setTaskListByType('visitReport', merged)
|
||||
this.updatePagination(pageResult, requestPage, list.length, pageSize)
|
||||
}
|
||||
} else {
|
||||
const params = {
|
||||
status: isCopyQuery ? 2 : this.activeStatus,
|
||||
@@ -798,6 +903,27 @@ export default {
|
||||
submitTimestamp: this.parseDateToTimestamp(activityDate)
|
||||
}
|
||||
},
|
||||
// 跑店填报:卡片主体是巡店列表,列表接口不带 storeList(大字段),展开时再拉详情
|
||||
normalizeVisitReport(item) {
|
||||
const createRaw = item.createTime || item.createdAt || ''
|
||||
const summaryRaw = this.parseJson(item.summary, '')
|
||||
const summary = typeof summaryRaw === 'string' ? summaryRaw : JSON.stringify(summaryRaw)
|
||||
const user = item && item.user ? item.user : {}
|
||||
const owner = item.owner || `${user.realName || user.nickname || '-'},${user.phone || ''}`
|
||||
return {
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
storeCount: Number(item.storeCount) || 0,
|
||||
storeList: item.storeList || '',
|
||||
owner,
|
||||
status: item.status,
|
||||
reward: item.reward,
|
||||
location: item.location || '',
|
||||
summary,
|
||||
rejectReason: item.rejectReason || '',
|
||||
submitTimestamp: this.parseDateToTimestamp(createRaw)
|
||||
}
|
||||
},
|
||||
formatAmount(value) {
|
||||
const num = Number(value || 0)
|
||||
return num.toFixed(2)
|
||||
@@ -895,6 +1021,48 @@ export default {
|
||||
async handleSelfReportUpdated() {
|
||||
await this.reloadFromFirstPage()
|
||||
},
|
||||
// 跑店填报与驻店填报共用同一套审核动作,仅接口不同。
|
||||
// 跑店没有销量小计,奖金得审核端手填,所以先弹输入框。
|
||||
async handleVisitReportApprove(item) {
|
||||
this.pendingRewardItem = item
|
||||
this.rewardAmount = item.reward === null || item.reward === undefined ? '' : `${item.reward}`
|
||||
this.showRewardPopup = true
|
||||
},
|
||||
closeRewardPopup() {
|
||||
this.showRewardPopup = false
|
||||
this.rewardAmount = ''
|
||||
this.pendingRewardItem = null
|
||||
},
|
||||
async submitRewardAmount() {
|
||||
const item = this.pendingRewardItem
|
||||
if (!item) {
|
||||
this.closeRewardPopup()
|
||||
return
|
||||
}
|
||||
const raw = String(this.rewardAmount === null || this.rewardAmount === undefined ? '' : this.rewardAmount).trim()
|
||||
if (!raw) {
|
||||
uni.showToast({title: '请输入奖金金额', icon: 'none'})
|
||||
return
|
||||
}
|
||||
const reward = Number(raw)
|
||||
if (!Number.isFinite(reward) || reward < 0) {
|
||||
uni.showToast({title: '奖金金额不能小于0', icon: 'none'})
|
||||
return
|
||||
}
|
||||
await visitReportChangeReq({
|
||||
id: item.id,
|
||||
status: 1,
|
||||
reward
|
||||
})
|
||||
uni.showToast({
|
||||
title: '操作成功'
|
||||
})
|
||||
this.closeRewardPopup()
|
||||
await this.reloadFromFirstPage()
|
||||
},
|
||||
async handleVisitReportReject(item) {
|
||||
this.openRejectPopup(item, 'visitReport')
|
||||
},
|
||||
async handleShareUpdate({item, payload, localDetailList}) {
|
||||
const data = this.buildSharePayload(item, payload)
|
||||
await shareChangeReq(data)
|
||||
@@ -928,7 +1096,7 @@ export default {
|
||||
},
|
||||
async submitRejectReason() {
|
||||
const reason = (this.rejectReason || '').trim()
|
||||
if (this.pendingRejectType !== 'selfReport' && !reason) {
|
||||
if (!this.isSelfReportLikeRejectType(this.pendingRejectType) && !reason) {
|
||||
uni.showToast({
|
||||
title: '请输入拒绝原因',
|
||||
icon: 'none'
|
||||
@@ -939,8 +1107,9 @@ export default {
|
||||
this.closeRejectPopup()
|
||||
return
|
||||
}
|
||||
if (this.pendingRejectType === 'selfReport') {
|
||||
await selfReportChangeReq({
|
||||
if (this.isSelfReportLikeRejectType(this.pendingRejectType)) {
|
||||
const req = this.pendingRejectType === 'visitReport' ? visitReportChangeReq : selfReportChangeReq
|
||||
await req({
|
||||
id: this.pendingReject.id,
|
||||
status: 2,
|
||||
rejectReason: reason
|
||||
@@ -955,6 +1124,10 @@ export default {
|
||||
await this.submitAuditAction(3, this.pendingReject, reason)
|
||||
this.closeRejectPopup()
|
||||
},
|
||||
// 驻店/跑店填报的驳回都是「驳回原因可不填」
|
||||
isSelfReportLikeRejectType(type) {
|
||||
return type === 'selfReport' || type === 'visitReport'
|
||||
},
|
||||
preview(url, list) {
|
||||
uni.previewImage({
|
||||
current: url,
|
||||
|
||||
@@ -582,7 +582,8 @@ export default {
|
||||
this.detailPopupVisible = true;
|
||||
},
|
||||
openSelfReportCreate() {
|
||||
this.jumpWithLogin('/packageUser/self-report/create')
|
||||
// 「自主填报」先选填报类型(驻店填报 / 跑店填报),再进对应表单页
|
||||
this.jumpWithLogin('/packageUser/self-report/select')
|
||||
},
|
||||
openSelfReportRecord() {
|
||||
this.jumpWithLogin('/packageUser/self-report/records')
|
||||
|
||||
@@ -489,7 +489,8 @@ export default {
|
||||
this.jumpWithLogin('/pages/bonus/index')
|
||||
},
|
||||
openSelfReport() {
|
||||
this.jumpWithLogin('/packageUser/self-report/create')
|
||||
// 「自主填报」先选填报类型(驻店填报 / 跑店填报),再进对应表单页
|
||||
this.jumpWithLogin('/packageUser/self-report/select')
|
||||
},
|
||||
openWithdraw() {
|
||||
this.jumpWithLogin('/pages/bonus/withdraw')
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 连锁 / 门店两级选择的公用逻辑。
|
||||
*
|
||||
* 抽出来的原因:驻店填报(packageUser/self-report/create.vue)与跑店填报
|
||||
* (packageUser/self-report/visit.vue)都要「先选连锁、再选门店」,
|
||||
* 且门店全量可达 8000+ 条 —— 明细必须挂在实例上而不是页面 data,
|
||||
* 否则页面 data 树膨胀到 1MB+,每次 setData 的 diff/序列化都会从 ~490ms 涨到 ~1000ms。
|
||||
*
|
||||
* 用法:在页面 methods 里混入本文件的 methods(页面 data 仍需自己声明
|
||||
* merchantChainList / merchantLoading / merchantStoreSearchKeyword / 各 picker 的开关与 value)。
|
||||
*/
|
||||
|
||||
export function 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 ''
|
||||
}
|
||||
|
||||
export function 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 = unwrapList(value)
|
||||
if (nested.length) return nested
|
||||
}
|
||||
}
|
||||
return [data]
|
||||
}
|
||||
|
||||
export function 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
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMerchantChain(item, index) {
|
||||
const chainName = 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 => 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 = 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
|
||||
}
|
||||
}
|
||||
|
||||
// 接口可能返回「扁平门店列表(带 chainName)」,也可能返回「连锁嵌套门店」,两种都兼容
|
||||
export function groupMerchantListByChain(list) {
|
||||
const chainMap = {}
|
||||
;(Array.isArray(list) ? list : []).forEach((item, index) => {
|
||||
const merchant = normalizeMerchant(item)
|
||||
if (merchant.id === undefined || merchant.id === null || String(merchant.id).trim() === '') return
|
||||
const chainName = 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])
|
||||
}
|
||||
|
||||
export function 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
|
||||
}
|
||||
|
||||
// 门店关键词过滤:连锁名命中由调用方另判,这里只按门店名匹配
|
||||
export function filterMerchantStores(merchants, keyword) {
|
||||
const list = Array.isArray(merchants) ? merchants : []
|
||||
const text = String(keyword || '').trim()
|
||||
if (!text) return list
|
||||
return list.filter(merchant => (merchant.text || '').includes(text))
|
||||
}
|
||||
|
||||
export default {
|
||||
pickFirstText,
|
||||
unwrapList,
|
||||
normalizeMerchant,
|
||||
normalizeMerchantChain,
|
||||
groupMerchantListByChain,
|
||||
flattenMerchantsFromChains,
|
||||
filterMerchantStores
|
||||
}
|
||||
Reference in New Issue
Block a user