Files
pingxiangcard/src/packages/user/pages/reserve/list.vue
T

798 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<!-- Tab 筛选栏 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view
v-for="tab in tabs"
:key="tab.value"
class="tab-item"
:class="{ 'tab-item--active': activeTab === tab.value }"
@tap="activeTab = tab.value"
>
<text class="tab-text">{{ tab.label }}</text>
<view v-if="activeTab === tab.value" class="tab-underline" />
</view>
</view>
</scroll-view>
<!-- 列表区域 -->
<scroll-view
scroll-y
class="list-area"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- 空状态 -->
<view v-if="filteredRecords.length === 0" class="empty-state">
<view class="empty-icon">
<view class="empty-cal" />
<view class="empty-cal__line" />
</view>
<text class="empty-text">暂无预约记录,快去预约景区</text>
<view class="empty-btn" @tap="goReserve">
<text class="empty-btn__text">去预约</text>
</view>
</view>
<!-- 记录列表 -->
<view v-else class="record-list">
<view
v-for="r in filteredRecords"
:key="r.id"
class="record-card"
>
<!-- 头部 -->
<view class="record-header">
<text class="record-name">{{ r.scenicName }}</text>
<view class="status-tag" :class="`status-tag--${r.status}`">
<text class="status-tag__text">{{ statusLabel(r.status) }}</text>
</view>
</view>
<!-- 卡种+卡号 -->
<view class="record-card-info">
<text class="record-card-type">{{ r.cardType }}</text>
<text class="record-card-no">卡号:{{ r.cardNo }}</text>
</view>
<!-- 预约入园日期 -->
<view class="record-info">
<text class="record-date">预约入园日期:<text class="record-date--value">{{ formatDateOnly(r.date) }}</text></text>
</view>
<!-- 操作 -->
<view class="record-actions">
<view
v-if="r.canCancel && r.status !== 'cancelled'"
class="btn-sm btn-sm--outline"
@tap="showCancelModal(r)"
>
<text class="btn-sm__text--outline">取消预约</text>
</view>
<view
v-if="r.showEntryCode && r.status !== 'cancelled'"
class="btn-sm btn-sm--outline"
@tap="showCode(r)"
>
<text class="btn-sm__text--outline">查看入园码</text>
</view>
<view
class="btn-sm btn-sm--outline"
@tap="viewDetail(r)"
>
<text class="btn-sm__text--outline">查看详情</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="filteredRecords.length > 0" class="load-more">
<text v-if="loading" class="load-more__text">加载中...</text>
<text v-else-if="noMore" class="load-more__text">没有更多记录了</text>
</view>
</view>
</scroll-view>
<!-- 入园码弹窗 -->
<uni-popup ref="codePopup" type="center" :mask-click="true" @close="closeCode">
<view class="code-modal">
<text class="code-modal__title">入园码</text>
<view class="qr-box">
<image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="widthFix" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
<text class="code-modal__countdown">{{ countdown }}秒后自动刷新</text>
</view>
</uni-popup>
<!-- 取消预约弹窗 -->
<uni-popup ref="cancelPopup" type="center" :mask-click="true">
<view class="cancel-modal">
<text class="cancel-modal__title">取消预约</text>
<view class="cancel-modal__body">
<text class="cancel-modal__text">确定取消 {{ formatReserveDate(cancelRecord?.date) }}{{ cancelRecord?.scenicName }} 的预约吗?</text>
</view>
<view class="cancel-modal__actions">
<view class="btn-modal btn-modal--outline" @tap="cancelPopup?.close()">
<text class="btn-modal__text--outline">再想想</text>
</view>
<view class="btn-modal btn-modal--primary" @tap="confirmCancel">
<text class="btn-modal__text--primary">确认取消</text>
</view>
</view>
</view>
</uni-popup>
<!-- 详情弹窗 -->
<uni-popup ref="detailPopup" type="center" :mask-click="true">
<view class="detail-sheet">
<view class="sheet-header">
<text class="sheet-title">使用详情</text>
<view class="sheet-close" @tap="detailPopup?.close()">
<view class="sheet-close__icon" />
</view>
</view>
<scroll-view scroll-y class="sheet-body">
<view class="sheet-row">
<text class="sheet-label">预约景区</text>
<text class="sheet-value">{{ detailRecord?.scenicName }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约入园日期</text>
<text class="sheet-value sheet-value--date">{{ formatDateOnly(detailRecord?.date) }}</text>
</view>
<view v-if="detailRecord?.session" class="sheet-row">
<text class="sheet-label">预约场次</text>
<text class="sheet-value">{{ detailRecord.session }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约人</text>
<text class="sheet-value">{{ detailRecord?.userName }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约状态</text>
<text class="sheet-value">{{ detailStatusLabel(detailRecord?.status || '') }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约申请时间</text>
<text class="sheet-value">{{ detailRecord?.applyTime }}</text>
</view>
<view v-if="detailRecord?.verifyTime" class="sheet-row">
<text class="sheet-label">核销时间</text>
<text class="sheet-value">{{ detailRecord.verifyTime }}</text>
</view>
<view v-if="detailRecord?.cancelTime" class="sheet-row">
<text class="sheet-label">取消时间</text>
<text class="sheet-value">{{ detailRecord.cancelTime }}</text>
</view>
<!-- 人脸图片 -->
<view v-if="detailRecord?.photo" class="sheet-photo">
<text class="sheet-label">人脸图片</text>
<image class="sheet-photo-avatar" :src="detailRecord.photo" mode="aspectFill" @tap="previewPhoto(detailRecord.photo)" />
</view>
<!-- 核销人脸照片 -->
<view v-if="detailRecord?.verifyPhoto" class="sheet-photo">
<text class="sheet-label">核销照片</text>
<image class="sheet-photo-avatar" :src="detailRecord.verifyPhoto" mode="aspectFill" @tap="previewPhoto(detailRecord.verifyPhoto)" />
</view>
<view v-if="detailRecord?.status !== 'used' && detailRecord?.cardInstruction" class="sheet-tip">
<text class="sheet-tip__text">{{ detailRecord.cardInstruction }}</text>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type RecordStatus = 'pending' | 'cancelled' | 'used'
interface ReserveRecord {
id: string
scenicName: string
date: string
session?: string
status: RecordStatus
userName: string
maskedId: string
cardType: string
cardNo: string
applyTime: string
cancelTime?: string
verifyTime?: string
verifyPhoto?: string
photo?: string
cardInstruction?: string
cardModelId?: string
canCancel?: boolean
showEntryCode?: boolean
}
const tabs = [
{ label: '全部', value: '' },
{ label: '待使用', value: 'pending' },
{ label: '已入园', value: 'used' },
{ label: '已取消', value: 'cancelled' },
]
const activeTab = ref('')
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const pageNo = ref(1)
const pageSize = 10
const codePopup = ref<any>(null)
const cancelPopup = ref<any>(null)
const detailPopup = ref<any>(null)
const codeSrc = ref('')
const detailRecord = ref<ReserveRecord | null>(null)
const cancelRecord = ref<ReserveRecord | null>(null)
const countdown = ref(20)
let refreshTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
clearTimer()
})
const records = ref<ReserveRecord[]>([])
const filteredRecords = computed(() => records.value)
function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待使用', cancelled: '已取消', used: '已入园' }
return map[status] || status
}
function detailStatusLabel(status: number | string) {
const map: Record<string, string> = { '1': '预约成功', '2': '预约失败', '3': '已取消', '4': '已入园' }
return map[String(status)] || String(status)
}
async function fetchRecords(append = false) {
if (loading.value) return
loading.value = true
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/queryUsageRecordList', {
phone,
status: activeTab.value,
pageNo: pageNo.value,
pageSize,
})
const list = (res.list || res.records || []).map((item: any) => ({
id: item.id || '',
scenicName: item.scenicName || item.resourceName || '',
date: item.appTime || item.date || '',
session: item.sessionTime || item.session || '',
status: item.status || 'pending',
userName: item.name || item.userName || '',
maskedId: item.identityNo ? `${item.identityNo.slice(0, 6)}****${item.identityNo.slice(-4)}` : '',
cardType: item.cardModelName || item.cardType || '',
cardNo: item.cardNum || item.cardNo || '',
applyTime: item.createTime || item.applyTime || '',
cancelTime: item.cancelTime || '',
verifyTime: item.verifyTime || '',
verifyPhoto: item.verifyPhoto || '',
photo: item.photo || '',
cardInstruction: item.cardInstruction || '',
cardModelId: item.cardModelId || item.thirdId || '',
canCancel: !!item.canCancel,
showEntryCode: !!item.showEntryCode,
}))
if (append) {
records.value.push(...list)
} else {
records.value = list
}
noMore.value = list.length < pageSize
} catch (_e) {
records.value = []
noMore.value = true
} finally {
loading.value = false
}
}
onLoad(() => {
fetchRecords()
})
watch(activeTab, () => {
pageNo.value = 1
fetchRecords()
})
/** 将预约日期(如 2026-08-05)格式化为「2026 年 8 月 5 日」 */
function formatReserveDate(date?: string): string {
const m = (date || '').match(/(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})/)
if (!m) return date || ''
return `${m[1]} 年 ${Number(m[2])} 月 ${Number(m[3])} 日`
}
/** 日期去除时分秒(如 2026-08-05 10:00:00 → 2026-08-05) */
function formatDateOnly(date?: string): string {
if (!date) return ''
return date.split(' ')[0]
}
function showCancelModal(r: ReserveRecord) {
cancelRecord.value = r
cancelPopup.value?.open()
}
async function confirmCancel() {
cancelPopup.value?.close()
if (cancelRecord.value) {
try {
await post<any>(`${BASE_URL}/zhy-api/cardBag/cancelAppoint`, {
appointId: cancelRecord.value.id,
channel: 'Channel_SZH',
})
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
if (idx !== -1) {
records.value[idx].status = 'cancelled'
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
uni.showToast({ title: '已取消预约', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
}
}
}
async function showCode(r: ReserveRecord) {
codeSrc.value = ''
currentRecord.value = r
await fetchCode(r)
codePopup.value?.open()
startTimer()
}
function closeCode() {
clearTimer()
}
function clearTimer() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startTimer() {
clearTimer()
countdown.value = 20
const id = currentRecord.value?.id
if (!id) return
refreshTimer = setInterval(() => fetchCode(currentRecord.value!), 20000)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) countdown.value = 20
}, 1000)
}
const currentRecord = ref<ReserveRecord | null>(null)
async function fetchCode(r: ReserveRecord) {
countdown.value = 20
try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`)
if (res.qrType == 1) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
} else if (res.qrType == 2) {
codeSrc.value = res.qrUrl
} else {
await fetchQrByCard(r)
}
} catch (_e) {
await fetchQrByCard(r)
}
}
async function fetchQrByCard(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
cardNum: r.cardNo,
channel: 'Channel_SZH',
mobile: phone,
scenicId: '',
thirdId: r.cardModelId,
})
if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
} else {
uni.showToast({ icon: 'none', title: '暂无核销码' })
}
} catch (_e) {
uni.showToast({ icon: 'none', title: '获取入园码失败' })
}
}
async function viewDetail(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId: r.id })
if (res) {
detailRecord.value = {
id: res.id || r.id,
scenicName: res.resourceName || r.scenicName,
date: res.appTime || r.date,
session: res.sessionTime || r.session,
status: res.status || r.status || 'pending',
userName: res.name || r.userName,
maskedId: res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : r.maskedId,
cardType: res.cardModelName || r.cardType,
cardNo: res.cardNum || r.cardNo,
applyTime: res.createTime || r.applyTime,
cancelTime: res.cancelTime || r.cancelTime,
verifyTime: res.verifyTime || r.verifyTime,
verifyPhoto: res.verifyPhoto || r.verifyPhoto,
photo: res.photo || r.photo,
cardInstruction: res.cardInstruction || r.cardInstruction || '',
cardModelId: res.cardModelId || r.cardModelId || '',
}
}
} catch (_e) {
detailRecord.value = r
}
detailPopup.value?.open()
}
async function onRefresh() {
refreshing.value = true
pageNo.value = 1
await fetchRecords()
refreshing.value = false
}
async function onLoadMore() {
if (loading.value || noMore.value) return
pageNo.value++
await fetchRecords(true)
}
function goReserve() {
uni.navigateTo({ url: '/packages/content/pages/scenic-list' })
}
function previewPhoto(url: string) {
uni.previewImage({ urls: [url] })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-background;
font-family: $font-family-body;
}
/* ===== Tab ===== */
.tab-bar {
white-space: nowrap;
background: $color-card;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.tab-inner {
display: flex;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 16rpx;
position: relative;
min-width: 120rpx;
}
.tab-text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
.tab-item--active .tab-text {
color: $uv-primary;
font-weight: $font-weight-bold;
}
.tab-underline {
width: 32rpx;
height: 4rpx;
border-radius: 2rpx;
background: $uv-primary;
margin-top: 6rpx;
}
/* ===== List ===== */
.list-area {
height: calc(100vh - 88rpx);
}
.record-list {
padding: 0 24rpx;
}
/* ===== Record Card ===== */
.record-card {
background: $color-card;
border-radius: $radius-lg;
padding: 28rpx;
margin-top: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.record-name {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $uv-main-color;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 16rpx;
}
.status-tag {
padding: 4rpx 14rpx;
border-radius: 8rpx;
flex-shrink: 0;
&--pending { background: #e8f3ff; }
&--cancelled { background: $uv-bg-color; }
&--used { background: #f0fff4; }
}
.status-tag__text {
font-family: $font-family-body;
font-size: 22rpx;
font-weight: $font-weight-medium;
}
.status-tag--pending .status-tag__text { color: $uv-primary; }
.status-tag--cancelled .status-tag__text { color: $color-status-cancel; }
.status-tag--used .status-tag__text { color: $color-status-success; }
.record-card-info {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
.record-card-type {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-primary;
background: rgba(60, 156, 255, 0.06);
padding: 2rpx 12rpx;
border-radius: 6rpx;
}
.record-card-no {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-placeholder-color;
}
.record-info {
padding-bottom: 12rpx;
border-bottom: 1rpx solid $uv-border-color;
margin-bottom: 12rpx;
}
.record-date {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.record-date--value {
color: $uv-error;
font-weight: $font-weight-medium;
}
.record-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.btn-sm {
height: 52rpx;
padding: 0 24rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
&--outline {
border: 2rpx solid $uv-border-color;
&:active { transform: scale(0.97); background: $uv-bg-color; }
}
}
.btn-sm__text--outline {
font-family: $font-family-body;
font-size: 22rpx;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
/* ===== Empty ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 48rpx 0;
}
.empty-icon { width: 120rpx; height: 120rpx; position: relative; margin-bottom: 32rpx; }
.empty-cal { width: 80rpx; height: 72rpx; border: 4rpx solid $uv-disabled-color; border-radius: 8rpx; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); }
.empty-cal__line { width: 80rpx; height: 4rpx; background: $uv-disabled-color; position: absolute; bottom: 48rpx; left: 50%; transform: translateX(-50%); }
.empty-text { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-placeholder-color; margin-bottom: 32rpx; }
.empty-btn {
height: 72rpx; padding: 0 56rpx; border-radius: 36rpx; background: $uv-primary;
display: flex; align-items: center; justify-content: center;
&:active { transform: scale(0.97); }
}
.empty-btn__text { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-bold; color: #ffffff; }
/* ===== Load More ===== */
.load-more { text-align: center; padding: 16rpx 0 120rpx; }
.load-more__text { font-family: $font-family-body; font-size: 22rpx; color: $uv-placeholder-color; }
/* ===== Code Modal ===== */
.code-modal {
width: 80vw; max-width: 700rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx;
display: flex; flex-direction: column; align-items: center;
}
.code-modal__title { font-family: $font-family-body; font-size: 32rpx; font-weight: $font-weight-bold; color: $uv-main-color; margin-bottom: 32rpx; }
.qr-box {
width: 100%; background: #F8FAFC;
border-radius: 16rpx; display: flex; align-items: center;
justify-content: center; border: 1rpx solid #E2E8F0; padding: 20rpx;
box-sizing: border-box;
}
.qr-img { width: 100%; height: auto; }
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; }
.code-modal__tip {
font-family: $font-family-body; font-size: $font-size-sm; color: $uv-warning;
font-weight: $font-weight-medium; margin-top: 16rpx;
}
.code-modal__countdown {
font-family: $font-family-body;
font-size: 24rpx;
color: $uv-placeholder-color;
margin-top: 12rpx;
}
/* ===== Cancel Modal ===== */
.cancel-modal {
width: 600rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx;
}
.cancel-modal__title {
display: block; font-family: $font-family-body; font-size: 32rpx; font-weight: $font-weight-bold;
color: $uv-main-color; text-align: center; margin-bottom: 24rpx;
}
.cancel-modal__body { margin-bottom: 32rpx; }
.cancel-modal__text {
font-family: $font-family-body; font-size: $font-size-sm; color: $uv-content-color; line-height: 1.7;
}
.cancel-modal__actions { display: flex; gap: 24rpx; }
.btn-modal {
flex: 1; height: 72rpx; border-radius: 36rpx; display: flex;
align-items: center; justify-content: center; transition: all $transition-fast;
&--outline {
border: 2rpx solid $uv-border-color;
&:active { transform: scale(0.97); background: $uv-bg-color; }
}
&--primary {
background: $uv-primary;
&:active { transform: scale(0.97); }
}
}
.btn-modal__text--outline { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-medium; color: $uv-tips-color; }
.btn-modal__text--primary { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-bold; color: #ffffff; }
/* ===== Detail Sheet ===== */
.detail-sheet {
width: 640rpx; background: $color-card; border-radius: $radius-lg; max-height: 75vh;
display: flex; flex-direction: column;
}
.sheet-header {
display: flex; align-items: center; justify-content: space-between;
padding: 32rpx; border-bottom: 1rpx solid $uv-border-color; flex-shrink: 0;
}
.sheet-title { font-family: $font-family-body; font-size: $font-size-lg; font-weight: $font-weight-bold; color: $uv-main-color; }
.sheet-close {
width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center;
&:active { opacity: 0.6; }
}
.sheet-close__icon {
width: 20rpx; height: 20rpx; position: relative;
&::before, &::after {
content: ''; position: absolute; top: 50%; left: 50%;
width: 20rpx; height: 3rpx; background: $uv-tips-color; border-radius: 2rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
.sheet-body { padding: 32rpx; flex: 1; }
.sheet-row {
display: flex; justify-content: space-between; align-items: center;
padding: 14rpx 0; border-bottom: 1rpx solid $uv-border-color;
}
.sheet-label { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-main-color; }
.sheet-value { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-content-color; text-align: right; }
.sheet-value--date { color: $uv-error; font-weight: $font-weight-medium; }
.sheet-photo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid $uv-border-color;
}
.sheet-photo-avatar {
width: 200rpx;
height: 200rpx;
border-radius: 8rpx;
}
.sheet-tip {
margin-top: 24rpx; padding: 20rpx; background: #fff2f2; border-radius: 12rpx;
}
.sheet-tip__text { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-error; line-height: 1.5; }
</style>