20260929
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
<template>
|
||||
<view class="address-page">
|
||||
<view class="page-hero">
|
||||
<AppNavbar title="收货地址管理" show-back/>
|
||||
<view class="intro-card">
|
||||
<view class="card-title">
|
||||
<view class="title-bar"></view>
|
||||
<text>收货地址管理</text>
|
||||
</view>
|
||||
<text class="intro-text">请填写准确的收货信息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title-row">
|
||||
<view class="section-title">地址列表</view>
|
||||
<view class="primary-btn add-btn" @tap="openFormPopup">新增地址</view>
|
||||
</view>
|
||||
<view v-if="loading" class="empty">加载中...</view>
|
||||
<view v-else-if="addresses.length === 0" class="empty">暂无地址</view>
|
||||
<view v-else class="address-list">
|
||||
<view class="addr-item" v-for="item in addresses" :key="item.id">
|
||||
<view class="addr-head">
|
||||
<view class="addr-name">{{ item.name }} {{ maskPhone(item.phone) }}</view>
|
||||
<view v-if="Number(item.isDefault) === 1 || item.isDefault === true" class="chip">默认</view>
|
||||
</view>
|
||||
<view class="addr-detail">{{ item.fullAddress || buildFullAddress(item) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showFormPopup" class="form-popup-mask" @tap="closeFormPopup"></view>
|
||||
<view v-if="showFormPopup" class="form-popup card" @tap.stop>
|
||||
<view class="popup-head">
|
||||
<view class="section-title">新增/编辑地址</view>
|
||||
<view class="close-btn" @tap="closeFormPopup">关闭</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="label">姓名</view>
|
||||
<input v-model="form.name" class="input" placeholder="收货人姓名" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="label">手机号</view>
|
||||
<input v-model="form.phone" class="input" placeholder="11位手机号" type="number" maxlength="11" />
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="label">省市区</view>
|
||||
<view class="picker-value" :class="{ placeholder: !form.province }" @tap="openRegionPicker">
|
||||
{{ regionText }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="showRegionPicker" class="region-picker">
|
||||
<view class="region-picker-head">
|
||||
<view class="region-picker-title">选择省市区</view>
|
||||
<view class="region-picker-actions">
|
||||
<view class="region-picker-btn" @tap="cancelRegionPicker">取消</view>
|
||||
<view class="region-picker-btn primary" @tap="confirmRegionPicker">确定</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="areaLoading" class="region-loading">加载中...</view>
|
||||
<picker-view v-else class="picker-view" :value="areaIndex" @change="onAreaPickerChange">
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.provinces" :key="item.id" class="picker-item">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.cities" :key="item.id" class="picker-item">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.regions" :key="item.id" class="picker-item">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="label">详细地址</view>
|
||||
<input v-model="form.address" class="input" placeholder="街道门牌信息" />
|
||||
</view>
|
||||
<view class="form-row switch-row">
|
||||
<view class="label">默认地址</view>
|
||||
<switch :checked="form.isDefault" color="#2563eb" @change="onDefaultChange" />
|
||||
</view>
|
||||
<view class="primary-btn submit" @tap="submitAddress">保存地址</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {saveUserAddressReq, userAddressListReq} from "@/api/address";
|
||||
import {areaListByFidReq} from "@/api/common";
|
||||
import AppNavbar from "@/components/AppNavbar.vue";
|
||||
|
||||
export default {
|
||||
components: {AppNavbar},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
province: '',
|
||||
city: '',
|
||||
region: '',
|
||||
phone: '',
|
||||
name: '',
|
||||
address: '',
|
||||
isDefault: false
|
||||
},
|
||||
addresses: [],
|
||||
loading: false,
|
||||
showFormPopup: false,
|
||||
showRegionPicker: false,
|
||||
areaLoading: false,
|
||||
areaIndex: [0, 0, 0],
|
||||
areaColumns: {
|
||||
provinces: [],
|
||||
cities: [],
|
||||
regions: []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
regionValue() {
|
||||
if (!this.form.province) return []
|
||||
return [this.form.province, this.form.city, this.form.region]
|
||||
},
|
||||
regionText() {
|
||||
if (!this.form.province) return '请选择省市区'
|
||||
return `${this.form.province} ${this.form.city} ${this.form.region}`
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadAddressList()
|
||||
},
|
||||
methods: {
|
||||
onRegionChange(e) {
|
||||
const value = e.detail.value || []
|
||||
this.form.province = value[0] || ''
|
||||
this.form.city = value[1] || ''
|
||||
this.form.region = value[2] || ''
|
||||
},
|
||||
onDefaultChange(e) {
|
||||
this.form.isDefault = e.detail.value
|
||||
},
|
||||
normalizeAreaItem(item) {
|
||||
return {
|
||||
id: item.areaId || item.code || item.value || item.id || null,
|
||||
name: item.name || item.title || item.areaName || item.label || ''
|
||||
}
|
||||
},
|
||||
async fetchAreaListByFid(fid) {
|
||||
const {data} = await areaListByFidReq({fid})
|
||||
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
return list.map(this.normalizeAreaItem)
|
||||
},
|
||||
async initAreaColumns() {
|
||||
this.areaLoading = true
|
||||
try {
|
||||
const provinces = await this.fetchAreaListByFid(0)
|
||||
this.areaColumns.provinces = provinces
|
||||
const provinceIndex = Math.max(0, provinces.findIndex(item => item.name === this.form.province))
|
||||
const province = provinces[provinceIndex]
|
||||
const cities = province ? await this.fetchAreaListByFid(province.id) : []
|
||||
this.areaColumns.cities = cities
|
||||
const cityIndex = Math.max(0, cities.findIndex(item => item.name === this.form.city))
|
||||
const city = cities[cityIndex]
|
||||
const regions = city ? await this.fetchAreaListByFid(city.id) : []
|
||||
this.areaColumns.regions = regions
|
||||
const regionIndex = Math.max(0, regions.findIndex(item => item.name === this.form.region))
|
||||
this.areaIndex = [provinceIndex, cityIndex, regionIndex]
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '地区加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.areaLoading = false
|
||||
}
|
||||
},
|
||||
async updateCitiesByProvince(provinceIndex) {
|
||||
const province = this.areaColumns.provinces[provinceIndex]
|
||||
console.log(province)
|
||||
this.areaColumns.cities = province ? await this.fetchAreaListByFid(province.id) : []
|
||||
this.areaColumns.regions = []
|
||||
this.areaIndex = [provinceIndex, 0, 0]
|
||||
await this.updateRegionsByCity(provinceIndex, 0)
|
||||
},
|
||||
async updateRegionsByCity(provinceIndex, cityIndex) {
|
||||
const city = this.areaColumns.cities[cityIndex]
|
||||
const regions = city ? await this.fetchAreaListByFid(city.id) : []
|
||||
this.areaColumns.regions = regions
|
||||
this.areaIndex = [provinceIndex, cityIndex, 0]
|
||||
},
|
||||
async openRegionPicker() {
|
||||
this.showRegionPicker = true
|
||||
if (!this.areaColumns.provinces.length) {
|
||||
await this.initAreaColumns()
|
||||
}
|
||||
},
|
||||
cancelRegionPicker() {
|
||||
this.showRegionPicker = false
|
||||
},
|
||||
confirmRegionPicker() {
|
||||
const [pIndex, cIndex, rIndex] = this.areaIndex
|
||||
const province = this.areaColumns.provinces[pIndex]
|
||||
const city = this.areaColumns.cities[cIndex]
|
||||
const region = this.areaColumns.regions[rIndex]
|
||||
this.form.province = province ? province.name : ''
|
||||
this.form.city = city ? city.name : ''
|
||||
this.form.region = region ? region.name : ''
|
||||
this.showRegionPicker = false
|
||||
},
|
||||
async onAreaPickerChange(e) {
|
||||
const value = e.detail.value || [0, 0, 0]
|
||||
console.log(value)
|
||||
const [pIndex, cIndex] = value
|
||||
const [prevP, prevC] = this.areaIndex
|
||||
if (pIndex !== prevP) {
|
||||
await this.updateCitiesByProvince(pIndex)
|
||||
return
|
||||
}
|
||||
if (cIndex !== prevC) {
|
||||
await this.updateRegionsByCity(pIndex, cIndex)
|
||||
return
|
||||
}
|
||||
this.areaIndex = value
|
||||
},
|
||||
buildFullAddress(item) {
|
||||
const province = item.province || ''
|
||||
const city = item.city || ''
|
||||
const region = item.region || ''
|
||||
const address = item.address || ''
|
||||
return `${province}${city}${region}${address}`
|
||||
},
|
||||
maskPhone(phone) {
|
||||
const value = String(phone || '')
|
||||
if (value.length < 7) return value
|
||||
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
||||
},
|
||||
async loadAddressList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const {data} = await userAddressListReq()
|
||||
this.addresses = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '地址加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
openFormPopup() {
|
||||
this.showFormPopup = true
|
||||
},
|
||||
closeFormPopup() {
|
||||
this.showFormPopup = false
|
||||
},
|
||||
async submitAddress() {
|
||||
if (!this.form.name || !this.form.phone || !this.form.address || !this.form.province) {
|
||||
uni.showToast({
|
||||
title: '请完善收货信息',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
const fullAddress = `${this.form.province}${this.form.city}${this.form.region}${this.form.address}`
|
||||
const payload = {
|
||||
province: this.form.province,
|
||||
city: this.form.city,
|
||||
region: this.form.region,
|
||||
phone: this.form.phone,
|
||||
name: this.form.name,
|
||||
address: this.form.address,
|
||||
fullAddress: fullAddress,
|
||||
isDefault: this.form.isDefault ? 1 : 0
|
||||
}
|
||||
try {
|
||||
await saveUserAddressReq(payload)
|
||||
uni.showToast({
|
||||
title: '地址已保存',
|
||||
icon: 'success'
|
||||
})
|
||||
this.form.address = ''
|
||||
this.form.name = ''
|
||||
this.form.phone = ''
|
||||
this.form.province = ''
|
||||
this.form.city = ''
|
||||
this.form.region = ''
|
||||
this.form.isDefault = false
|
||||
this.showFormPopup = false
|
||||
await this.loadAddressList()
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.address-page {
|
||||
min-height: 100vh;
|
||||
//padding: 0 20rpx 40rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
//padding: 30rpx 6rpx 18rpx;
|
||||
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat
|
||||
top center / 100% auto;
|
||||
}
|
||||
|
||||
.page-navbar {
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
margin: 18rpx 20rpx 0;
|
||||
padding: 20rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 6rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #2fc6a7;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 18rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 6rpx 16rpx;
|
||||
height: 52rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 1rpx solid #edf1f7;
|
||||
}
|
||||
|
||||
.form-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-main);
|
||||
font-size: 26rpx;
|
||||
min-width: 140rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--text-main);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.picker-value.placeholder {
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 18rpx;
|
||||
padding: 14rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.addr-item {
|
||||
border: 1rpx dashed #dfe8f5;
|
||||
border-radius: 14rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
.addr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addr-name {
|
||||
color: var(--text-main);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addr-detail {
|
||||
margin-top: 6rpx;
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-sub);
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.region-picker {
|
||||
margin: 12rpx 0 8rpx;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #e7eef8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.region-picker-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10rpx 12rpx;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.region-picker-title {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.region-picker-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.region-picker-btn {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.region-picker-btn.primary {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.region-loading {
|
||||
padding: 16rpx 0;
|
||||
text-align: center;
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.form-popup-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.form-popup {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 40rpx;
|
||||
transform: translateX(-50%);
|
||||
width: 88%;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.popup-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="coupon-page">
|
||||
<view class="page-hero">
|
||||
<AppNavbar title="优惠券中心" show-back/>
|
||||
<view class="intro-card">
|
||||
<view class="card-title">
|
||||
<view class="title-bar"></view>
|
||||
<text>优惠券中心</text>
|
||||
</view>
|
||||
<text class="intro-text">领券大厅与我的优惠券</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card subsection">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:class="['tab', activeTab === tab.value ? 'active' : '']"
|
||||
@tap="changeTab(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title">{{ activeTab === 'lobby' ? '领券大厅' : '我的优惠券' }}</view>
|
||||
<view v-if="loading" class="empty">加载中...</view>
|
||||
<view v-else-if="currentCoupons.length === 0" class="empty">暂无优惠券</view>
|
||||
<view v-else class="coupon-list">
|
||||
<view class="coupon-card" v-for="coupon in currentCoupons" :key="coupon.id">
|
||||
<view class="coupon-main">
|
||||
<!-- <view class="coupon-amount">¥{{ coupon.amount }}</view>-->
|
||||
<view class="coupon-info">
|
||||
<view class="coupon-title">{{ coupon.title }}</view>
|
||||
<!-- <view class="coupon-meta">{{ coupon.desc }}</view>-->
|
||||
<view class="coupon-meta">
|
||||
有效期:{{ coupon.expireType === 1 ? coupon.expireEndDate : `领取后${coupon.expireDay}天过期` }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="activeTab === 'lobby'" class="coupon-action">
|
||||
<view class="primary-btn" @tap="receiveCoupon(coupon)">领取</view>
|
||||
</view>
|
||||
<view v-else class="coupon-status">{{ coupon.statusText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {couponListReq, userCouponAddReq, userCouponListReq} from "@/api/coupon";
|
||||
import AppNavbar from "@/components/AppNavbar.vue";
|
||||
|
||||
export default {
|
||||
components: {AppNavbar},
|
||||
data() {
|
||||
return {
|
||||
tabs: [
|
||||
{label: '领券大厅', value: 'lobby'},
|
||||
{label: '我的优惠券', value: 'mine'}
|
||||
],
|
||||
activeTab: 'lobby',
|
||||
lobbyCoupons: [],
|
||||
myCoupons: [],
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentCoupons() {
|
||||
return this.activeTab === 'lobby' ? this.lobbyCoupons : this.myCoupons
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.fetchCoupons()
|
||||
},
|
||||
methods: {
|
||||
changeTab(value) {
|
||||
this.activeTab = value
|
||||
this.fetchCoupons()
|
||||
},
|
||||
async fetchCoupons() {
|
||||
this.loading = true
|
||||
try {
|
||||
if (this.activeTab === 'lobby') {
|
||||
const {data} = await couponListReq()
|
||||
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
this.lobbyCoupons = list.map(this.normalizeCoupon)
|
||||
} else {
|
||||
const {data} = await userCouponListReq()
|
||||
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
this.myCoupons = list.map(this.normalizeUserCoupon)
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
normalizeCoupon(item) {
|
||||
const amount = Number(item.amount || item.faceValue || item.money || 0)
|
||||
return {
|
||||
id: item.id || item.couponId || item.coupon_id,
|
||||
amount: amount.toFixed(2),
|
||||
title: item.title || item.name || '优惠券',
|
||||
desc: item.desc || item.description || item.useRule || '无门槛使用',
|
||||
expire: item.expire || item.expireTime || item.endTime || '-',
|
||||
expireType: item.expireType,
|
||||
expireEndDate: item.expireEndDate,
|
||||
expireDay: item.expireDay,
|
||||
}
|
||||
},
|
||||
normalizeUserCoupon(item) {
|
||||
const amount = Number(item.amount || item.faceValue || item.money || 0)
|
||||
const statusValue = Number(item.used || item.status)
|
||||
let statusText = '未使用'
|
||||
if (statusValue === 1) statusText = '已使用'
|
||||
if (statusValue === 2) statusText = '已过期'
|
||||
return {
|
||||
id: item.id || item.userCouponId || item.couponId,
|
||||
amount: amount.toFixed(2),
|
||||
title: item.coupon.title || item.name || '优惠券',
|
||||
desc: item.desc || item.description || item.useRule || '无门槛使用',
|
||||
expire: item.expire || item.expireTime || item.endTime || '-',
|
||||
statusText,
|
||||
expireType: item.coupon.expireType,
|
||||
expireEndDate: item.coupon.expireEndDate,
|
||||
expireDay: item.coupon.expireDay,
|
||||
}
|
||||
},
|
||||
async receiveCoupon(coupon) {
|
||||
if (!coupon.id) {
|
||||
uni.showToast({
|
||||
title: '缺少优惠券ID',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
const res = await userCouponAddReq({
|
||||
couponId: coupon.id
|
||||
}).catch(e => {
|
||||
uni.showToast({
|
||||
title: e.message,
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
if (res) {
|
||||
uni.showToast({
|
||||
title: '领取成功',
|
||||
icon: 'success'
|
||||
})
|
||||
this.fetchCoupons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.coupon-page {
|
||||
min-height: 100vh;
|
||||
//padding: 0 20rpx 40rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
//padding: 30rpx 6rpx 18rpx;
|
||||
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat top center / 100% auto;
|
||||
}
|
||||
|
||||
.page-navbar {
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
margin: 18rpx 20rpx 0;
|
||||
padding: 20rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 6rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #2fc6a7;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin: 18rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f5f8ff;
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
border: 1rpx solid #e1e8f5;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #ffffff;
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 18rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.coupon-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.coupon-card {
|
||||
border: 1rpx solid #e7eef8;
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.coupon-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.coupon-amount {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #ff3b30;
|
||||
min-width: 110rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coupon-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.coupon-title {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.coupon-meta {
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.coupon-action {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.coupon-status {
|
||||
margin-top: 12rpx;
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-sub);
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<view class="points-page">
|
||||
<view class="page-hero">
|
||||
<AppNavbar title="我的积分" show-back/>
|
||||
<view class="intro-card">
|
||||
<view class="card-title">
|
||||
<view class="title-bar"></view>
|
||||
<text>我的积分</text>
|
||||
</view>
|
||||
<text class="intro-text">查看积分余额和积分明细记录。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card summary-card">
|
||||
<view class="summary-item">
|
||||
<view class="summary-value">{{ summary.currentPoints }}</view>
|
||||
<view class="summary-label">当前积分</view>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<view class="summary-value">{{ summary.totalEarned }}</view>
|
||||
<view class="summary-label">累计获得</view>
|
||||
</view>
|
||||
<!-- <view class="summary-item">-->
|
||||
<!-- <view class="summary-value">{{ summary.totalUsed }}</view>-->
|
||||
<!-- <view class="summary-label">累计消耗</view>-->
|
||||
<!-- </view>-->
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title">积分明细</view>
|
||||
<view v-if="loading && !records.length" class="empty">加载中...</view>
|
||||
<view v-else-if="!records.length" class="empty">暂无积分记录</view>
|
||||
<view v-else class="record-list">
|
||||
<view class="record-item" v-for="item in records" :key="item.id">
|
||||
<view class="record-main">
|
||||
<view class="record-title-row">
|
||||
<view class="record-title">{{ item.title }}</view>
|
||||
<view :class="['record-points', item.delta >= 0 ? 'record-points--plus' : 'record-points--minus']">
|
||||
{{ item.deltaText }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="record-meta">{{ dayjs(item.date).format('YYYY-MM-DD HH:mm:ss') }}</view>
|
||||
<view class="record-desc" v-if="item.remark">{{ item.remark }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list-status">{{ finished ? '没有更多了' : '上拉加载更多' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppNavbar from "@/components/AppNavbar.vue";
|
||||
import {userPointLogUserListReq} from "@/api/shop";
|
||||
import {userInfoReq} from "@/api/user";
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
components: {AppNavbar},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
finished: false,
|
||||
records: [],
|
||||
user: {},
|
||||
summary: {
|
||||
currentPoints: 0,
|
||||
totalEarned: 0,
|
||||
totalUsed: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.refreshData()
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.refreshData().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
onReachBottom() {
|
||||
if (this.loading || this.finished) return
|
||||
this.fetchPointLogs()
|
||||
},
|
||||
methods: {
|
||||
dayjs,
|
||||
async refreshData() {
|
||||
this.page = 1
|
||||
this.finished = false
|
||||
this.records = []
|
||||
await Promise.all([
|
||||
this.getUserData(),
|
||||
this.fetchPointLogs(true)
|
||||
])
|
||||
},
|
||||
async getUserData() {
|
||||
try {
|
||||
const {data} = await userInfoReq()
|
||||
this.user = data || {}
|
||||
this.summary.currentPoints = this.resolveSummaryPoints()
|
||||
} catch (error) {
|
||||
this.user = {}
|
||||
}
|
||||
},
|
||||
async fetchPointLogs(refresh = false) {
|
||||
this.loading = true
|
||||
try {
|
||||
const currentPage = refresh ? 1 : this.page
|
||||
const {data} = await userPointLogUserListReq({
|
||||
page: currentPage,
|
||||
limit: this.limit
|
||||
})
|
||||
const rawList = Array.isArray(data)
|
||||
? data
|
||||
: (data && Array.isArray(data.list) ? data.list : [])
|
||||
const normalizedList = rawList.map(this.normalizeRecord)
|
||||
this.records = refresh ? normalizedList : this.records.concat(normalizedList)
|
||||
this.page = currentPage + 1
|
||||
this.finished = !rawList.length || rawList.length < this.limit
|
||||
this.updateSummary(data)
|
||||
} catch (error) {
|
||||
if (refresh) {
|
||||
this.records = []
|
||||
}
|
||||
uni.showToast({
|
||||
title: '积分加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
updateSummary(payload) {
|
||||
const totalEarned = this.records
|
||||
.filter(item => item.delta > 0)
|
||||
.reduce((sum, item) => sum + item.delta, 0)
|
||||
const totalUsed = this.records
|
||||
.filter(item => item.delta < 0)
|
||||
.reduce((sum, item) => sum + Math.abs(item.delta), 0)
|
||||
this.summary = {
|
||||
currentPoints: this.resolveSummaryPoints(payload, this.records),
|
||||
totalEarned,
|
||||
totalUsed
|
||||
}
|
||||
},
|
||||
resolveSummaryPoints(payload = {}, records = this.records) {
|
||||
const candidates = [
|
||||
payload.currentPoints,
|
||||
payload.currentPoint,
|
||||
payload.points,
|
||||
payload.point,
|
||||
payload.totalPoints,
|
||||
payload.totalPoint,
|
||||
this.user.currentPoints,
|
||||
this.user.currentPoint,
|
||||
this.user.points,
|
||||
this.user.point,
|
||||
this.user.integral,
|
||||
this.user.score
|
||||
]
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const value = Number(candidates[i])
|
||||
if (Number.isFinite(value)) return value
|
||||
}
|
||||
return records.reduce((sum, item) => sum + item.delta, 0)
|
||||
},
|
||||
resolveDelta(item = {}) {
|
||||
const candidates = [
|
||||
item.changePoint,
|
||||
item.changePoints,
|
||||
item.changeNum,
|
||||
item.delta,
|
||||
item.num,
|
||||
item.amount,
|
||||
item.value,
|
||||
item.point,
|
||||
item.points,
|
||||
item.integral,
|
||||
item.score
|
||||
]
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const value = Number(candidates[i])
|
||||
if (Number.isFinite(value)) return value
|
||||
}
|
||||
return 0
|
||||
},
|
||||
normalizeRecord(item = {}) {
|
||||
const delta = this.resolveDelta(item)
|
||||
return {
|
||||
id: item.id || item.logId || item.userPointLogId || `${item.createTime || item.createdAt || ''}-${delta}`,
|
||||
title: item.title || item.typeName || item.bizTypeName || item.remark || '积分变动',
|
||||
remark: item.description || item.content || item.memo || '',
|
||||
date: item.createTime || item.createdAt || item.date || '-',
|
||||
delta,
|
||||
deltaText: `${delta > 0 ? '+' : ''}${delta}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.points-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat top center / 100% auto;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
margin: 18rpx 20rpx 0;
|
||||
padding: 20rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 6rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #2fc6a7;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 18rpx 20rpx 0;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 18rpx;
|
||||
box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 18rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f0fffb, #ffffff);
|
||||
border: 1rpx solid #ccfbf1;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #2fc6a7;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e5e7eb;
|
||||
}
|
||||
|
||||
.record-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.record-points {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.record-points--plus {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.record-points--minus {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.record-desc {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.list-status {
|
||||
padding: 36rpx 0 18rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1,893 @@
|
||||
<template>
|
||||
<view class="travel-page">
|
||||
<view class="page-hero">
|
||||
<AppNavbar title="差旅报销" show-back/>
|
||||
<view class="intro-card">
|
||||
<view class="card-title">
|
||||
<view class="title-bar"></view>
|
||||
<text>差旅报销</text>
|
||||
</view>
|
||||
<text class="intro-text">填写出差时间、出发地和目的地,上传凭证后进入审核。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title">新增报销</view>
|
||||
<view class="form">
|
||||
<view class="form-row">
|
||||
<text>开始时间</text>
|
||||
<view class="picker-value" @tap="openDatePicker('startDate')">{{ travelForm.startDate || '点击选择' }}</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>结束时间</text>
|
||||
<view class="picker-value" @tap="openDatePicker('endDate')">{{ travelForm.endDate || '点击选择' }}</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>出发地</text>
|
||||
<view class="picker-value" :class="{placeholder: !travelForm.fromProvince}" @tap="openRegionPicker('from')">
|
||||
{{ fromRegionText }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>目的地</text>
|
||||
<view class="picker-value" :class="{placeholder: !travelForm.toProvince}" @tap="openRegionPicker('to')">
|
||||
{{ toRegionText }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>交通费</text>
|
||||
<input type="number" v-model="travelForm.transportCosts" placeholder="元"/>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>住宿费</text>
|
||||
<input type="number" v-model="travelForm.liveCosts" placeholder="元"/>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text>其他费用</text>
|
||||
<input type="number" v-model="travelForm.otherCosts" placeholder="元"/>
|
||||
</view>
|
||||
<view class="form-row textarea-row">
|
||||
<text>备注</text>
|
||||
<textarea
|
||||
v-model="travelForm.remark"
|
||||
class="remark-input"
|
||||
maxlength="200"
|
||||
auto-height
|
||||
placeholder="请输入备注(选填)"
|
||||
/>
|
||||
</view>
|
||||
<view class="upload" @tap="uploadProof">
|
||||
<view>
|
||||
<view class="upload-title">出差凭证</view>
|
||||
<view class="upload-meta">{{ images.length ? '已上传,点击继续上传' : '点击上传照片' }}</view>
|
||||
</view>
|
||||
<view class="chip">必填</view>
|
||||
</view>
|
||||
<view v-if="images.length" class="proof-list">
|
||||
<view class="proof-item" v-for="(img, idx) in images" :key="`${img}-${idx}`">
|
||||
<image class="proof-image" :src="img" mode="aspectFill"/>
|
||||
<view class="proof-remove" @tap.stop="removeProof(idx)">x</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="primary-btn submit" @tap="submitTravel">提交报销</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card block">
|
||||
<view class="section-title">历史报销</view>
|
||||
<view v-if="loading" class="empty">加载中...</view>
|
||||
<view v-else-if="travelHistory.length === 0" class="empty">暂无记录</view>
|
||||
<view v-else>
|
||||
<view class="history" v-for="item in travelHistory" :key="item.id">
|
||||
<view class="history-head" @tap="toggleHistoryDetail(item.id)">
|
||||
<view class="history-main">
|
||||
<view class="history-title">{{ item.fromText }} → {{ item.toText }}</view>
|
||||
<view class="history-meta">{{ item.start }} 至 {{ item.end }}</view>
|
||||
</view>
|
||||
<view class="history-side">
|
||||
<view class="history-status" :class="{'pending': item.status === '审核中', 'reject': item.status === '已驳回'}">
|
||||
{{ item.status }}
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="24" color="#9ca3af" :class="['history-arrow', item.expanded ? 'open' : '']"/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.expanded" class="history-detail">
|
||||
<view class="history-meta">交通 ¥{{ item.traffic }} · 住宿 ¥{{ item.hotel }}</view>
|
||||
<view class="history-meta">其他 ¥{{ item.other }}</view>
|
||||
<view class="history-meta">备注:{{ item.remark || '-' }}</view>
|
||||
<view class="history-meta">凭证:</view>
|
||||
<view v-if="item.proofs && item.proofs.length" class="history-proof-list">
|
||||
<view class="history-proof-item" v-for="(proof, proofIdx) in item.proofs" :key="`${item.id}-proof-${proofIdx}`">
|
||||
<image
|
||||
class="history-proof-image"
|
||||
:src="proof"
|
||||
mode="aspectFill"
|
||||
@tap="previewHistoryProof(item.proofs, proofIdx)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="history-meta">-</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<u-datetime-picker
|
||||
:show="showStartPicker"
|
||||
:value="startPickerValue"
|
||||
mode="date"
|
||||
@confirm="e => onDateConfirm('startDate', e)"
|
||||
@cancel="closeDatePicker('startDate')"
|
||||
@close="closeDatePicker('startDate')"
|
||||
/>
|
||||
<u-datetime-picker
|
||||
:show="showEndPicker"
|
||||
:value="endPickerValue"
|
||||
mode="date"
|
||||
@confirm="e => onDateConfirm('endDate', e)"
|
||||
@cancel="closeDatePicker('endDate')"
|
||||
@close="closeDatePicker('endDate')"
|
||||
/>
|
||||
|
||||
<view v-if="showRegionPicker" class="form-popup-mask" @tap="cancelRegionPicker"></view>
|
||||
<view v-if="showRegionPicker" class="region-picker-popup card" @tap.stop>
|
||||
<view class="region-picker-head">
|
||||
<view class="region-picker-title">选择{{ activeRegionField === 'from' ? '出发地' : '目的地' }}</view>
|
||||
<view class="region-picker-actions">
|
||||
<view class="region-picker-btn" @tap="cancelRegionPicker">取消</view>
|
||||
<view class="region-picker-btn primary" @tap="confirmRegionPicker">确定</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="areaLoading" class="region-loading">加载中...</view>
|
||||
<picker-view v-else class="picker-view" :value="areaIndex" @change="onAreaPickerChange">
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.provinces" :key="item.id" class="picker-item">{{ item.name }}</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.cities" :key="item.id" class="picker-item">{{ item.name }}</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in areaColumns.regions" :key="item.id" class="picker-item">{{ item.name }}</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {reimburseAddReq, reimburseUserListReq} from "@/api/reimburse";
|
||||
import {areaListByFidReq} from "@/api/common";
|
||||
import {chooseImg} from "@/util";
|
||||
import dayjs from "dayjs";
|
||||
import AppNavbar from "@/components/AppNavbar.vue";
|
||||
|
||||
export default {
|
||||
components: {AppNavbar},
|
||||
data() {
|
||||
return {
|
||||
travelForm: this.buildForm(),
|
||||
images: [],
|
||||
travelHistory: [],
|
||||
loading: false,
|
||||
showStartPicker: false,
|
||||
showEndPicker: false,
|
||||
showRegionPicker: false,
|
||||
activeRegionField: 'from',
|
||||
areaLoading: false,
|
||||
areaIndex: [0, 0, 0],
|
||||
areaColumns: {
|
||||
provinces: [],
|
||||
cities: [],
|
||||
regions: []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
startPickerValue() {
|
||||
return this.travelForm.startDate || Date.now()
|
||||
},
|
||||
endPickerValue() {
|
||||
return this.travelForm.endDate || Date.now()
|
||||
},
|
||||
fromRegionText() {
|
||||
return this.composeRegionText(this.travelForm.fromProvince, this.travelForm.fromCity, this.travelForm.fromRegion) || '请选择出发地'
|
||||
},
|
||||
toRegionText() {
|
||||
return this.composeRegionText(this.travelForm.toProvince, this.travelForm.toCity, this.travelForm.toRegion) || '请选择目的地'
|
||||
}
|
||||
},
|
||||
async onShow() {
|
||||
await this.ensureDefaultProvince()
|
||||
await this.fetchHistory()
|
||||
},
|
||||
methods: {
|
||||
buildForm() {
|
||||
return {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
fromProvince: '',
|
||||
fromCity: '',
|
||||
fromRegion: '',
|
||||
toProvince: '',
|
||||
toCity: '',
|
||||
toRegion: '',
|
||||
transportCosts: '',
|
||||
liveCosts: '',
|
||||
otherCosts: '',
|
||||
remark: '',
|
||||
images: ''
|
||||
}
|
||||
},
|
||||
composeRegionText(province, city, region) {
|
||||
return [province, city, region].filter(Boolean).join(' ')
|
||||
},
|
||||
getFieldArea(field) {
|
||||
if (field === 'from') {
|
||||
return {
|
||||
province: this.travelForm.fromProvince,
|
||||
city: this.travelForm.fromCity,
|
||||
region: this.travelForm.fromRegion
|
||||
}
|
||||
}
|
||||
return {
|
||||
province: this.travelForm.toProvince,
|
||||
city: this.travelForm.toCity,
|
||||
region: this.travelForm.toRegion
|
||||
}
|
||||
},
|
||||
setFieldArea(field, province, city, region) {
|
||||
if (field === 'from') {
|
||||
this.travelForm.fromProvince = province || ''
|
||||
this.travelForm.fromCity = city || ''
|
||||
this.travelForm.fromRegion = region || ''
|
||||
return
|
||||
}
|
||||
this.travelForm.toProvince = province || ''
|
||||
this.travelForm.toCity = city || ''
|
||||
this.travelForm.toRegion = region || ''
|
||||
},
|
||||
normalizeAreaItem(item) {
|
||||
return {
|
||||
id: item.areaId || item.code || item.value || item.id || null,
|
||||
name: item.name || item.title || item.areaName || item.label || ''
|
||||
}
|
||||
},
|
||||
async fetchAreaListByFid(fid) {
|
||||
const {data} = await areaListByFidReq({fid})
|
||||
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
return list.map(this.normalizeAreaItem)
|
||||
},
|
||||
findGuangxiProvince(provinces = []) {
|
||||
return provinces.find(item => String(item.name || '').includes('广西')) || null
|
||||
},
|
||||
async buildDefaultArea(provinces = [], preferredProvinceName = '') {
|
||||
let province = null
|
||||
if (preferredProvinceName) {
|
||||
province = provinces.find(item => item.name === preferredProvinceName)
|
||||
}
|
||||
if (!province) {
|
||||
province = this.findGuangxiProvince(provinces) || provinces[0] || null
|
||||
}
|
||||
if (!province) {
|
||||
return {province: '', city: '', region: ''}
|
||||
}
|
||||
const cities = await this.fetchAreaListByFid(province.id)
|
||||
const city = cities[0] || null
|
||||
const regions = city ? await this.fetchAreaListByFid(city.id) : []
|
||||
const region = regions[0] || null
|
||||
return {
|
||||
province: province.name || '',
|
||||
city: city ? city.name : '',
|
||||
region: region ? region.name : ''
|
||||
}
|
||||
},
|
||||
async ensureDefaultProvince() {
|
||||
if (
|
||||
this.travelForm.fromProvince && this.travelForm.fromCity && this.travelForm.fromRegion &&
|
||||
this.travelForm.toProvince && this.travelForm.toCity && this.travelForm.toRegion
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const provinces = await this.fetchAreaListByFid(0)
|
||||
const fromDefault = await this.buildDefaultArea(provinces, this.travelForm.fromProvince)
|
||||
const toDefault = await this.buildDefaultArea(provinces, this.travelForm.toProvince)
|
||||
if (!this.travelForm.fromProvince) this.travelForm.fromProvince = fromDefault.province
|
||||
if (!this.travelForm.fromCity) this.travelForm.fromCity = fromDefault.city
|
||||
if (!this.travelForm.fromRegion) this.travelForm.fromRegion = fromDefault.region
|
||||
if (!this.travelForm.toProvince) this.travelForm.toProvince = toDefault.province
|
||||
if (!this.travelForm.toCity) this.travelForm.toCity = toDefault.city
|
||||
if (!this.travelForm.toRegion) this.travelForm.toRegion = toDefault.region
|
||||
} catch (err) {
|
||||
if (!this.travelForm.fromProvince) this.travelForm.fromProvince = '广西壮族自治区'
|
||||
if (!this.travelForm.fromCity) this.travelForm.fromCity = ''
|
||||
if (!this.travelForm.fromRegion) this.travelForm.fromRegion = ''
|
||||
if (!this.travelForm.toProvince) this.travelForm.toProvince = '广西壮族自治区'
|
||||
if (!this.travelForm.toCity) this.travelForm.toCity = ''
|
||||
if (!this.travelForm.toRegion) this.travelForm.toRegion = ''
|
||||
}
|
||||
},
|
||||
async initAreaColumns(field) {
|
||||
this.areaLoading = true
|
||||
try {
|
||||
const provinces = await this.fetchAreaListByFid(0)
|
||||
this.areaColumns.provinces = provinces
|
||||
const current = this.getFieldArea(field)
|
||||
const defaultProvince = this.findGuangxiProvince(provinces)
|
||||
let provinceIndex = provinces.findIndex(item => item.name === current.province)
|
||||
if (provinceIndex < 0 && current.province) {
|
||||
provinceIndex = provinces.findIndex(item => current.province.includes(item.name) || item.name.includes(current.province))
|
||||
}
|
||||
if (provinceIndex < 0 && defaultProvince) {
|
||||
provinceIndex = provinces.findIndex(item => item.id === defaultProvince.id)
|
||||
}
|
||||
if (provinceIndex < 0) provinceIndex = 0
|
||||
const province = provinces[provinceIndex]
|
||||
const cities = province ? await this.fetchAreaListByFid(province.id) : []
|
||||
this.areaColumns.cities = cities
|
||||
let cityIndex = cities.findIndex(item => item.name === current.city)
|
||||
if (cityIndex < 0) cityIndex = 0
|
||||
const city = cities[cityIndex]
|
||||
const regions = city ? await this.fetchAreaListByFid(city.id) : []
|
||||
this.areaColumns.regions = regions
|
||||
let regionIndex = regions.findIndex(item => item.name === current.region)
|
||||
if (regionIndex < 0) regionIndex = 0
|
||||
this.areaIndex = [provinceIndex, cityIndex, regionIndex]
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '地区加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.areaLoading = false
|
||||
}
|
||||
},
|
||||
async updateCitiesByProvince(provinceIndex) {
|
||||
const province = this.areaColumns.provinces[provinceIndex]
|
||||
this.areaColumns.cities = province ? await this.fetchAreaListByFid(province.id) : []
|
||||
this.areaColumns.regions = []
|
||||
this.areaIndex = [provinceIndex, 0, 0]
|
||||
await this.updateRegionsByCity(provinceIndex, 0)
|
||||
},
|
||||
async updateRegionsByCity(provinceIndex, cityIndex) {
|
||||
const city = this.areaColumns.cities[cityIndex]
|
||||
this.areaColumns.regions = city ? await this.fetchAreaListByFid(city.id) : []
|
||||
this.areaIndex = [provinceIndex, cityIndex, 0]
|
||||
},
|
||||
async openRegionPicker(field) {
|
||||
this.activeRegionField = field
|
||||
this.showRegionPicker = true
|
||||
await this.initAreaColumns(field)
|
||||
},
|
||||
cancelRegionPicker() {
|
||||
this.showRegionPicker = false
|
||||
},
|
||||
confirmRegionPicker() {
|
||||
const [pIndex, cIndex, rIndex] = this.areaIndex
|
||||
const province = this.areaColumns.provinces[pIndex]
|
||||
const city = this.areaColumns.cities[cIndex]
|
||||
const region = this.areaColumns.regions[rIndex]
|
||||
this.setFieldArea(this.activeRegionField, province ? province.name : '', city ? city.name : '', region ? region.name : '')
|
||||
this.showRegionPicker = false
|
||||
},
|
||||
async onAreaPickerChange(e) {
|
||||
const value = e.detail.value || [0, 0, 0]
|
||||
const [pIndex, cIndex] = value
|
||||
const [prevP, prevC] = this.areaIndex
|
||||
if (pIndex !== prevP) {
|
||||
await this.updateCitiesByProvince(pIndex)
|
||||
return
|
||||
}
|
||||
if (cIndex !== prevC) {
|
||||
await this.updateRegionsByCity(pIndex, cIndex)
|
||||
return
|
||||
}
|
||||
this.areaIndex = value
|
||||
},
|
||||
async uploadProof() {
|
||||
const remain = 9 - this.images.length
|
||||
if (remain <= 0) return
|
||||
const res = await chooseImg(remain)
|
||||
this.images = [
|
||||
...this.images,
|
||||
...res.map(i => i.path)
|
||||
]
|
||||
},
|
||||
removeProof(idx) {
|
||||
this.images.splice(idx, 1)
|
||||
},
|
||||
async submitTravel() {
|
||||
if (
|
||||
!this.travelForm.startDate ||
|
||||
!this.travelForm.endDate ||
|
||||
!this.travelForm.fromProvince || !this.travelForm.fromCity || !this.travelForm.fromRegion ||
|
||||
!this.travelForm.toProvince || !this.travelForm.toCity || !this.travelForm.toRegion ||
|
||||
!this.images.length
|
||||
) {
|
||||
uni.showToast({
|
||||
title: '请填写必填项并上传凭证',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
const start = dayjs(this.travelForm.startDate)
|
||||
const end = dayjs(this.travelForm.endDate)
|
||||
if (start.isValid() && end.isValid() && end.isBefore(start, 'day')) {
|
||||
uni.showToast({
|
||||
title: '结束时间不能早于开始时间',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
const fromText = this.composeRegionText(this.travelForm.fromProvince, this.travelForm.fromCity, this.travelForm.fromRegion)
|
||||
const toText = this.composeRegionText(this.travelForm.toProvince, this.travelForm.toCity, this.travelForm.toRegion)
|
||||
try {
|
||||
await reimburseAddReq({
|
||||
startDate: this.travelForm.startDate,
|
||||
endDate: this.travelForm.endDate,
|
||||
fromProvince: this.travelForm.fromProvince,
|
||||
fromCity: this.travelForm.fromCity,
|
||||
fromRegion: this.travelForm.fromRegion,
|
||||
departureProvince: this.travelForm.fromProvince,
|
||||
departureCity: this.travelForm.fromCity,
|
||||
departureRegion: this.travelForm.fromRegion,
|
||||
departure: fromText,
|
||||
toProvince: this.travelForm.toProvince,
|
||||
toCity: this.travelForm.toCity,
|
||||
toRegion: this.travelForm.toRegion,
|
||||
destinationProvince: this.travelForm.toProvince,
|
||||
destinationCity: this.travelForm.toCity,
|
||||
destinationRegion: this.travelForm.toRegion,
|
||||
destination: toText,
|
||||
city: toText,
|
||||
transportCosts: this.travelForm.transportCosts,
|
||||
liveCosts: this.travelForm.liveCosts,
|
||||
otherCosts: this.travelForm.otherCosts,
|
||||
remark: this.travelForm.remark,
|
||||
images: JSON.stringify(this.images)
|
||||
})
|
||||
uni.showToast({
|
||||
title: '报销已提交审核',
|
||||
icon: 'success'
|
||||
})
|
||||
const keepFromProvince = this.travelForm.fromProvince
|
||||
const keepFromCity = this.travelForm.fromCity
|
||||
const keepFromRegion = this.travelForm.fromRegion
|
||||
const keepToProvince = this.travelForm.toProvince
|
||||
const keepToCity = this.travelForm.toCity
|
||||
const keepToRegion = this.travelForm.toRegion
|
||||
this.travelForm = this.buildForm()
|
||||
this.travelForm.fromProvince = keepFromProvince
|
||||
this.travelForm.fromCity = keepFromCity
|
||||
this.travelForm.fromRegion = keepFromRegion
|
||||
this.travelForm.toProvince = keepToProvince
|
||||
this.travelForm.toCity = keepToCity
|
||||
this.travelForm.toRegion = keepToRegion
|
||||
this.images = []
|
||||
await this.fetchHistory()
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '提交失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
async fetchHistory() {
|
||||
this.loading = true
|
||||
try {
|
||||
const {data} = await reimburseUserListReq()
|
||||
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
|
||||
this.travelHistory = list.map(this.normalizeHistory)
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
normalizeHistory(item) {
|
||||
const statusValue = Number(item.status)
|
||||
let statusLabel = '待审核'
|
||||
if (statusValue === 0) statusLabel = '审核中'
|
||||
if (statusValue === 1) statusLabel = '已通过'
|
||||
if (statusValue === 2) statusLabel = '已驳回'
|
||||
const fromProvince = item.fromProvince || item.departureProvince || item.startProvince || ''
|
||||
const fromCity = item.fromCity || item.departureCity || item.startCity || ''
|
||||
const fromRegion = item.fromRegion || item.departureRegion || item.startRegion || ''
|
||||
const toProvince = item.toProvince || item.destinationProvince || ''
|
||||
const toCity = item.toCity || item.destinationCity || ''
|
||||
const toRegion = item.toRegion || item.destinationRegion || ''
|
||||
const fromText = this.composeRegionText(fromProvince, fromCity, fromRegion) || item.departure || item.from || '-'
|
||||
const toText = this.composeRegionText(toProvince, toCity, toRegion) || item.city || item.destination || '-'
|
||||
const proofs = this.parseHistoryProofs(item.images || item.image || item.proof || item.proofs || item.receiptPhoto || item.receiptPhotos)
|
||||
return {
|
||||
id: item.id,
|
||||
start: item.startDate || item.beginDate || '',
|
||||
end: item.endDate || '',
|
||||
fromText,
|
||||
toText,
|
||||
traffic: item.transportCosts || 0,
|
||||
hotel: item.liveCosts || 0,
|
||||
other: item.otherCosts || 0,
|
||||
remark: item.remark || item.note || item.memo || '',
|
||||
proofs,
|
||||
status: statusLabel,
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
parseHistoryProofs(value) {
|
||||
if (value === null || value === undefined || value === '') return []
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (!item) return ''
|
||||
if (typeof item === 'string') return item
|
||||
return item.url || item.path || item.image || item.img || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const url = value.url || value.path || value.image || value.img
|
||||
return url ? [url] : []
|
||||
}
|
||||
const text = String(value).trim()
|
||||
if (!text) return []
|
||||
if (text.startsWith('[') || text.startsWith('{')) {
|
||||
try {
|
||||
return this.parseHistoryProofs(JSON.parse(text))
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
if (text.includes(',')) {
|
||||
return text.split(',').map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
return [text]
|
||||
},
|
||||
previewHistoryProof(list = [], index = 0) {
|
||||
if (!Array.isArray(list) || list.length === 0) return
|
||||
uni.previewImage({
|
||||
urls: list,
|
||||
current: list[index] || list[0]
|
||||
})
|
||||
},
|
||||
toggleHistoryDetail(id) {
|
||||
this.travelHistory = this.travelHistory.map(item => {
|
||||
if (item.id !== id) return item
|
||||
return {
|
||||
...item,
|
||||
expanded: !item.expanded
|
||||
}
|
||||
})
|
||||
},
|
||||
openDatePicker(field) {
|
||||
if (field === 'startDate') this.showStartPicker = true
|
||||
if (field === 'endDate') this.showEndPicker = true
|
||||
},
|
||||
closeDatePicker(field) {
|
||||
if (field === 'startDate') this.showStartPicker = false
|
||||
if (field === 'endDate') this.showEndPicker = false
|
||||
},
|
||||
onDateConfirm(field, e) {
|
||||
const value = dayjs(e.value).format('YYYY-MM-DD')
|
||||
this.travelForm[field] = value
|
||||
this.closeDatePicker(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.travel-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat
|
||||
top center / 100% auto;
|
||||
}
|
||||
|
||||
.page-navbar {
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
margin: 18rpx 20rpx 0;
|
||||
padding: 20rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 6rpx;
|
||||
height: 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #2fc6a7;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 18rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10rpx 0;
|
||||
border-bottom: 1rpx solid #edf1f7;
|
||||
}
|
||||
|
||||
.form-row input {
|
||||
text-align: right;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.picker-value.placeholder {
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.textarea-row {
|
||||
align-items: flex-start;
|
||||
padding-top: 14rpx;
|
||||
}
|
||||
|
||||
.remark-input {
|
||||
flex: 1;
|
||||
min-height: 100rpx;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.upload {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 1rpx solid #edf1f7;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upload-meta {
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.proof-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.proof-item {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proof-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.proof-remove {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history {
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 1rpx solid #edf1f7;
|
||||
}
|
||||
|
||||
.history:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.history-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
margin: 8rpx 0;
|
||||
}
|
||||
|
||||
.history-status.pending {
|
||||
color: #ff8354;
|
||||
}
|
||||
|
||||
.history-status.reject {
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
.history-arrow {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.history-arrow.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.history-detail {
|
||||
margin-top: 8rpx;
|
||||
padding: 8rpx 10rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.history-proof-list {
|
||||
margin-top: 8rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.history-proof-item {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 10rpx;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.history-proof-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-sub);
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.form-popup-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.region-picker-popup {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 40rpx;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100vw - 40rpx);
|
||||
max-height: 80vh;
|
||||
z-index: 31;
|
||||
border-radius: 18rpx;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.region-picker-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10rpx 12rpx;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.region-picker-title {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.region-picker-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.region-picker-btn {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.region-picker-btn.primary {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.region-loading {
|
||||
padding: 16rpx 0;
|
||||
text-align: center;
|
||||
color: var(--text-sub);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.picker-view {
|
||||
height: 360rpx;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: var(--text-main);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user