feat: 年卡相关页面(列表/详情/购买/激活/入园码/绑定)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-06-14 17:35:33 +08:00
co-authored by Claude Opus 4.8
parent 2293e67f8c
commit b4981587a2
6 changed files with 552 additions and 6 deletions
+104 -1
View File
@@ -1,3 +1,106 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page">
<!-- 年卡信息头部 -->
<view class="card-info">
<text class="card-name">{{ cardName }}</text>
<text class="card-tip">请填写实名信息以激活年卡</text>
</view>
<!-- 实名表单 -->
<view class="form-card">
<view class="form-item">
<text class="label">真实姓名</text>
<input class="input" v-model="form.realName" placeholder="请输入真实姓名" />
</view>
<view class="form-item">
<text class="label">身份证号</text>
<input class="input" v-model="form.idCard" placeholder="请输入身份证号" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input class="input" v-model="form.phone" placeholder="请输入手机号码" type="number" />
</view>
</view>
<!-- 激活按钮 -->
<view class="btn-area">
<button class="btn-activate" @click="handleActivate" :disabled="activating">
{{ activating ? '激活中...' : '立即激活' }}
</button>
</view>
</view>
</template> </template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useCardStore } from '../../../../store/card'
import { showToast } from '../../../../utils'
const cardStore = useCardStore()
const cardId = ref('')
const cardName = ref('')
const activating = ref(false)
/** 实名信息表单 */
const form = reactive({
realName: '',
idCard: '',
phone: ''
})
/** 页面加载时解析路由参数 */
onLoad((options) => {
cardId.value = options?.cardId || ''
cardName.value = decodeURIComponent(options?.name || '')
})
/** 表单校验 */
function validate(): boolean {
if (!form.realName.trim()) {
showToast('请输入真实姓名')
return false
}
if (!/^\d{17}[\dXx]$/.test(form.idCard)) {
showToast('请输入正确的身份证号')
return false
}
if (!/^1\d{10}$/.test(form.phone)) {
showToast('请输入正确的手机号码')
return false
}
return true
}
/** 提交激活 */
async function handleActivate() {
if (!validate()) return
if (!cardId.value) return
activating.value = true
try {
await cardStore.activateCard(cardId.value, form)
showToast('激活成功', 'success')
// 激活成功后跳转入园码页
setTimeout(() => {
uni.redirectTo({ url: `/packages/user/pages/card/qrcode?cardId=${cardId.value}` })
}, 1500)
} catch (err: any) {
showToast(err.message || '激活失败')
} finally {
activating.value = false
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; padding: 20rpx; box-sizing: border-box; }
.card-info { background: #4A90D9; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; }
.card-name { font-size: 34rpx; font-weight: bold; color: #fff; display: block; margin-bottom: 8rpx; }
.card-tip { font-size: 24rpx; color: rgba(255,255,255,0.8); }
.form-card { background: #fff; border-radius: 16rpx; padding: 10rpx 30rpx; }
.form-item { padding: 24rpx 0; border-bottom: 1rpx solid #f0f0f0; }
.form-item:last-child { border-bottom: none; }
.label { display: block; font-size: 26rpx; color: #666; margin-bottom: 12rpx; }
.input { width: 100%; height: 80rpx; font-size: 30rpx; color: #333; box-sizing: border-box; }
.btn-area { padding: 40rpx 20rpx; }
.btn-activate { width: 100%; height: 96rpx; line-height: 96rpx; background: #4A90D9; color: #fff; border-radius: 48rpx; font-size: 32rpx; border: none; }
.btn-activate[disabled] { background: #A5C8E8; }
</style>
+64 -1
View File
@@ -1,3 +1,66 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page">
<!-- 页面头部 -->
<view class="header">
<text class="title">绑定年卡</text>
<text class="desc">输入卡号绑定已有年卡</text>
</view>
<!-- 卡号输入表单 -->
<view class="form-card">
<view class="form-item">
<text class="label">卡号</text>
<input class="input" v-model="cardNo" placeholder="请输入年卡卡号" />
</view>
</view>
<!-- 绑定按钮 -->
<view class="btn-area">
<button class="btn-bind" @click="handleBind" :disabled="binding">
{{ binding ? '绑定中...' : '立即绑定' }}
</button>
</view>
</view>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { useCardStore } from '../../../../store/card'
import { showToast } from '../../../../utils'
const cardStore = useCardStore()
const cardNo = ref('')
const binding = ref(false)
/** 执行绑定 */
async function handleBind() {
if (!cardNo.value.trim()) {
showToast('请输入年卡卡号')
return
}
binding.value = true
try {
await cardStore.bindCard(cardNo.value.trim())
showToast('绑定成功', 'success')
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err: any) {
showToast(err.message || '绑定失败')
} finally {
binding.value = false
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; padding: 20rpx; box-sizing: border-box; }
.header { padding: 20rpx 10rpx 30rpx; }
.title { font-size: 36rpx; font-weight: bold; color: #333; display: block; margin-bottom: 8rpx; }
.desc { font-size: 26rpx; color: #999; }
.form-card { background: #fff; border-radius: 16rpx; padding: 10rpx 30rpx; }
.form-item { padding: 24rpx 0; }
.label { display: block; font-size: 26rpx; color: #666; margin-bottom: 12rpx; }
.input { width: 100%; height: 80rpx; font-size: 30rpx; color: #333; box-sizing: border-box; }
.btn-area { padding: 40rpx 20rpx; }
.btn-bind { width: 100%; height: 96rpx; line-height: 96rpx; background: #4A90D9; color: #fff; border-radius: 48rpx; font-size: 32rpx; border: none; }
.btn-bind[disabled] { background: #A5C8E8; }
</style>
+104 -1
View File
@@ -1,3 +1,106 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page">
<!-- 订单信息卡片 -->
<view class="order-card">
<text class="card-title">订单信息</text>
<view class="order-row">
<text class="label">年卡名称</text>
<text class="value">{{ cardName }}</text>
</view>
<view class="order-row">
<text class="label">有效期</text>
<text class="value">365天</text>
</view>
<view class="order-row">
<text class="label">价格</text>
<text class="value price">¥{{ price }}</text>
</view>
</view>
<!-- 支付方式 -->
<view class="pay-card">
<text class="card-title">支付方式</text>
<view class="pay-option active">
<text class="pay-icon">💳</text>
<text class="pay-name">模拟支付(测试)</text>
<text class="pay-check">✓</text>
</view>
</view>
<!-- 底部支付栏 -->
<view class="bottom-bar">
<view class="bottom-price">
<text class="label">应付金额</text>
<text class="value">¥{{ price }}</text>
</view>
<button class="btn-pay" @click="handlePay" :disabled="paying">
{{ paying ? '支付中...' : '确认支付' }}
</button>
</view>
</view>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createOrder, mockPay } from '../../../../api/order'
import { showToast } from '../../../../utils'
const cardId = ref('')
const cardName = ref('')
const price = ref(0)
const paying = ref(false)
/** 页面加载时解析路由参数 */
onLoad((options) => {
cardId.value = options?.id || ''
cardName.value = decodeURIComponent(options?.name || '')
price.value = Number(options?.price || 0)
})
/** 执行支付流程 */
async function handlePay() {
if (!cardId.value) return
paying.value = true
try {
// 1. 创建订单
const order = await createOrder({ cardId: cardId.value })
// 2. 模拟支付
const payResult = await mockPay(order.id)
if (payResult.success) {
showToast('支付成功', 'success')
// 3. 支付成功后跳转激活页
setTimeout(() => {
uni.redirectTo({
url: `/packages/user/pages/card/activate?cardId=${cardId.value}&name=${encodeURIComponent(cardName.value)}`
})
}, 1500)
} else {
showToast('支付失败')
}
} catch (err: any) {
showToast(err.message || '支付失败')
} finally {
paying.value = false
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; padding: 20rpx; padding-bottom: 140rpx; box-sizing: border-box; }
.order-card, .pay-card { background: #fff; border-radius: 16rpx; padding: 24rpx 30rpx; margin-bottom: 20rpx; }
.card-title { font-size: 30rpx; font-weight: bold; color: #333; display: block; margin-bottom: 20rpx; padding-bottom: 16rpx; border-bottom: 1rpx solid #f0f0f0; }
.order-row { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 0; }
.label { font-size: 28rpx; color: #666; }
.value { font-size: 28rpx; color: #333; }
.value.price { color: #E74C3C; font-weight: bold; font-size: 36rpx; }
.pay-option { display: flex; align-items: center; padding: 20rpx 0; }
.pay-option.active { color: #4A90D9; }
.pay-icon { font-size: 36rpx; margin-right: 16rpx; }
.pay-name { flex: 1; font-size: 28rpx; }
.pay-check { font-size: 32rpx; color: #4A90D9; font-weight: bold; }
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; display: flex; align-items: center; padding: 16rpx 30rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee; }
.bottom-price { flex: 1; display: flex; flex-direction: column; }
.bottom-price .label { font-size: 22rpx; color: #999; }
.bottom-price .value { font-size: 40rpx; color: #E74C3C; font-weight: bold; }
.btn-pay { background: #E74C3C; color: #fff; font-size: 30rpx; padding: 20rpx 60rpx; border-radius: 44rpx; border: none; }
.btn-pay[disabled] { background: #F5A8A3; }
</style>
+113 -1
View File
@@ -1,3 +1,115 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page" v-if="card">
<!-- 封面图 -->
<image class="cover" :src="card.coverImage" mode="aspectFill" />
<!-- 基本信息 -->
<view class="info-card">
<text class="card-name">{{ card.name }}</text>
<view class="price-row">
<text class="price">¥{{ card.price }}</text>
<text class="original-price" v-if="card.originalPrice">¥{{ card.originalPrice }}</text>
</view>
<text class="card-desc">{{ card.description }}</text>
</view>
<!-- 权益说明 -->
<view class="section" v-if="card.benefits?.length">
<text class="section-title">权益说明</text>
<view class="benefit-list">
<view class="benefit-item" v-for="(benefit, idx) in card.benefits" :key="idx">
<text class="benefit-icon">✅</text>
<text class="benefit-text">{{ benefit }}</text>
</view>
</view>
</view>
<!-- 权益景区 -->
<view class="section" v-if="card.scenicSpots?.length">
<text class="section-title">权益景区({{ card.scenicSpots.length }}个)</text>
<scroll-view scroll-x class="scenic-scroll">
<view class="scenic-list">
<view class="scenic-card" v-for="spot in card.scenicSpots" :key="spot.id">
<image class="scenic-img" :src="spot.image" mode="aspectFill" />
<text class="scenic-name">{{ spot.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 年卡详情(富文本) -->
<view class="section" v-if="card.detail">
<text class="section-title">年卡详情</text>
<rich-text :nodes="card.detail" />
</view>
<!-- 底部购买栏 -->
<view class="bottom-bar">
<view class="bottom-price">
<text class="label">价格</text>
<text class="value">¥{{ card.price }}</text>
</view>
<button class="btn-buy" @click="handleBuy">立即购买</button>
</view>
</view>
<!-- 加载中状态 -->
<view v-else class="loading-page"><text>加载中...</text></view>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useCardStore } from '../../../../store/card'
import type { CardDetail } from '../../../../api/types'
const cardStore = useCardStore()
const card = ref<CardDetail | null>(null)
/** 页面加载时获取年卡详情 */
onLoad((options) => {
if (options?.id) {
loadDetail(options.id)
}
})
/** 加载年卡详情数据 */
async function loadDetail(id: string) {
try {
await cardStore.fetchCardDetail(id)
card.value = cardStore.currentCard
} catch (err) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
/** 跳转购买页 */
function handleBuy() {
if (!card.value) return
uni.navigateTo({
url: `/packages/user/pages/card/buy?id=${card.value.id}&name=${encodeURIComponent(card.value.name)}&price=${card.value.price}`
})
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; padding-bottom: 120rpx; }
.cover { width: 100%; height: 400rpx; background: #ddd; }
.info-card { background: #fff; padding: 24rpx 30rpx; margin-bottom: 16rpx; }
.card-name { font-size: 36rpx; font-weight: bold; color: #333; display: block; }
.price-row { display: flex; align-items: baseline; gap: 12rpx; margin: 12rpx 0; }
.price { font-size: 44rpx; color: #E74C3C; font-weight: bold; }
.original-price { font-size: 26rpx; color: #ccc; text-decoration: line-through; }
.card-desc { font-size: 26rpx; color: #666; }
.section { background: #fff; padding: 24rpx 30rpx; margin-bottom: 16rpx; }
.section-title { font-size: 30rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
.benefit-list { display: flex; flex-direction: column; gap: 12rpx; }
.benefit-item { display: flex; align-items: center; gap: 12rpx; }
.benefit-icon { font-size: 28rpx; }
.benefit-text { font-size: 26rpx; color: #666; }
.scenic-scroll { white-space: nowrap; }
.scenic-list { display: flex; gap: 16rpx; }
.scenic-card { min-width: 200rpx; display: flex; flex-direction: column; }
.scenic-img { width: 200rpx; height: 140rpx; border-radius: 12rpx; background: #eee; }
.scenic-name { font-size: 24rpx; color: #333; margin-top: 8rpx; text-align: center; }
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; display: flex; align-items: center; padding: 16rpx 30rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee; }
.bottom-price { flex: 1; display: flex; flex-direction: column; }
.bottom-price .label { font-size: 22rpx; color: #999; }
.bottom-price .value { font-size: 36rpx; color: #E74C3C; font-weight: bold; }
.btn-buy { background: #E74C3C; color: #fff; font-size: 30rpx; padding: 20rpx 60rpx; border-radius: 44rpx; border: none; }
.loading-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #999; }
</style>
+80 -1
View File
@@ -1,3 +1,82 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page">
<!-- 搜索栏 -->
<view class="search-bar">
<uni-search-bar v-model="keyword" placeholder="搜索年卡" @confirm="handleSearch" @clear="handleSearch" />
</view>
<!-- 分类筛选 -->
<view class="filter-bar">
<view
v-for="cat in categories"
:key="cat.value"
class="filter-tag"
:class="{ active: currentCategory === cat.value }"
@click="selectCategory(cat.value)"
>
<text>{{ cat.label }}</text>
</view>
</view>
<!-- 年卡列表 -->
<view class="card-list">
<view v-if="cardStore.loading" class="loading"><text>加载中...</text></view>
<view v-else-if="cardStore.cardList.length === 0">
<empty-state icon="🎫" text="暂无符合条件的年卡" />
</view>
<view v-else>
<card-item v-for="card in cardStore.cardList" :key="card.id" :card="card" @click="goDetail" />
</view>
</view>
</view>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useCardStore } from '../../../../store/card'
import CardItem from '../../../../components/card-item.vue'
import EmptyState from '../../../../components/empty-state.vue'
import type { CardInfo } from '../../../../api/types'
const cardStore = useCardStore()
const keyword = ref('')
const currentCategory = ref('')
/** 分类选项 */
const categories = [
{ label: '全部', value: '' },
{ label: '旅游年卡', value: 'travel' },
{ label: '亲子年卡', value: 'family' },
{ label: '情侣年卡', value: 'couple' }
]
/** 页面加载时获取年卡列表 */
onLoad(() => {
cardStore.fetchCardList()
})
/** 执行搜索 */
function handleSearch() {
cardStore.fetchCardList({ keyword: keyword.value, category: currentCategory.value })
}
/** 选择分类 */
function selectCategory(value: string) {
currentCategory.value = value
handleSearch()
}
/** 跳转年卡详情 */
function goDetail(card: CardInfo) {
uni.navigateTo({ url: `/packages/user/pages/card/detail?id=${card.id}` })
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; }
.search-bar { background: #fff; padding: 12rpx 20rpx; }
.filter-bar { display: flex; gap: 16rpx; padding: 16rpx 20rpx; background: #fff; border-bottom: 1rpx solid #f0f0f0; }
.filter-tag { padding: 10rpx 24rpx; border-radius: 30rpx; font-size: 24rpx; color: #666; background: #f5f5f5; }
.filter-tag.active { background: #4A90D9; color: #fff; }
.card-list { padding: 20rpx; }
.loading { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
</style>
+87 -1
View File
@@ -1,3 +1,89 @@
<template> <template>
<view><text>页面占位 - 后续任务实现</text></view> <view class="page">
<!-- 年卡信息头部 -->
<view class="card-top">
<text class="card-label">{{ qrInfo?.cardName || '年卡' }}</text>
<text class="user-name">{{ qrInfo?.userName || '---' }}</text>
<text class="expire-date">有效期至 {{ qrInfo?.expireDate || '---' }}</text>
</view>
<!-- 二维码区域 -->
<view class="qr-card">
<view class="qr-area">
<image v-if="qrInfo?.qrCode" class="qr-image" :src="qrInfo.qrCode" mode="aspectFit" />
<view v-else class="qr-placeholder"><text>加载中...</text></view>
</view>
<text class="qr-tip">扫码入园 · 每60秒自动刷新</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<button class="btn-secondary" @click="showComingSoon">预约景区</button>
<button class="btn-primary" @click="refreshQrCode">刷新二维码</button>
</view>
</view>
</template> </template>
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useCardStore } from '../../../../store/card'
import { showToast } from '../../../../utils'
import type { QrCodeInfo } from '../../../../api/types'
const cardStore = useCardStore()
const cardId = ref('')
const qrInfo = ref<QrCodeInfo | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
/** 页面加载时获取入园码并启动定时刷新 */
onLoad((options) => {
cardId.value = options?.cardId || ''
if (cardId.value) {
loadQrCode()
// 每60秒自动刷新二维码
refreshTimer = setInterval(() => {
loadQrCode()
}, 60000)
}
})
/** 页面销毁时清除定时器 */
onBeforeUnmount(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
/** 加载入园码 */
async function loadQrCode() {
try {
qrInfo.value = await cardStore.getEntryQrCode(cardId.value)
} catch (err: any) {
showToast(err.message || '获取入园码失败')
}
}
/** 手动刷新二维码 */
function refreshQrCode() {
loadQrCode()
showToast('已刷新', 'success')
}
/** 预约景区(暂未开放) */
function showComingSoon() {
showToast('即将上线,敬请期待')
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f5f5; padding: 20rpx; box-sizing: border-box; }
.card-top { background: linear-gradient(135deg, #4A90D9, #357ABD); border-radius: 20rpx; padding: 30rpx; color: #fff; margin-bottom: -20rpx; }
.card-label { font-size: 24rpx; opacity: 0.8; display: block; }
.user-name { font-size: 36rpx; font-weight: bold; display: block; margin: 12rpx 0 8rpx; }
.expire-date { font-size: 22rpx; opacity: 0.7; display: block; }
.qr-card { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-top: 30rpx; box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08); text-align: center; }
.qr-area { width: 360rpx; height: 360rpx; margin: 20rpx auto; display: flex; align-items: center; justify-content: center; background: #f8f8f8; border-radius: 12rpx; }
.qr-image { width: 320rpx; height: 320rpx; }
.qr-placeholder { color: #999; font-size: 28rpx; }
.qr-tip { font-size: 24rpx; color: #999; display: block; margin-top: 16rpx; }
.actions { display: flex; gap: 20rpx; padding: 40rpx 20rpx; }
.btn-secondary { flex: 1; height: 88rpx; line-height: 88rpx; background: #fff; color: #4A90D9; border: 2rpx solid #4A90D9; border-radius: 44rpx; font-size: 28rpx; }
.btn-primary { flex: 1; height: 88rpx; line-height: 88rpx; background: #4A90D9; color: #fff; border-radius: 44rpx; font-size: 28rpx; border: none; }
</style>