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

609 lines
20 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">预约入园日期:{{ r.date }}</text>
</view>
<!-- 操作 -->
<view class="record-actions">
<view
v-if="r.status === 'pending' && canCancel(r.date)"
class="btn-sm btn-sm--outline"
@tap="showCancelModal(r)"
>
<text class="btn-sm__text--outline">取消预约</text>
</view>
<view
v-if="r.status === 'pending'"
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">
<view class="code-modal">
<text class="code-modal__title">入园码</text>
<view class="qr-box">
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</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">确定取消该预约吗?未提前取消且未入园将扣除1次年卡使用次数,权益不可恢复。</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="bottom" :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">{{ detailRecord?.cardType }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">卡号</text>
<text class="sheet-value">{{ detailRecord?.cardNo }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约入园日期</text>
<text class="sheet-value">{{ 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">{{ detailRecord?.maskedId }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约状态</text>
<text class="sheet-value">{{ statusLabel(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?.verifyPhoto && detailRecord?.status !== 'auto_used'" 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'" class="sheet-tip">
<text class="sheet-tip__text">未提前取消且未入园将扣除1次年卡使用次数</text>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
type RecordStatus = 'pending' | 'used' | 'cancelled' | 'auto_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
}
const tabs = [
{ label: '全部', value: '' },
{ label: '待入园', value: 'pending' },
{ label: '已入园', value: 'used' },
{ label: '已取消', value: 'cancelled' },
{ label: '自动核销', value: 'auto_used' },
]
const activeTab = ref('')
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const codePopup = ref<any>(null)
const cancelPopup = ref<any>(null)
const detailPopup = ref<any>(null)
const popupCode = ref('')
const detailRecord = ref<ReserveRecord | null>(null)
const cancelRecord = ref<ReserveRecord | null>(null)
const records = ref<ReserveRecord[]>([
{ id: 'YP26062601', scenicName: '武功山金顶景区', date: '2026-06-27', session: '08:00-10:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012398', applyTime: '2026-06-25 14:30' },
{ id: 'YP26062502', scenicName: '杨岐山孽龙洞景区', date: '2026-06-26', session: '10:00-12:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012399', applyTime: '2026-06-24 10:15' },
{ id: 'YP26062003', scenicName: '仙凤三宝景区', date: '2026-06-20', session: '14:00-16:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012400', applyTime: '2026-06-18 09:20', verifyTime: '2026-06-20 09:45', verifyPhoto: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?fm=jpg&q=80&w=200&h=200&fit=crop' },
{ id: 'YP26061804', scenicName: '武功山君澜温泉', date: '2026-06-18', status: 'cancelled', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012401', applyTime: '2026-06-16 16:40', cancelTime: '2026-06-17 20:30' },
{ id: 'YP26061505', scenicName: '凯光新天地生态旅游区', date: '2026-06-15', status: 'auto_used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012402', applyTime: '2026-06-13 11:05', verifyTime: '2026-06-16 00:00', verifyPhoto: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?fm=jpg&q=80&w=200&h=200&fit=crop' },
{ id: 'YP26061006', scenicName: '和平农场·哎马乐园', date: '2026-06-10', session: '08:00-12:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012403', applyTime: '2026-06-08 08:50', verifyTime: '2026-06-10 10:22', verifyPhoto: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?fm=jpg&q=80&w=200&h=200&fit=crop' },
])
const filteredRecords = computed(() => {
if (!activeTab.value) return records.value
return records.value.filter(r => r.status === activeTab.value)
})
function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待入园', used: '已入园', cancelled: '已取消', auto_used: '自动核销' }
return map[status] || status
}
function canCancel(date: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const d = new Date(date)
return (d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) >= 1
}
function showCancelModal(r: ReserveRecord) {
cancelRecord.value = r
cancelPopup.value?.open()
}
function confirmCancel() {
cancelPopup.value?.close()
if (cancelRecord.value) {
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' })
}
}
function showCode(r: ReserveRecord) {
popupCode.value = r.id
codePopup.value?.open()
}
function viewDetail(r: ReserveRecord) {
detailRecord.value = r
detailPopup.value?.open()
}
function onRefresh() {
refreshing.value = true
setTimeout(() => { refreshing.value = false }, 800)
}
function onLoadMore() {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
loading.value = false
noMore.value = true
}, 800)
}
function goReserve() {
uni.navigateTo({ url: '/packages/user/pages/reserve/index' })
}
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;
}
.status-tag {
padding: 4rpx 14rpx;
border-radius: 8rpx;
&--pending { background: #e8f3ff; }
&--used { background: #f0fff4; }
&--cancelled { background: $uv-bg-color; }
&--auto_used { background: #fff8e8; }
}
.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--used .status-tag__text { color: $color-status-success; }
.status-tag--cancelled .status-tag__text { color: $color-status-cancel; }
.status-tag--auto_used .status-tag__text { color: $color-status-pending; }
.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-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: 560rpx; 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: 360rpx; height: 360rpx; background: #F8FAFC;
border-radius: 16rpx; display: flex; align-items: center;
justify-content: center; border: 1rpx solid #E2E8F0;
}
.qr-img { width: 300rpx; height: 300rpx; }
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; margin-top: 24rpx; }
/* ===== 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 {
background: $color-card; border-radius: $radius-lg $radius-lg 0 0; 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-photo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid $uv-border-color;
}
.sheet-photo-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
}
.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>