Files
yxe-mp/packageUser/self-report/records.vue
T
weicw-dshsa 9eaa168d72 feat(填报记录): 被驳回记录支持修改后重新提交
- records.vue:已驳回记录卡片底部新增“重新提交”按钮,跳转
  create?id=<id>&mode=resubmit;同时补上驳回原因展示
  (原 normalizeRecord 未提取 rejectReason,列表页看不到驳回理由)
- create.vue:复用同一表单页做编辑模式,onLoad 读 id/mode,
  回填门店、日期、打卡照、摆台照、小票、销量、小结、评分、定位;
  标题与按钮切换为“重新提交填报/重新提交”,隐藏保存草稿
  (草稿 key 与新增共用,会互相覆盖)
- 提交走 resubmit 接口,成功后返回列表,onShow 自动刷新为待审核
- api/self-report.js:新增 selfReportResubmitReq
2026-10-11 17:05:12 +08:00

882 lines
25 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="records-page">
<AppNavbar title="填报记录" showBack/>
<view class="card subsection">
<view class="status-tabs">
<view
v-for="tab in statusTabs"
:key="tab.value"
:class="['tab', activeStatus === tab.value ? 'active' : '']"
@tap="changeStatus(tab.value)"
>
{{ tab.label }}
</view>
</view>
</view>
<view class="card block">
<view class="section-title">筛选查询</view>
<view class="filter-row">
<view class="filter-chip" @tap="openChainPicker">{{ chainLabel }}</view>
<view class="filter-chip" @tap="openDatePicker('start')">{{ filterForm.startDate || '开始日期' }}</view>
<view class="filter-chip" @tap="openDatePicker('end')">{{ filterForm.endDate || '结束日期' }}</view>
</view>
<view class="keyword-row">
<input
v-model="filterForm.merchantKeyword"
class="keyword-input"
type="text"
confirm-type="search"
placeholder="请输入门店关键词"
placeholder-class="keyword-placeholder"
@confirm="handleQuery"
/>
<view class="filter-btn primary" @tap="handleQuery">查询</view>
<view class="filter-btn" @tap="handleReset">重置</view>
</view>
</view>
<view class="block">
<view class="section-title">记录列表</view>
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="filteredRecords.length === 0" class="empty">暂无记录</view>
<view v-else>
<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>
<view :class="['status-badge', statusClass(item.status)]">{{ statusText(item.status) }}</view>
</view>
<view class="record-grid">
<view 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">
<text class="record-label">评分</text>
<text class="record-value">{{ item.rating || '-' }}星</text>
</view>
<view class="record-kv">
<text class="record-label">提交时间</text>
<text class="record-value">{{ item.submitTime || '-' }}</text>
</view>
</view>
<view class="summary-line">小结:{{ item.summary || '-' }}</view>
<view v-if="isRejected(item.status) && item.rejectReason" class="reject-line">
驳回原因:{{ item.rejectReason }}
</view>
<view class="record-toggle" @tap="toggleExpanded(item.id)">
<text>{{ isExpanded(item.id) ? '收起详情' : '展开详情' }}</text>
<u-icon :name="isExpanded(item.id) ? 'arrow-up' : 'arrow-down'" size="20" color="#2fc6a7"/>
</view>
<view v-if="isExpanded(item.id)" class="record-detail">
<view class="record-section">
<view class="record-section-title">上下班打卡</view>
<view v-if="item.clockImages.length" class="media-images">
<image
v-for="(img, idx) in item.clockImages"
:key="`clock-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@tap="preview(img, item.clockImages)"
/>
</view>
<view v-else class="detail-empty">暂无打卡图片</view>
</view>
<view class="record-section">
<view class="record-section-title">门头摆台照</view>
<view v-if="item.boothImages.length" class="media-images">
<image
v-for="(img, idx) in item.boothImages"
:key="`booth-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@tap="preview(img, item.boothImages)"
/>
</view>
<view v-else class="detail-empty">暂无门头摆台照</view>
</view>
<view class="record-section">
<view class="record-section-title">小票上传</view>
<view v-if="item.receiptImages.length" class="media-images">
<image
v-for="(img, idx) in item.receiptImages"
:key="`receipt-${idx}`"
class="media-image"
:src="img"
mode="aspectFill"
@tap="preview(img, item.receiptImages)"
/>
</view>
<view v-else class="detail-empty">暂无小票</view>
</view>
<view class="record-section">
<view class="record-section-title">销量填写</view>
<view v-if="item.salesRows.length" class="sales-list">
<view class="sales-item" v-for="(detail, idx) in item.salesRows" :key="`sales-${idx}`">
<view class="sales-head">
<view class="sales-name">{{ detail.name }}</view>
<view class="sales-qty">{{ detail.qty }}{{ detail.unit }}</view>
</view>
<view class="sales-meta">
<text>{{ detail.groupTitle }}</text>
<text>{{ detail.makerAbb || '-' }}</text>
<text>{{ detail.spec || '-' }}</text>
</view>
</view>
</view>
<view v-else class="detail-empty">暂无销量数据</view>
</view>
</view>
<view v-if="isRejected(item.status)" class="record-actions">
<view class="resubmit-btn" @tap="handleResubmit(item)">重新提交</view>
</view>
</view>
</view>
</view>
<u-picker
:show="showChainPicker"
:columns="[chainOptions]"
:loading="chainLoading"
:defaultIndex="chainPickerIndex"
keyName="text"
@confirm="onChainConfirm"
@cancel="closeChainPicker"
@close="closeChainPicker"
/>
<u-datetime-picker
:show="showStartPicker"
:value="startPickerValue"
mode="date"
@confirm="e => onDateConfirm('start', e)"
@cancel="closeDatePicker('start')"
@close="closeDatePicker('start')"
/>
<u-datetime-picker
:show="showEndPicker"
:value="endPickerValue"
mode="date"
@confirm="e => onDateConfirm('end', e)"
@cancel="closeDatePicker('end')"
@close="closeDatePicker('end')"
/>
<Login ref="Login" @done="handleLoginDone"/>
</view>
</template>
<script>
import dayjs from "dayjs";
import AppNavbar from "@/components/AppNavbar.vue";
import Login from "@/components/Login.vue";
import {dictDataListByDictReq} from "@/api/common";
import {selfReportUserListReq} from "@/api/self-report";
import {getUserInfo} from "@/util/user";
export default {
components: {AppNavbar, Login},
data() {
return {
statusTabs: [
{label: '全部', value: 'all'},
{label: '待审核', value: 0},
{label: '已通过', value: 1},
{label: '已驳回', value: 2}
],
activeStatus: 'all',
records: [],
loading: false,
chainLoading: false,
showChainPicker: false,
showStartPicker: false,
showEndPicker: false,
chainOptions: [{id: '', text: '全部连锁'}],
filterForm: {
chainId: '',
merchantKeyword: '',
startDate: '',
endDate: ''
},
expandedMap: {}
}
},
computed: {
normalizedRecords() {
return (Array.isArray(this.records) ? this.records : []).map(item => this.normalizeRecord(item))
},
filteredRecords() {
const keyword = (this.filterForm.merchantKeyword || '').trim().toLowerCase()
const startTs = this.filterForm.startDate ? dayjs(`${this.filterForm.startDate} 00:00:00`).valueOf() : null
const endTs = this.filterForm.endDate ? dayjs(`${this.filterForm.endDate} 23:59:59`).valueOf() : null
return this.normalizedRecords.filter(item => {
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)) {
return false
}
if (keyword && !String(item.merchantName || '').toLowerCase().includes(keyword)) {
return false
}
if ((startTs || endTs) && !Number.isFinite(item.submitTimestamp)) {
return false
}
if (startTs && item.submitTimestamp < startTs) {
return false
}
if (endTs && item.submitTimestamp > endTs) {
return false
}
return true
})
},
chainLabel() {
const selected = this.chainOptions.find(item => String(item.id) === String(this.filterForm.chainId))
return (selected && selected.text) || '全部连锁'
},
chainPickerIndex() {
const idx = this.chainOptions.findIndex(item => String(item.id) === String(this.filterForm.chainId))
return [idx > -1 ? idx : 0]
},
startPickerValue() {
return this.filterForm.startDate ? dayjs(this.filterForm.startDate).valueOf() : Date.now()
},
endPickerValue() {
return this.filterForm.endDate ? dayjs(this.filterForm.endDate).valueOf() : Date.now()
}
},
async onShow() {
if (!this.ensureLogin()) return
await this.initPage()
},
async onPullDownRefresh() {
try {
if (this.ensureLogin()) {
await this.initPage()
}
} finally {
uni.stopPullDownRefresh()
}
},
methods: {
ensureLogin() {
if (getUserInfo().token) return true
this.$refs.Login.open()
return false
},
async handleLoginDone() {
await this.initPage()
},
async initPage() {
this.expandedMap = {}
await Promise.all([
this.fetchChainList(),
this.fetchList()
])
},
async fetchList() {
this.loading = true
try {
const {data} = await selfReportUserListReq({})
this.records = this.unwrapList(data)
} catch (error) {
this.records = []
uni.showToast({
title: '记录加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
async fetchChainList() {
this.chainLoading = true
try {
const {data} = await dictDataListByDictReq({dictId: 175})
const list = Array.isArray(data) ? data : this.unwrapList(data)
const options = [{id: '', text: '全部连锁'}]
list
.map((item, index) => this.normalizeChainDictOption(item, index))
.filter(item => item.id !== '' && item.id !== null && item.id !== undefined)
.forEach(item => options.push(item))
this.chainOptions = options
} catch (error) {
this.chainOptions = [{id: '', text: '全部连锁'}]
} finally {
this.chainLoading = false
}
},
normalizeRecord(item = {}) {
const createRaw = item.createTime || item.createdAt || item.date || ''
const clockPhotos = this.parseMaybeJson(item.clockPhotos, [])
const boothPhotos = this.parseMaybeJson(item.boothPhotos, [])
const receiptPhoto = this.parseMaybeJson(item.receiptPhoto, [])
const salesGroups = this.parseMaybeJson(item.salesGroups, [])
const summaryRaw = this.parseMaybeJson(item.summary, item.summary || '')
const summary = typeof summaryRaw === 'string' ? summaryRaw : JSON.stringify(summaryRaw || '')
return {
id: item.id,
merchantId: item.merchantId || item.merchant_id || '',
merchantName: item.merchantName || item.merchant_name || '',
chainName: item.chainName || item.chain_name || '-',
chainIdNormalized: item.chainId || item.chain_id || '',
activityTime: item.activityTime || item.activity_time || '',
date: item.date || '',
status: item.status,
rejectReason: item.rejectReason || item.reject_reason || '',
reward: item.reward,
rating: item.rating,
summary,
submitTime: this.formatDateTime(createRaw, createRaw || '-'),
submitTimestamp: this.parseDateToTimestamp(createRaw),
clockImages: this.normalizeMediaList(clockPhotos),
boothImages: this.normalizeMediaList(boothPhotos),
receiptImages: this.normalizeMediaList(receiptPhoto),
salesRows: this.buildSalesRows(salesGroups)
}
},
buildSalesRows(groups) {
const rows = []
;(Array.isArray(groups) ? groups : []).forEach((group, gIdx) => {
const items = Array.isArray(group && group.items) ? group.items : []
items.forEach((detail, iIdx) => {
rows.push({
key: `${gIdx}-${iIdx}`,
groupTitle: group && group.title ? group.title : `分组${gIdx + 1}`,
name: detail && detail.name ? detail.name : '-',
makerAbb: detail && detail.makerAbb ? detail.makerAbb : '',
spec: detail && detail.spec ? detail.spec : '',
qty: detail && detail.qty !== undefined && detail.qty !== null ? detail.qty : 0,
unit: detail && detail.unit ? detail.unit : ''
})
})
})
return rows
},
normalizeMediaList(value) {
const list = Array.isArray(value) ? value : []
return list.map(item => this.resolveMediaUrl(item)).filter(Boolean)
},
resolveMediaUrl(media) {
if (!media) return ''
if (typeof media === 'string') return media
return media.url || media.path || media.image || ''
},
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
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result']
for (let i = 0; i < keys.length; i++) {
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 []
},
normalizeChainDictOption(item, index) {
const text = item.dictDataName || item.label || item.name || item.title || item.text || `连锁${index + 1}`
const code = item.dictDataCode ?? item.code ?? item.value ?? ''
const value = item.dictDataValue ?? item.value ?? item.code ?? ''
const rawId = item.id ?? code ?? value ?? `chain_${index}`
return {
id: rawId,
text,
code,
value
}
},
matchesSelectedChain(chainId, chainName, selectedId) {
if (selectedId === '' || selectedId === null || selectedId === undefined) return true
const option = this.chainOptions.find(item => String(item.id) === String(selectedId))
if (!option) {
return String(chainId ?? '').trim() === String(selectedId).trim()
}
const selectedValues = [
option.id,
option.code,
option.value,
option.text
].map(value => String(value ?? '').trim()).filter(Boolean)
const itemValues = [
chainId,
chainName
].map(value => String(value ?? '').trim()).filter(Boolean)
return itemValues.some(value => selectedValues.includes(value))
},
parseDateToTimestamp(value) {
if (value === null || value === undefined || value === '') return NaN
const raw = String(value).trim()
if (!raw) return NaN
if (/^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw)
if (!Number.isFinite(num)) return NaN
return Math.abs(num) < 1e12 ? num * 1000 : num
}
const parsed = dayjs(raw)
return parsed.isValid() ? parsed.valueOf() : NaN
},
formatDateTime(value, fallback = '') {
if (value === null || value === undefined || value === '') return fallback
const raw = String(value).trim()
if (!raw) return fallback
if (/^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw)
if (!Number.isFinite(num)) return fallback
const parsedNumber = dayjs(Math.abs(num) < 1e12 ? num * 1000 : num)
return parsedNumber.isValid() ? parsedNumber.format('YYYY-MM-DD HH:mm:ss') : fallback
}
const parsed = dayjs(raw)
if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss')
return raw.replace('T', ' ')
},
formatPrice(value) {
const num = Number(value || 0)
return Number.isFinite(num) ? num.toFixed(2) : '0.00'
},
statusText(status) {
if (Number(status) === 0) return '待审核'
if (Number(status) === 1) return '已通过'
if (Number(status) === 2) return '已驳回'
return '-'
},
statusClass(status) {
if (Number(status) === 1) return 'success'
if (Number(status) === 2) return 'danger'
return 'pending'
},
isRejected(status) {
return Number(status) === 2
},
handleResubmit(item) {
if (!item || item.id === undefined || item.id === null) return
uni.navigateTo({
url: `/packageUser/self-report/create?id=${item.id}&mode=resubmit`
})
},
changeStatus(value) {
this.activeStatus = value
},
handleQuery() {
this.validateDateRange()
},
async handleReset() {
this.activeStatus = 'all'
this.filterForm = {
chainId: '',
merchantKeyword: '',
startDate: '',
endDate: ''
}
this.expandedMap = {}
},
openChainPicker() {
this.showChainPicker = true
},
closeChainPicker() {
this.showChainPicker = false
},
onChainConfirm(e) {
const selected = e.value && e.value.length ? e.value[0] : null
this.filterForm.chainId = selected ? (selected.id ?? '') : ''
this.showChainPicker = false
},
openDatePicker(type) {
if (type === 'start') {
this.showStartPicker = true
return
}
this.showEndPicker = true
},
closeDatePicker(type) {
if (type === 'start') {
this.showStartPicker = false
return
}
this.showEndPicker = false
},
onDateConfirm(type, event) {
const value = dayjs(event && event.value !== undefined ? event.value : Date.now()).format('YYYY-MM-DD')
const prevStartDate = this.filterForm.startDate
const prevEndDate = this.filterForm.endDate
if (type === 'start') {
this.filterForm.startDate = value
} else {
this.filterForm.endDate = value
}
if (!this.validateDateRange()) {
this.filterForm.startDate = prevStartDate
this.filterForm.endDate = prevEndDate
return
}
this.closeDatePicker(type)
},
validateDateRange() {
const {startDate, endDate} = this.filterForm
if (!startDate || !endDate) return true
const startTs = dayjs(`${startDate} 00:00:00`).valueOf()
const endTs = dayjs(`${endDate} 23:59:59`).valueOf()
if (startTs <= endTs) return true
uni.showToast({
title: '开始日期不能晚于结束日期',
icon: 'none'
})
return false
},
toggleExpanded(id) {
this.$set(this.expandedMap, id, !this.expandedMap[id])
},
isExpanded(id) {
return !!this.expandedMap[id]
},
preview(url, list) {
uni.previewImage({
current: url,
urls: Array.isArray(list) ? list : [url]
})
}
}
}
</script>
<style lang="scss" scoped>
.records-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40rpx;
}
.subsection {
margin: 18rpx 20rpx 0;
}
.block {
margin: 18rpx 20rpx 0;
}
.status-tabs {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
}
.tab {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: #f5f8ff;
color: #6b7280;
font-size: 24rpx;
border: 1rpx solid #e1e8f5;
}
.tab.active {
color: #ffffff;
background: #2fc6a7;
border-color: #2fc6a7;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.filter-row {
margin-top: 14rpx;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.filter-chip {
padding: 8rpx 18rpx;
border-radius: 999rpx;
border: 1rpx solid #2fc6a7;
color: #2fc6a7;
font-size: 22rpx;
background: #f3fffb;
}
.keyword-row {
margin-top: 14rpx;
display: flex;
align-items: center;
gap: 10rpx;
}
.keyword-input {
flex: 1;
height: 64rpx;
border-radius: 14rpx;
border: 1rpx solid #d1d5db;
background: #ffffff;
padding: 0 16rpx;
font-size: 24rpx;
color: #374151;
}
.keyword-placeholder {
color: #9ca3af;
}
.filter-btn {
min-width: 96rpx;
height: 64rpx;
padding: 0 20rpx;
border-radius: 14rpx;
border: 1rpx solid #d1d5db;
color: #6b7280;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.filter-btn.primary {
border-color: #2fc6a7;
background: #2fc6a7;
color: #ffffff;
}
.empty {
color: #6b7280;
text-align: center;
padding: 40rpx 0;
font-size: 26rpx;
}
.record-card {
margin-top: 16rpx;
padding: 22rpx;
border-radius: 22rpx;
background: #ffffff;
border: 1rpx solid #eef2f7;
box-shadow: 0 8rpx 20rpx rgba(37, 99, 235, 0.06);
}
.record-head {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.record-title {
color: #111827;
font-size: 30rpx;
font-weight: 700;
}
.record-sub {
margin-top: 8rpx;
color: #6b7280;
font-size: 24rpx;
}
.status-badge {
align-self: flex-start;
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
white-space: nowrap;
}
.status-badge.pending {
color: #b45309;
background: #fff7ed;
}
.status-badge.success {
color: #047857;
background: #ecfdf5;
}
.status-badge.danger {
color: #dc2626;
background: #fef2f2;
}
.record-grid {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.record-kv {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.record-label {
color: #9ca3af;
font-size: 22rpx;
}
.record-value {
color: #1f2937;
font-size: 24rpx;
word-break: break-all;
}
.text-red {
color: #dc2626;
}
.summary-line {
margin-top: 16rpx;
color: #4b5563;
font-size: 24rpx;
line-height: 1.5;
word-break: break-word;
}
.reject-line {
margin-top: 10rpx;
color: #dc2626;
font-size: 24rpx;
line-height: 1.5;
word-break: break-word;
}
.record-actions {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #eef2f7;
display: flex;
justify-content: flex-end;
}
.resubmit-btn {
min-width: 200rpx;
min-height: 72rpx;
padding: 0 32rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #2fc6a7, #1db896);
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 20rpx rgba(47, 198, 167, 0.22);
}
.record-toggle {
margin-top: 16rpx;
display: inline-flex;
align-items: center;
gap: 8rpx;
color: #2fc6a7;
font-size: 24rpx;
}
.record-detail {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #eef2f7;
}
.record-section {
margin-top: 16rpx;
}
.record-section:first-child {
margin-top: 0;
}
.record-section-title {
margin-bottom: 12rpx;
color: #111827;
font-size: 26rpx;
font-weight: 700;
}
.media-images {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.media-image {
width: 150rpx;
height: 150rpx;
border-radius: 14rpx;
background: #f3f4f6;
}
.detail-empty {
color: #9ca3af;
font-size: 24rpx;
}
.sales-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.sales-item {
padding: 16rpx;
border-radius: 16rpx;
background: #f9fafb;
}
.sales-head {
display: flex;
justify-content: space-between;
gap: 16rpx;
}
.sales-name {
color: #111827;
font-size: 26rpx;
font-weight: 700;
word-break: break-word;
}
.sales-qty {
color: #dc2626;
font-size: 24rpx;
white-space: nowrap;
}
.sales-meta {
margin-top: 8rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
color: #6b7280;
font-size: 22rpx;
}
</style>