feat: 抖音核销接入小泥人API、公告详情页、景区预约人选人等多处优化

抖音核销(douyin-verify):
- 对接小泥人 OAuth2 token 与 voucher-list 券码接口,按 code 分组展示订单券号及可激活次数
- 支持手动输入券码验证券码;核销流程改为先调后端 sendAndBind 发卡绑定,再验证券码
- 购卡须知改为富文本渲染,新增无可核销订单提示弹窗、客服电话拨打
- 增加 product_id 过滤与 per-page=100,验证券码命中后仅展示不弹提示

公告模块:
- 首页/公告列表点击跳转到新增公告详情页(notice-detail),展示标题、发布时间、富文本内容及视频
- 公告列表卡片改为标题+发布时间+查看详情按钮,时间与按钮同排
- 富文本支持内嵌 video 解析播放;content 为空不显示分隔线;标题取公告 title 而非类型

首页与价格:
- 首页大卡价格/划线价/立省改为商品详情接口动态获取
- 热门景区列表扩展至8条,支持优惠/折扣标签与默认占位图

预约/景区:
- 预约页改为选择预约人(可选/已预约置灰),预约参数改用预约人信息
- 景区列表与详情支持优惠类型、折扣、特惠价展示,富文本图片防溢出
- 景区列表移除自定义导航改用原生导航

订单/绑卡:
- 订单列表/详情增加抖音核销单号(thirdOrderNo)展示,核销订单隐藏金额与退款入口
- 绑卡页移除人脸可选开关,照片必传改为拍照
- 其他:图片域名统一为 hn12301,tabBar'旅游卡'改名,登录页支持 redirect 回跳
This commit is contained in:
lcj
2026-08-07 10:45:53 +08:00
parent 40cebaa3a5
commit f0911c67a0
18 changed files with 1076 additions and 421 deletions
+4 -23
View File
@@ -20,7 +20,6 @@
placeholder="请输入卡号"
placeholder-class="placeholder"
:disabled="faceBindMode"
@blur="onCardNoBlur"
/>
</view>
<view class="divider" />
@@ -75,7 +74,7 @@
<view class="divider" />
<!-- 持卡人照片 -->
<view v-if="needFaceBind" class="form-row form-row--photo">
<view class="form-row form-row--photo">
<text class="form-label form-label--top">持卡人照片</text>
<view class="upload-area" @tap="photoUrl ? previewPhoto() : chooseImage()">
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFit" />
@@ -152,7 +151,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
import { uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
const faceBindMode = ref(false)
@@ -187,7 +186,6 @@ const photoUrl = ref('')
const agreed = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const needFaceBind = ref(false)
const showIdTypePicker = ref(false)
const idTypePopup = ref<any>(null)
@@ -200,15 +198,8 @@ onLoad(async (options) => {
const idType = options.idType || ''
docType.value = idType || '1'
form.idCard = options.idNo || ''
needFaceBind.value = true
} else if (options?.redeemCode) {
form.cardNo = options.redeemCode
try {
const res = await checkFaceFlag(options.redeemCode)
needFaceBind.value = !!res
} catch {
needFaceBind.value = false
}
}
})
@@ -227,22 +218,12 @@ function selectIdType(value: string) {
showIdTypePicker.value = false
}
async function onCardNoBlur() {
if (!form.cardNo.trim()) return
try {
const res = await checkFaceFlag(form.cardNo.trim())
needFaceBind.value = !!res
} catch {
needFaceBind.value = false
}
}
function chooseImage() {
if (uploading.value) return
uni.chooseImage({
count: 1,
sizeType: ['original'],
sourceType: ['album', 'camera'],
sourceType: ['camera'],
success: (res) => handleImage(res.tempFilePaths[0]),
fail: (err) => {
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
@@ -316,7 +297,7 @@ function validateForm() {
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确'
if (!form.idCard.trim()) return `请输入${idTypeLabel.value}号码`
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
if (needFaceBind.value && !photoUrl.value) return '请上传持卡人照片'
if (!photoUrl.value) return '请上传持卡人照片'
return ''
}
+132 -3
View File
@@ -15,22 +15,43 @@
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-value">{{ buyPrice }}</text>
<text class="price-original">¥3800+</text>
<text v-if="showPrice" class="price-original">¥{{ showPrice }}</text>
</view>
<view class="btn-buy" :class="{ 'btn-buy--disabled': paying }" @tap="handleBuy">
<text class="btn-buy__text">{{ paying ? '支付中...' : '立即购买' }}</text>
</view>
</view>
<!-- 购卡须知弹窗:内容为富文本 -->
<view v-if="showNotice" class="modal-mask" @tap="showNotice = false">
<view class="modal-panel" @tap.stop>
<text class="modal-title">购卡须知</text>
<scroll-view scroll-y class="modal-scroll">
<rich-text class="modal-html" :nodes="noticeHtml" />
</scroll-view>
<view class="modal-divider" />
<view class="modal-footer">
<view class="modal-foot-btn" @tap="showNotice = false">
<text class="modal-foot-text">取消</text>
</view>
<view class="modal-foot-split" />
<view class="modal-foot-btn" @tap="confirmBuy">
<text class="modal-foot-text modal-foot-text--red">确认购买</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { post, getToken } from '../../../api/request'
import { BASE_URL } from '../../../config'
import { useUserStore } from '../../../store/user'
import { createOrder, prepay } from '../../../api/order'
import type { BannerItem } from '../../../api/types'
const userStore = useUserStore()
@@ -39,10 +60,42 @@ const cardModleId = ref('')
const mainPic = ref('')
const introduction = ref('')
const buyPrice = ref('0')
const showPrice = ref('')
const skuId = ref('')
const loading = ref(true)
const paying = ref(false)
const shareTitle = ref('爱上萍乡旅游年卡')
const showNotice = ref(false)
const noticeContent = ref('')
/** 富文本 HTML 清洗:给所有 img 加 max-width:100%,防止超出容器边界 */
function fixRichHtml(html: string): string {
if (!html) return ''
const style = 'max-width:100%;height:auto;display:block;'
return html.replace(/<img(?![^>]*max-width)[^>]*>/gi, (tag: string) => {
if (/style="[^"]*"/i.test(tag)) {
return tag.replace(/style="([^"]*)"/i, (_: string, s: string) => `style="${s};${style}"`)
}
return tag.replace(/<img/i, `<img style="${style}"`)
})
}
const noticeHtml = computed(() => fixRichHtml(noticeContent.value))
/** 获取购卡须知富文本(type = CARD_PURCHASE_NOTICE) */
async function fetchPurchaseNotice() {
try {
const res = await post<BannerItem[]>('/mp-api/mpContent/queryList', {
channel: 'pingxiang',
type: 'CARD_PURCHASE_NOTICE',
})
const list = res || []
const item = list.find((i) => i.type === 'CARD_PURCHASE_NOTICE') || list[0]
noticeContent.value = item?.content || ''
} catch {
noticeContent.value = ''
}
}
onLoad((query) => {
goodsId.value = (query?.goodsId as string) || '821926819630853'
@@ -59,7 +112,7 @@ interface CardModelDetail {
name: string
goods: { id: string; mainPic: string }
goodsInfo: { introduction: string }
sku: Array<{ id: string; buyPrice: number }>
sku: Array<{ id: string; buyPrice: number; showPrice: number }>
}
onMounted(async () => {
@@ -73,6 +126,8 @@ onMounted(async () => {
skuId.value = sku?.id || ''
const price = sku?.buyPrice || 0
buyPrice.value = (price / 100).toFixed(2)
const showPriceValue = sku?.showPrice || 0
showPrice.value = showPriceValue ? (showPriceValue / 100).toFixed(2) : ''
} catch {
// 静默失败
} finally {
@@ -97,8 +152,15 @@ async function handleBuy() {
uni.navigateTo({ url: '/pages/login/login' })
return
}
if (paying.value) return
showNotice.value = true
fetchPurchaseNotice()
}
async function confirmBuy() {
if (paying.value) return
paying.value = true
showNotice.value = false
try {
const mobile = (uni.getStorageSync('phone') as string) || ''
@@ -276,4 +338,71 @@ $bar-height: 120rpx;
color: #ffffff;
}
}
/* ===== 购卡须知弹窗 ===== */
.modal-mask {
position: fixed;
inset: 0;
z-index: $z-modal;
background: rgba(120, 120, 120, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.modal-panel {
width: 662rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
}
.modal-title {
display: block;
text-align: center;
font-family: $font-family-display;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 24rpx;
}
.modal-scroll {
height: 700rpx;
}
.modal-html {
width: 100%;
display: block;
font-size: $font-size-sm;
color: $uv-content-color;
line-height: 1.7;
word-break: break-all;
}
.modal-divider {
height: 1rpx;
background: $uv-border-color;
margin-top: 24rpx;
}
.modal-footer {
display: flex;
align-items: center;
}
.modal-foot-btn {
flex: 1;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-foot-split {
width: 1rpx;
height: 48rpx;
background: $uv-border-color;
}
.modal-foot-text {
font-size: $font-size-lg;
color: $uv-main-color;
&--red {
color: #ff383c;
}
}
</style>
+378 -111
View File
@@ -1,12 +1,7 @@
<template>
<view class="page">
<!-- 头部:装饰渐变 + 标题 -->
<view class="hero">
<text class="hero-title">抖音购卡订单</text>
<view class="hero-pill">
<text class="hero-pill-text">订单核销</text>
</view>
</view>
<!-- 顶部背景图 -->
<image class="hero-bg" src="https://aliyunoss.hn12301.net/pxmall/px_douyin.png" mode="widthFix" />
<scroll-view scroll-y class="body">
<!-- 第 1 步:确认抖音下单信息 -->
@@ -29,35 +24,39 @@
<view class="field">
<text class="field-label">抖音的订单券号:</text>
<!-- 有券号:单选列表 -->
<!-- 有券号:按 code 分组单选列表 -->
<template v-if="hasVoucher">
<text class="hint-red">当前微信手机号,可核销券号如下。请选择券号核销</text>
<view
v-for="item in vouchers"
:key="item.id"
v-for="(group, index) in voucherGroups"
:key="group.code"
class="field-box field-box--row"
@tap="selectVoucher(item)"
@tap="selectVoucher(group)"
>
<text class="code-text">{{ item.code }}</text>
<view class="radio" :class="{ 'radio--on': selectedId === item.id }">
<view v-if="selectedId === item.id" class="radio-dot" />
<text class="code-text">
抖音订单号{{ index + 1 }}:{{ group.code }}(可激活次数{{ group.count }}次)
</text>
<view class="radio" :class="{ 'radio--on': selectedCode === group.code }">
<view v-if="selectedCode === group.code" class="radio-dot" />
</view>
</view>
</template>
</view>
<!-- 无券号:手输券号 -->
<template v-else>
<text class="hint-red">无可核销订单!请输入订单券号核销</text>
<view class="field-box">
<input
class="field-input"
v-model="manualCode"
type="number"
placeholder="请粘贴抖音订单券号"
placeholder-class="placeholder"
/>
<!-- 手动输入券码 + 验证券码(仅当手机号未查到券码时才需填写) -->
<view class="field" v-if="!hasVoucher">
<text class="hint-red">当前微信手机号,无可核销订单!请输入订单券号核销</text>
<view class="field-box">
<input
class="field-input"
v-model="codeInput"
placeholder="请粘贴已复制的订单券号"
placeholder-class="placeholder"
/>
<view class="verify-btn" @tap="verifyInputCode">
<text class="verify-btn-text">验证券码</text>
</view>
</template>
</view>
</view>
</view>
@@ -108,7 +107,7 @@
<view class="face-box" @tap="faceImg ? previewFace() : chooseImage()">
<image v-if="faceImg" class="face-preview" :src="faceImg" mode="aspectFill" />
<text v-else class="face-plus">+</text>
<view v-if="faceImg" class="face-remove" @tap.stop="faceImg = ''">
<view v-if="faceImg" class="face-remove" @tap.stop="removePhoto">
<view class="face-remove__x" />
</view>
</view>
@@ -142,21 +141,16 @@
<!-- 底部固定按钮 -->
<view class="footer-bar">
<view class="footer-btn" :class="{ 'footer-btn--disabled': submitting }" @tap="onConfirmVerify">
<text class="footer-btn-text">确认核销</text>
<text class="footer-btn-text">提交</text>
</view>
</view>
<!-- 购卡须知弹窗:内容为单张可滑动图片 -->
<!-- 购卡须知弹窗:内容为富文本 -->
<view v-if="showNotice" class="modal-mask" @tap="showNotice = false">
<view class="modal-panel" @tap.stop>
<text class="modal-title">购卡须知</text>
<scroll-view scroll-y class="modal-scroll">
<image
class="modal-img"
src="https://aliyunoss.hn12301.net/pxmall/aspx.png"
mode="widthFix"
@tap="previewNotice"
/>
<rich-text class="modal-html" :nodes="noticeHtml" />
</scroll-view>
<view class="modal-divider" />
<view class="modal-footer">
@@ -171,6 +165,38 @@
</view>
</view>
<!-- 无可核销订单弹窗 -->
<view v-if="showNoVoucher" class="modal-mask" @tap="showNoVoucher = false">
<view class="modal-panel" @tap.stop>
<text class="no-voucher-title">温馨提示</text>
<text class="no-voucher-sub">当前无可核销订单!</text>
<view class="no-voucher-body">
<view class="no-voucher-reason">
<text class="no-voucher-label">可能原因:</text>
</view>
<view class="no-voucher-reason">
<text class="no-voucher-num">1.</text>
<text>抖音购卡的手机号和当前登录手机号码不一致</text>
</view>
<view class="no-voucher-reason">
<text class="no-voucher-num">2.</text>
<text>抖音购卡的订单已经核销完成了</text>
</view>
<view class="no-voucher-reason">
<text class="no-voucher-num">3.</text>
<text class="no-voucher-text">如无法解决请拨打客服电话:</text>
<text class="no-voucher-phone" @tap="callService">0731-88912301</text>
</view>
</view>
<view class="modal-divider" />
<view class="modal-footer">
<view class="modal-foot-btn" @tap="closeNoVoucher">
<text class="modal-foot-text modal-foot-text--primary">确认</text>
</view>
</view>
</view>
</view>
<!-- 证件类型选择(与绑卡页一致) -->
<uni-popup
ref="idTypePopup"
@@ -215,11 +241,10 @@ import { ref, reactive, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useUserStore } from '../../../store/user'
import { uploadImage } from '../api/card'
import {
queryVoucherList,
activateAndBind,
type VoucherItem,
} from '../api/douyin'
import { post } from '../../../api/request'
import { BASE_URL } from '../../../config'
import type { VoucherItem } from '../api/douyin'
import type { BannerItem } from '../../../api/types'
const userStore = useUserStore()
@@ -229,15 +254,73 @@ const maskedPhone = computed(() =>
)
const vouchers = ref<VoucherItem[]>([])
const selectedId = ref('')
const manualCode = ref('')
const vouchersLoaded = ref(false)
const selectedCode = ref('')
const codeInput = ref('')
const hasVoucher = computed(() => vouchers.value.length > 0)
const selectedVoucher = computed(() => vouchers.value.find((v) => v.id === selectedId.value) || null)
interface VoucherGroup {
code: string
count: number
id: string
voucher: VoucherItem
}
/** 按 code 分组:同一订单多张券共享一个 code,合并显示并标注可激活次数 */
const voucherGroups = computed<VoucherGroup[]>(() => {
const map = new Map<string, VoucherItem[]>()
for (const v of vouchers.value) {
const list = map.get(v.code) || []
list.push(v)
map.set(v.code, list)
}
return Array.from(map.values()).map((list) => ({
code: list[0].code,
count: list.length,
id: list[0].id,
voucher: list[0],
}))
})
const selectedVoucher = computed(() => voucherGroups.value.find((g) => g.code === selectedCode.value) || null)
const form = reactive({ userName: '', idNo: '' })
const faceImg = ref('')
const uploading = ref(false)
const submitting = ref(false)
const showNotice = ref(false)
const showNoVoucher = ref(false)
const accessToken = ref('')
const tokenType = ref('')
const noticeContent = ref('')
/** 富文本 HTML 清洗:给所有 img 加 max-width:100%,防止超出容器边界 */
function fixRichHtml(html: string): string {
if (!html) return ''
const style = 'max-width:100%;height:auto;display:block;'
return html.replace(/<img(?![^>]*max-width)[^>]*>/gi, (tag: string) => {
if (/style="[^"]*"/i.test(tag)) {
return tag.replace(/style="([^"]*)"/i, (_: string, s: string) => `style="${s};${style}"`)
}
return tag.replace(/<img/i, `<img style="${style}"`)
})
}
const noticeHtml = computed(() => fixRichHtml(noticeContent.value))
/** 获取购卡须知富文本(type = CARD_PURCHASE_NOTICE) */
async function fetchPurchaseNotice() {
try {
const res = await post<BannerItem[]>('/mp-api/mpContent/queryList', {
channel: 'pingxiang',
type: 'CARD_PURCHASE_NOTICE',
})
const list = res || []
const item = list.find((i) => i.type === 'CARD_PURCHASE_NOTICE') || list[0]
noticeContent.value = item?.content || ''
} catch {
noticeContent.value = ''
}
}
// xuchao: 证件类型与绑卡页 bind.vue 保持一致
const idTypeOptions = [
@@ -265,34 +348,117 @@ function selectIdType(value: string) {
showIdTypePicker.value = false
}
onShow(() => {
onShow(async () => {
await fetchOAuthToken()
phone.value = (uni.getStorageSync('phone') as string) || ''
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
uni.navigateTo({ url: '/pages/login/login?redirect=/packages/user/pages/douyin-verify' })
return
}
fetchVouchers()
if (!vouchersLoaded.value) {
fetchVouchers()
}
})
/** xuchao: 按当前手机号查询可核销券码(status=1 未使用) */
/** 获取第三方 OAuth2 token,后续接口放在 header 中 */
async function fetchOAuthToken() {
try {
console.log('[douyin] fetching OAuth token...')
const res = await uni.request({
url: 'https://www.xiaoniren.cn/openapi/oauth2-token/authorize?client_id=uwfdRQD0CjHrz0oY&client_key=186c07637ed40ef9ef0a2289b188ac',
method: 'GET',
})
console.log('[douyin] OAuth response:', res)
const body = (res as any).data
if (body?.data?.access_token) {
accessToken.value = body.data.access_token
tokenType.value = body.data.token_type || 'Bearer'
console.log('[douyin] OAuth token acquired')
} else {
console.warn('[douyin] OAuth response missing token:', body)
uni.showToast({ title: '获取凭证失败', icon: 'none' })
}
} catch (e) {
console.error('[douyin] fetchOAuthToken failed:', e)
uni.showToast({ title: '获取凭证失败: ' + ((e as any)?.errMsg || (e as any)?.message || '网络错误'), icon: 'none' })
}
}
/** 按当前手机号查询可核销券码 */
async function fetchVouchers() {
if (!phone.value) {
if (!phone.value || !accessToken.value) {
vouchers.value = []
vouchersLoaded.value = true
return
}
try {
vouchers.value = await queryVoucherList({ mobile: phone.value, status: 1 })
const res = await uni.request({
url: `https://www.xiaoniren.cn/openapi/ticket/voucher-list?status=1&mobile=${phone.value}&product_id=59221&page=1&per-page=100`,
method: 'GET',
header: {
Authorization: `${tokenType.value} ${accessToken.value}`,
},
})
const body = (res as any).data
const ret = body?.success ? (body?.data?.ret || []) : []
vouchers.value = (ret as VoucherItem[]).filter((v) => String(v.product_id) === '59221')
} catch {
vouchers.value = []
}
selectedId.value = ''
manualCode.value = ''
selectedCode.value = ''
vouchersLoaded.value = true
if (vouchers.value.length === 0) {
showNoVoucher.value = true
}
}
/** xuchao: 选中券码;若带实名信息则回填绑卡表单(可编辑) */
function selectVoucher(item: VoucherItem) {
selectedId.value = item.id
const info = item.voucherRealnameInfo?.[0]
/** 验证券码:调用 voucher-list 接口按 code 查询,命中则自动选中 */
async function verifyInputCode() {
const code = codeInput.value.trim()
if (!code) {
uni.showToast({ title: '请输入券码', icon: 'none' })
return
}
if (!accessToken.value) {
uni.showToast({ title: '获取凭证失败,请重试', icon: 'none' })
return
}
uni.showLoading({ title: '验证中...', mask: true })
try {
const res = await uni.request({
url: `https://www.xiaoniren.cn/openapi/ticket/voucher-list?status=1&code=${encodeURIComponent(code)}&product_id=59221&page=1&per-page=100`,
method: 'GET',
header: {
Authorization: `${tokenType.value} ${accessToken.value}`,
},
})
const body = (res as any).data
const list = body?.success ? (body?.data?.ret || []) : []
const matched = list.find((v: any) => String(v.code) === code)
if (matched) {
// 命中后按手机号查询一致的逻辑展示:回填列表并选中对应券码
vouchers.value = (list as VoucherItem[]).filter((v) => String(v.product_id) === '59221')
uni.hideLoading()
const group = voucherGroups.value.find((g) => g.code === matched.code)
if (group) {
selectVoucher(group)
} else {
selectVoucher({ code: matched.code, count: 1, id: matched.id, voucher: matched as VoucherItem })
}
} else {
uni.hideLoading()
uni.showToast({ title: '该券码未找到可核销的订单', icon: 'none' })
}
} catch {
uni.hideLoading()
uni.showToast({ title: '验证失败', icon: 'none' })
}
}
/** xuchao: 选中券码分组;若带实名信息则回填绑卡表单(可编辑) */
function selectVoucher(group: VoucherGroup) {
selectedCode.value = group.code
const info = group.voucher.voucherRealnameInfo?.[0]
if (info) {
if (info.realname) form.userName = info.realname
if (info.card_id) form.idNo = info.card_id
@@ -301,20 +467,9 @@ function selectVoucher(item: VoucherItem) {
}
}
/** 当前用于提交的券码:有券取选中项,无券取手输 */
function currentCode(): string {
return hasVoucher.value ? selectedVoucher.value?.code || '' : manualCode.value.trim()
}
/** xuchao: 校验表单,返回错误文案,空串表示通过 */
function validate(): string {
if (hasVoucher.value) {
if (!selectedId.value) return '请选择要核销的券号'
} else {
const code = manualCode.value.trim()
if (!code) return '请输入订单券号'
if (!/^\d+$/.test(code)) return '券号格式不正确,仅限数字'
}
if (!selectedCode.value) return '请选择要核销的券号'
if (!form.userName.trim()) return '请输入绑卡人姓名'
if (!form.idNo.trim()) return '请输入证件号码'
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idNo.trim())) return '身份证号格式不正确'
@@ -331,32 +486,73 @@ function onConfirmVerify() {
return
}
showNotice.value = true
fetchPurchaseNotice()
}
/** 弹窗内确认核销:调用激活且绑定接口 */
/** 弹窗内确认核销:先调 sendAndBind,成功后验证券码 */
async function doActivate() {
if (submitting.value) return
submitting.value = true
uni.showLoading({ title: '核销中...', mask: true })
try {
await activateAndBind({
voucherId: selectedVoucher.value?.id || '',
voucherCode: currentCode(),
quantity: 1,
mobile: phone.value,
userName: form.userName.trim(),
idType: docType.value,
idNo: form.idNo.trim(),
faceImg: faceImg.value,
// 第一步:调后端发卡绑定
const sendRes = await uni.request({
url: `${BASE_URL}/open-api/card/sendAndBind`,
method: 'POST',
data: {
orderNo: selectedVoucher.value?.id || '',
typeId: '820093438328133',
userName: form.userName.trim(),
mobile: phone.value,
idNo: form.idNo.trim(),
idType: docType.value,
img: faceImg.value,
appId: 'AISHANGPX_DY_PRD_917406',
sign: '',
},
})
const sendBody = (sendRes as any).data
const sendOk = sendBody?.code === 200 || sendBody?.errcode === 0
if (!sendOk) {
uni.hideLoading()
showNotice.value = false
uni.showToast({ title: sendBody?.msg || sendBody?.errmsg || '发卡绑定失败', icon: 'none' })
return
}
// 第二步:验证券码
const validateRes = await uni.request({
url: 'https://www.xiaoniren.cn/openapi/ticket/validate',
method: 'POST',
header: {
Authorization: `${tokenType.value} ${accessToken.value}`,
},
data: {
data: {
code: '',
id: selectedVoucher.value?.id || '',
quantity: 1,
},
},
})
const validateBody = (validateRes as any).data
const validateOk = validateBody?.success && validateBody?.data?.error_code === 0
if (!validateOk) {
uni.hideLoading()
showNotice.value = false
uni.showToast({ title: validateBody?.data?.message || validateBody?.msg || '验证券码失败', icon: 'none' })
return
}
uni.hideLoading()
showNotice.value = false
uni.showToast({ title: '核销成功', icon: 'success' })
setTimeout(() => uni.switchTab({ url: '/pages/qrcode/index' }), 1200)
vouchersLoaded.value = false
fetchVouchers()
} catch (e) {
// xuchao: 真实请求的错误提示由 request.ts 统一弹出,此处仅收尾,避免重复 toast
console.error('[douyin] activateAndBind failed:', e)
console.error('[douyin] doActivate failed:', e)
uni.hideLoading()
uni.showToast({ title: '核销失败', icon: 'none' })
} finally {
submitting.value = false
}
@@ -364,10 +560,11 @@ async function doActivate() {
/** 选择人脸照片 */
function chooseImage() {
if (uploading.value) return
uni.chooseImage({
count: 1,
sizeType: ['original'],
sourceType: ['album', 'camera'],
sourceType: ['camera'],
success: (res) => handleImage(res.tempFilePaths[0]),
fail: (err) => {
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
@@ -376,6 +573,7 @@ function chooseImage() {
}
async function handleImage(filePath: string) {
uploading.value = true
uni.showLoading({ title: '处理中...' })
try {
const compressed = await compressImage(filePath)
@@ -385,6 +583,7 @@ async function handleImage(filePath: string) {
} catch {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uploading.value = false
uni.hideLoading()
}
}
@@ -436,8 +635,16 @@ function previewFace() {
if (faceImg.value) uni.previewImage({ urls: [faceImg.value] })
}
function previewNotice() {
uni.previewImage({ urls: ['https://aliyunoss.hn12301.net/pxmall/aspx.png'] })
function removePhoto() {
faceImg.value = ''
}
function callService() {
uni.makePhoneCall({ phoneNumber: '0731-88912301' })
}
function closeNoVoucher() {
showNoVoucher.value = false
}
</script>
@@ -448,37 +655,19 @@ function previewNotice() {
font-family: $font-family-body;
}
/* ===== Hero ===== */
.hero {
padding: 48rpx 40rpx 56rpx;
background: linear-gradient(180deg, #fff4e6 0%, #fdeede 55%, #fff 100%);
}
.hero-title {
/* ===== Hero background ===== */
.hero-bg {
display: block;
font-family: $font-family-display;
font-size: 48rpx;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 1.2;
}
.hero-pill {
display: inline-flex;
align-items: center;
margin-top: 20rpx;
padding: 12rpx 24rpx;
border-radius: 30rpx 0 0 10rpx;
background: linear-gradient(90deg, #fac28a 0%, #ff4c17 100%);
}
.hero-pill-text {
font-family: $font-family-display;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: #fff;
width: 100%;
}
/* ===== Body / Section ===== */
.body {
height: calc(100vh - 240rpx);
position: absolute;
top: 300rpx;
padding-bottom: 120rpx;
box-sizing: border-box;
}
.section {
padding: 8rpx 32rpx 24rpx;
@@ -517,6 +706,7 @@ function previewNotice() {
font-size: $font-size-sm;
color: $uv-main-color;
line-height: 48rpx;
font-weight: bold;
}
.hint-red {
display: block;
@@ -561,6 +751,26 @@ function previewNotice() {
color: $uv-tips-color;
}
/* ===== Verify button ===== */
.verify-btn {
flex-shrink: 0;
margin-left: 16rpx;
padding: 10rpx 24rpx;
border-radius: 8rpx;
background: $uv-primary;
transition: opacity $transition-fast;
&:active {
opacity: 0.8;
}
}
.verify-btn-text {
font-size: $font-size-sm;
color: #fff;
font-weight: $font-weight-medium;
}
/* ===== Radio ===== */
.radio {
width: 34rpx;
@@ -686,7 +896,7 @@ function previewNotice() {
flex-direction: column;
text {
font-size: $font-size-sm;
color: $uv-tips-color;
color: #8E8E93;
line-height: 40rpx;
}
}
@@ -751,9 +961,13 @@ function previewNotice() {
.modal-scroll {
height: 700rpx;
}
.modal-img {
.modal-html {
width: 100%;
display: block;
font-size: $font-size-sm;
color: $uv-content-color;
line-height: 1.7;
word-break: break-all;
}
.modal-divider {
height: 1rpx;
@@ -777,11 +991,62 @@ function previewNotice() {
background: $uv-border-color;
}
.modal-foot-text {
font-size: $font-size-sm;
font-size: $font-size-lg;
color: $uv-main-color;
&--red {
color: #ff383c;
}
&--primary {
color: $uv-primary;
font-weight: $font-weight-bold;
}
}
/* ===== No voucher modal ===== */
.no-voucher-title {
display: block;
text-align: center;
font-family: $font-family-display;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 16rpx;
}
.no-voucher-sub {
display: block;
text-align: center;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: #ff383c;
margin-bottom: 24rpx;
}
.no-voucher-body {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 24rpx;
}
.no-voucher-reason {
display: flex;
align-items: flex-start;
font-size: $font-size-sm;
color: $uv-content-color;
line-height: 40rpx;
}
.no-voucher-label {
font-weight: $font-weight-bold;
color: $uv-main-color;
}
.no-voucher-num {
flex-shrink: 0;
margin-right: 8rpx;
}
.no-voucher-text {
flex-shrink: 0;
}
.no-voucher-phone {
color: $uv-primary;
text-decoration: underline;
}
/* ===== ID type popup ===== */
@@ -789,8 +1054,10 @@ function previewNotice() {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
max-height: 70vh;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-bottom: 120rpx;
}
.popup-header {
display: flex;
+12 -7
View File
@@ -12,7 +12,7 @@
<!-- 模块2:商品信息卡片 -->
<view class="info-card">
<text class="product-name">超级玩家卡</text>
<view class="price-row">
<view v-if="!order.thirdOrderNo" class="price-row">
<view class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
@@ -27,6 +27,10 @@
<text class="detail-label">订单编号</text>
<text class="detail-value">{{ order.id }}</text>
</view>
<view v-if="order.thirdOrderNo" class="detail-row">
<text class="detail-label">核销单号</text>
<text class="detail-value">{{ order.thirdOrderNo }}</text>
</view>
<view v-if="order.redeemCode && order.payStatus === 2" class="detail-row">
<text class="detail-label">卡号</text>
<view class="detail-value-row">
@@ -40,15 +44,15 @@
<text class="detail-label">下单时间</text>
<text class="detail-value">{{ order.createTime }}</text>
</view>
<view class="detail-row">
<view v-if="!order.thirdOrderNo" class="detail-row">
<text class="detail-label">支付时间</text>
<text class="detail-value">{{ order.payTime || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">支付渠道</text>
<text class="detail-value">{{ order.payChannel }}</text>
<text class="detail-value">{{ order.thirdOrderNo ? '抖音核销' : order.payChannel }}</text>
</view>
<view class="detail-row">
<view v-if="!order.thirdOrderNo" class="detail-row">
<text class="detail-label">支付金额</text>
<text class="detail-value detail-value--price">¥{{ (order.payAmount / 100).toFixed(2) }}</text>
</view>
@@ -88,7 +92,7 @@
<text class="btn-action__text">立即支付</text>
</view>
</view>
<view v-else-if="Number(order.payStatus) === 2" class="bottom-bar__right">
<view v-else-if="Number(order.payStatus) === 2 && !order.thirdOrderNo" class="bottom-bar__right">
<view class="btn-action btn-action--outline" @tap="handleRefund">
<text class="btn-action__text--outline">申请退款</text>
</view>
@@ -116,6 +120,7 @@ interface OrderDetail {
payTime: string | null
payAmount: number
payChannel: string
thirdOrderNo: string
cardNo: string | null
redeemCode: string | null
isBind: number | null
@@ -137,6 +142,7 @@ const order = ref<OrderDetail>({
payTime: null,
payAmount: 0,
payChannel: '微信支付',
thirdOrderNo: '',
cardNo: null,
redeemCode: null,
isBind: null,
@@ -178,6 +184,7 @@ async function fetchOrderDetail() {
payTime: res.payTime || null,
payAmount: res.payAmount || 0,
payChannel: res.payChannel || '微信支付',
thirdOrderNo: res.thirdOrderNo || '',
cardNo: res.cardNo || null,
redeemCode: res.redeemCode || null,
isBind: res.isBind ?? null,
@@ -291,10 +298,8 @@ async function handleRefund() {
$bar-height: 120rpx;
.page {
min-height: 100vh;
background: $color-background;
padding: $spacing-3;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom) + #{$spacing-3});
font-family: $font-family-body;
}
+33 -3
View File
@@ -57,7 +57,11 @@
<view class="card-product">
<view class="product-name-row">
<text class="product-name">超级玩家卡</text>
<view class="price-current">
<view v-if="order.thirdOrderNo" class="third-order">
<text class="third-order__label">核销单号:</text>
<text class="third-order__no">{{ order.thirdOrderNo }}</text>
</view>
<view v-else class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
</view>
@@ -67,7 +71,7 @@
<!-- 底部信息行 -->
<view class="card-info">
<text class="info-time">{{ order.createTime }}</text>
<text class="info-pay">{{ order.payChannel }}</text>
<text class="info-pay">{{ order.thirdOrderNo ? '抖音核销' : order.payChannel }}</text>
</view>
<!-- 操作按钮 -->
@@ -76,7 +80,7 @@
<text class="btn-primary__text">立即支付</text>
</view>
</view>
<view class="card-actions" v-else-if="order.status === 'activated'">
<view class="card-actions" v-else-if="order.status === 'activated' && !order.thirdOrderNo">
<view class="btn-outline" @tap.stop="showRefundModal(order)">
<text class="btn-outline__text">申请退款</text>
</view>
@@ -135,6 +139,7 @@ interface OrderItem {
payAmount: number
redeemCode: string | null
payChannel: string
thirdOrderNo: string
}
const tabs = [
@@ -195,6 +200,7 @@ async function fetchOrders(append = false) {
payAmount: item.payAmount || 0,
redeemCode: item.redeemCode || null,
payChannel: item.payChannel || '微信支付',
thirdOrderNo: item.thirdOrderNo || '',
}))
if (append) {
orders.value.push(...list)
@@ -464,6 +470,30 @@ onShow(() => {
line-height: 1;
}
/* 抖音核销单号 */
.third-order {
display: flex;
align-items: baseline;
flex-shrink: 0;
max-width: 60%;
}
.third-order__label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.third-order__no {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-main-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Info Row */
.card-info {
display: flex;
+185 -42
View File
@@ -74,26 +74,37 @@
</view>
<view class="user-card">
<text class="section-title">预约人信息</text>
<view class="user-row">
<text class="user-label">姓名</text>
<text class="user-value">{{ userName }}</text>
<text class="section-title">选择预约人</text>
<view v-if="selectedCards.length > 0" class="person-list">
<view
v-for="c in selectedCards"
:key="c.cardNumber"
class="person-item"
:class="{
'person-item--active': selectedCard?.cardNumber === c.cardNumber && c.reserveStatus !== 'reserved',
'person-item--disabled': c.reserveStatus === 'reserved'
}"
@tap="c.reserveStatus !== 'reserved' && (selectedCard = c)"
>
<view class="person-info">
<text class="person-name">{{ c.name }}</text>
<text class="person-id">{{ maskId(c.identityNo) }}</text>
</view>
<text v-if="c.reserveStatus === 'reserved'" class="person-status">已预约</text>
<view v-else class="radio" :class="{ 'radio--on': selectedCard?.cardNumber === c.cardNumber }">
<view v-if="selectedCard?.cardNumber === c.cardNumber" class="radio-dot" />
</view>
</view>
</view>
<view class="user-row">
<text class="user-label">身份证号</text>
<text class="user-value">{{ userIdCard }}</text>
<view v-else-if="selectedDate" class="no-person-tip">
<text class="no-person-tip__text">当日无可用预约人</text>
</view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view>
<view class="notice-card" v-if="bookingNotice">
<text class="notice-title">预约须知</text>
<view class="notice-list">
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">{{ bookingNotice }}</text>
</view>
</view>
<rich-text class="notice-html" :nodes="bookingNoticeHtml" />
<view class="notice-link" @tap="goFaq">
<text class="notice-link__text">查看完整FAQ</text>
</view>
@@ -115,7 +126,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
@@ -126,12 +137,22 @@ interface ReserveOptionSession {
stock: number
}
interface CardOption {
cardNumber: string
name: string
identityNo: string
expiresTime: string
reserveStatus: string
appointmentId: number | null
}
interface DateOption {
key: string
week: string
label: string
stock: number | null
sessions: ReserveOptionSession[]
cards: CardOption[]
disabled?: boolean
reason?: string
}
@@ -142,6 +163,26 @@ const userIdCard = ref('')
const bookingNotice = ref('')
const cardModelName = ref('')
const appointmentDays = ref<number | null>(null)
const selectedCard = ref<CardOption | null>(null)
function maskId(id: string): string {
if (!id) return ''
return id.length >= 15 ? `${id.slice(0, 6)}****${id.slice(-4)}` : id
}
/** 富文本 HTML 清洗:给所有 img 加 max-width:100%,防止超出容器边界 */
function fixRichHtml(html: string): string {
if (!html) return ''
const style = 'max-width:100%;height:auto;display:block;'
return html.replace(/<img(?![^>]*max-width)[^>]*>/gi, (tag: string) => {
if (/style="[^"]*"/i.test(tag)) {
return tag.replace(/style="([^"]*)"/i, (_: string, s: string) => `style="${s};${style}"`)
}
return tag.replace(/<img/i, `<img style="${style}"`)
})
}
const bookingNoticeHtml = computed(() => fixRichHtml(bookingNotice.value))
const reserveParams = ref({
scenicId: 0,
@@ -194,6 +235,16 @@ const selectedDateStock = computed(() => {
return d.stock == null ? 1 : d.stock
})
const selectedCards = computed(() => {
if (!selectedDate.value) return []
return dateOptions.value.find(d => d.key === selectedDate.value)?.cards || []
})
watch(selectedDate, () => {
const available = selectedCards.value.find(c => c.reserveStatus !== 'reserved')
selectedCard.value = available || null
})
onLoad(async (query: any) => {
const resourceId = query?.resourceId
if (!resourceId) return
@@ -216,15 +267,15 @@ onLoad(async (query: any) => {
}
userName.value = res.userName || ''
userIdCard.value = res.userIdCard || ''
bookingNotice.value = Array.isArray(res.bookingNotice) ? res.bookingNotice.join('\n') : (res.bookingNotice || '')
bookingNotice.value = Array.isArray(res.bookingNotice) ? res.bookingNotice.map((n: any) => (typeof n === 'string' ? n : n?.content || n?.text || '')).join('<br/>') : (res.bookingNotice || '')
cardModelName.value = res.cardModelName || ''
appointmentDays.value = (res.dates || []).length
reserveParams.value = {
scenicId: parseInt(resourceId),
channel: 'Channel_SZH',
thirdId: cardModelId,
cardNum: cardNumber,
mobile: phone,
thirdId: String(res.thirdId || res.cardModelId || cardModelId),
cardNum: res.cardNum || '',
mobile: res.phone || phone,
cardModelName: res.cardModelName || ''
}
dateOptions.value = (res.dates || []).map((d: any) => ({
@@ -233,6 +284,7 @@ onLoad(async (query: any) => {
label: d.label,
stock: d.stock,
sessions: d.sessions || [],
cards: d.cards || [],
disabled: d.disabled,
reason: d.reason,
}))
@@ -252,6 +304,10 @@ onLoad(async (query: any) => {
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
}
const mockCards: CardOption[] = [
{ cardNumber: 'CJ0000442472', name: '易佳欣', identityNo: '360312199906103124', expiresTime: '2027-08-05 00:00:00', reserveStatus: 'none', appointmentId: null },
{ cardNumber: 'CJ0000462004', name: '彭丽萍', identityNo: '360322200312042545', expiresTime: '2027-08-05 00:00:00', reserveStatus: 'none', appointmentId: null },
]
dateOptions.value = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1)
@@ -265,6 +321,7 @@ onLoad(async (query: any) => {
label,
stock: mockStocks[i],
sessions: mockSessions[`mock${i}`] || [],
cards: mockCards.map(c => i === 0 && c.cardNumber === 'CJ0000442472' ? { ...c, reserveStatus: 'reserved', appointmentId: 834707842323141 } : c),
}
})
appointmentDays.value = 3
@@ -274,10 +331,18 @@ onLoad(async (query: any) => {
hours: '08:00-18:00',
image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop',
}
bookingNotice.value = '请提前10分钟到达景区检票口;\n入园需出示预约码和身份证;\n如无法按时入园请提前取消预约。'
userName.value = '张三'
userIdCard.value = '360302199001011234'
bookingNotice.value = '<p>请提前10分钟到达景区检票口;</p><p>入园需出示预约码和身份证;</p><p>如无法按时入园请提前取消预约。</p>'
userName.value = '易佳欣'
userIdCard.value = '360312199906103124'
cardModelName.value = '赣湘全域旅游年卡'
reserveParams.value = {
scenicId: parseInt(resourceId),
channel: 'Channel_SZH',
thirdId: cardModelId,
cardNum: 'CJ0000442472',
mobile: phone,
cardModelName: '赣湘全域旅游年卡'
}
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
@@ -285,6 +350,10 @@ onLoad(async (query: any) => {
async function handleSubmit() {
if (!selectedDate.value || submitting.value) return
if (!selectedCard.value) {
uni.showToast({ title: '请选择预约人', icon: 'none' })
return
}
const dateObj = dateOptions.value.find(d => d.key === selectedDate.value)
if (dateObj?.disabled) {
@@ -300,22 +369,23 @@ async function handleSubmit() {
try {
const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const p = reserveParams.value
const card = selectedCard.value
const result = await post<any>(`${BASE_URL}/zhy-api/cardBag/appoint`, {
scenicId: String(p.scenicId),
channel: p.channel,
thirdId: p.thirdId,
cardNum: p.cardNum,
cardNum: card.cardNumber,
mobile: p.mobile,
cardModelName: p.cardModelName,
appointDate: selectedDate.value,
timeId: session ? String(session.id) : '',
name: userName.value,
idNo: userIdCard.value,
name: card.name,
idNo: card.identityNo,
})
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({
url: `/packages/user/pages/reserve/success?appointId=${result}&cardNumber=${p.cardNum}&cardModelId=${p.thirdId}`
url: `/packages/user/pages/reserve/success?appointId=${result}&cardNumber=${card.cardNumber}&cardModelId=${p.thirdId}`
})
}, 1500)
} catch (e: any) {
@@ -648,6 +718,88 @@ $bar-height: 120rpx;
margin-top: 12rpx;
}
.person-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.person-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border: 2rpx solid $uv-border-color;
border-radius: 12rpx;
transition: all $transition-fast;
&--active {
border-color: $uv-primary;
background: rgba(60, 156, 255, 0.04);
}
&--disabled {
opacity: 0.5;
}
}
.person-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.person-name {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $uv-main-color;
}
.person-id {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.person-status {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-placeholder-color;
}
.radio {
width: 34rpx;
height: 34rpx;
border: 2rpx solid $uv-light-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--on {
border-color: $uv-primary;
}
}
.radio-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background: $uv-primary;
}
.no-person-tip {
padding: 16rpx 0;
}
.no-person-tip__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-placeholder-color;
}
.notice-card {
margin: 24rpx 32rpx 0;
background: #eef4ff;
@@ -668,27 +820,18 @@ $bar-height: 120rpx;
margin-bottom: 16rpx;
}
.notice-item {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
}
.notice-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $uv-primary;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: 12rpx;
}
.notice-text {
.notice-html {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
display: block;
margin-bottom: 16rpx;
word-break: break-all;
:deep(p) {
margin: 0 0 8rpx;
}
}
.notice-link {
+13 -9
View File
@@ -63,14 +63,14 @@
<!-- 操作 -->
<view class="record-actions">
<view
v-if="r.status === 'pending' && canCancel(r.date)"
v-if="r.canCancel"
class="btn-sm btn-sm--outline"
@tap="showCancelModal(r)"
>
<text class="btn-sm__text--outline">取消预约</text>
</view>
<view
v-if="r.status === 'pending'"
v-if="r.showEntryCode && r.status !== 'cancelled'"
class="btn-sm btn-sm--outline"
@tap="showCode(r)"
>
@@ -215,6 +215,8 @@ interface ReserveRecord {
verifyPhoto?: string
cardInstruction?: string
cardModelId?: string
canCancel?: boolean
showEntryCode?: boolean
}
const tabs = [
@@ -288,6 +290,8 @@ async function fetchRecords(append = false) {
verifyPhoto: item.verifyPhoto || '',
cardInstruction: item.cardInstruction || '',
cardModelId: item.cardModelId || item.thirdId || '',
canCancel: !!item.canCancel,
showEntryCode: !!item.showEntryCode,
}))
if (append) {
records.value.push(...list)
@@ -321,13 +325,6 @@ watch(activeTab, () => {
fetchRecords()
})
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()
@@ -556,11 +553,18 @@ function previewPhoto(url: string) {
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; }
+1 -7
View File
@@ -38,13 +38,7 @@
<text class="info-label">预约编号</text>
<text class="info-value">{{ reserveId }}</text>
</view>
<view v-if="status === 1" class="info-row info-row--last">
<text class="info-label">入园凭证</text>
<view class="code-btn" @tap="showCode">
<text class="code-btn__text">查看入园码</text>
</view>
</view>
<view v-else class="info-row info-row--last">
<view v-if="status !== 1" class="info-row info-row--last">
<text class="info-label">预约状态</text>
<text class="info-value">{{ statusLabel }}</text>
</view>