This commit is contained in:
2026-09-29 15:17:13 +08:00
commit 69e6ed8c59
113 changed files with 36564 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+833
View File
@@ -0,0 +1,833 @@
<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 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>
</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,
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'
},
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;
}
.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>