refactor: move pages to subpackages to reduce main package size

This commit is contained in:
jacobxu666
2026-06-26 11:59:59 +08:00
parent 4b7c540343
commit 4acb2e6a9c
18 changed files with 113 additions and 108 deletions
+608
View File
@@ -0,0 +1,608 @@
<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>