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
+3 -3
View File
@@ -4,11 +4,11 @@
*/
// 首页 Banner
export const IMG_HOME_BANNER = 'https://aliyunoss.hntgov.net/image/2026/04/14/d2bf03167e484ba59f7c2d62879ad07f.jpg'
export const IMG_HOME_BANNER = 'https://aliyunoss.hn12301.net/image/2026/04/14/d2bf03167e484ba59f7c2d62879ad07f.jpg'
// 年卡购买页图片
export const IMG_CARD_BUY_1 = 'https://aliyunoss.hntgov.net/image/2026/06/23/70e9f4b3b5fd49adb1ff06edcb8fa032.jpg'
export const IMG_CARD_BUY_2 = 'https://aliyunoss.hntgov.net/image/2026/06/25/f3e45bcb3b4f4a2997f107cc6335a1bc.jpg'
export const IMG_CARD_BUY_1 = 'https://aliyunoss.hn12301.net/image/2026/06/23/70e9f4b3b5fd49adb1ff06edcb8fa032.jpg'
export const IMG_CARD_BUY_2 = 'https://aliyunoss.hn12301.net/image/2026/06/25/f3e45bcb3b4f4a2997f107cc6335a1bc.jpg'
// 入园码页面
export const IMG_QRCODE_LOGO = 'https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/%E5%88%9D%E5%BF%83%E6%BA%90%20%E6%B9%98%E9%84%82%E8%B5%A3%E6%96%87%E6%97%85%E4%B8%80%E5%8D%A1%E9%80%9A_1777447994254.png'
@@ -0,0 +1,151 @@
<template>
<view class="page">
<view class="detail-card">
<text class="detail-title">{{ title }}</text>
<text class="detail-date" v-if="date">{{ date }}</text>
<view class="divider" v-if="content" />
<template v-for="(part, i) in parts" :key="i">
<rich-text
v-if="part.html"
class="detail-content"
:nodes="fixRichHtml(part.html)"
/>
<video
v-else-if="part.videoSrc"
class="detail-video"
:src="part.videoSrc"
controls
object-fit="contain"
/>
</template>
</view>
<video
v-if="videoUrl"
class="detail-video"
:src="videoUrl"
controls
object-fit="contain"
/>
<view class="safe-bottom" />
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
interface ContentPart {
html?: string
videoSrc?: string
}
function fixRichHtml(html: string): string {
if (!html) return ''
const style = 'max-width:100%;height:auto;display:block;'
return html
.replace(/<video[\s\S]*?<\/video>/gi, '')
.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}"`)
})
}
function parseParts(html: string): ContentPart[] {
const parts: ContentPart[] = []
let lastIndex = 0
const videoRegex = /<video[\s\S]*?<\/video>/gi
let m: RegExpExecArray | null
while ((m = videoRegex.exec(html)) !== null) {
if (m.index > lastIndex) parts.push({ html: html.slice(lastIndex, m.index) })
const srcMatch =
m[0].match(/<source[^>]*src=["']([^"']+)["']/i) ||
m[0].match(/<video[^>]*src=["']([^"']+)["']/i)
parts.push({ videoSrc: srcMatch ? srcMatch[1] : '' })
lastIndex = m.index + m[0].length
}
if (lastIndex < html.length) parts.push({ html: html.slice(lastIndex) })
return parts
}
const title = ref('公告')
const date = ref('')
const content = ref('')
const videoUrl = ref('')
const parts = computed(() => parseParts(content.value))
onLoad(() => {
const detail = uni.getStorageSync('notice_detail')
if (detail) {
title.value = detail.title || '公告'
date.value = detail.date || ''
content.value = detail.content || ''
videoUrl.value = detail.videoUrl || ''
uni.removeStorageSync('notice_detail')
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #F7F8FA;
font-family: 'SourceHanSerifCN', '思源宋体', -apple-system, 'PingFang SC', sans-serif;
}
.detail-card {
margin: 24rpx 30rpx;
background: #FFFFFF;
border-radius: 24rpx;
padding: 40rpx 32rpx;
border: 1rpx solid rgba(0, 0, 0, 0.04);
}
.detail-title {
display: block;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 1.4;
letter-spacing: -0.3rpx;
}
.detail-date {
display: block;
margin-top: 16rpx;
font-size: $font-size-sm;
font-weight: $font-weight-normal;
color: $uv-placeholder-color;
}
.divider {
height: 1rpx;
background: rgba(0, 0, 0, 0.06);
margin: 32rpx 0;
}
.detail-content {
font-size: $font-size-base;
font-weight: $font-weight-normal;
color: $uv-main-color;
line-height: 1.8;
::v-deep img {
max-width: 100%;
height: auto;
display: block;
}
}
.detail-video {
width: 690rpx;
margin-left: 30rpx;
height: 400rpx;
background: #000;
}
.safe-bottom {
height: calc(40rpx + env(safe-area-inset-bottom));
}
</style>
+37 -53
View File
@@ -8,25 +8,17 @@
:style="{ animationDelay: index * 60 + 'ms' }"
@click="openDetail(item)"
>
<!-- 第一行:标题 + 日期 + 箭头 -->
<view class="card-header">
<view class="header-text">
<text class="card-title">{{ item.title }}</text>
<text class="card-summary">{{ item.content }}</text>
<text class="card-title">{{ item.title }}</text>
<view class="card-footer">
<text class="card-date">发布时间:{{ item.date }}</text>
<view class="card-action-row">
<text class="card-action">查看详情</text>
<view class="card-arrow" />
</view>
<text class="card-date">{{ item.date }}</text>
<view class="card-arrow" />
</view>
</view>
</view>
<!-- 公告详情弹窗 -->
<AnnouncementModal
v-model="showModal"
:title="modalTitle"
:body="modalBody"
/>
<!-- 底部安全区 -->
<view class="safe-bottom" />
</view>
@@ -34,7 +26,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import AnnouncementModal from '../../../components/announcement-modal.vue'
import { post } from '../../../api/request'
import type { BannerItem } from '../../../api/types'
@@ -43,6 +34,7 @@ interface NoticeItem {
title: string
date: string
content: string
videoUrl: string
}
const notices = ref<NoticeItem[]>([])
@@ -55,21 +47,21 @@ onMounted(async () => {
title: n.title,
date: n.createdTime ? n.createdTime.slice(0, 10) : '',
content: n.content || '',
videoUrl: (n as any).videoUrl || '',
}))
} catch {
// 静默失败
}
})
/** 弹窗状态 */
const showModal = ref(false)
const modalTitle = ref('公告')
const modalBody = ref('')
/** 打开公告详情页 */
function openDetail(item: NoticeItem) {
modalTitle.value = item.title
modalBody.value = item.content
showModal.value = true
uni.setStorageSync('notice_detail', {
title: item.title,
content: item.content || '',
videoUrl: item.videoUrl || '',
})
uni.navigateTo({ url: '/packages/content/pages/notice-detail' })
}
</script>
@@ -144,63 +136,55 @@ function openDetail(item: NoticeItem) {
}
}
/* ====== 卡片内容行 ====== */
.card-header {
display: flex;
align-items: center;
}
/* 左侧文字区(标题 + 摘要) */
.header-text {
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
/* 标题 */
.card-title {
display: block;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 1.3;
line-height: 1.4;
letter-spacing: -0.3rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8rpx;
}
/* 摘要 */
.card-summary {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: $font-size-sm;
font-weight: $font-weight-normal;
color: $uv-tips-color;
line-height: 1.6;
/* 发布行:时间 + 查看详情按钮在一排 */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
}
/* 日期 */
/* 发布时间 */
.card-date {
font-size: 22rpx;
font-weight: $font-weight-normal;
color: $uv-placeholder-color;
flex-shrink: 0;
letter-spacing: 0.5rpx;
}
.card-action-row {
display: flex;
align-items: center;
}
.card-action {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-primary;
}
/* 右箭头 */
.card-arrow {
width: 14rpx;
height: 14rpx;
border-right: 3rpx solid #dadbde;
border-bottom: 3rpx solid #dadbde;
border-right: 3rpx solid $uv-primary;
border-bottom: 3rpx solid $uv-primary;
transform: rotate(-45deg);
flex-shrink: 0;
margin-left: 16rpx;
margin-left: 12rpx;
}
/* ====== 底部安全区 ====== */
+26 -7
View File
@@ -95,7 +95,9 @@
<view class="benefit-meta">
<view class="icon-wrap icon-crown" />
<text class="remaining-text">
{{ card.remainingCount > 0 ? '全年剩余' + card.remainingCount + '次' : '全年可用' + card.freeCount + '次' }}
<template v-if="card.preferential === 'DISCOUNT'">可享{{ card.discountPercentage / 10 }}折优惠</template>
<template v-else-if="card.preferential === 'PREFERENTIAL'">可享特惠价{{ card.discountAmount / 100 }}元/次</template>
<template v-else>{{ card.remainingCount > 0 ? '全年剩余' + card.remainingCount + '次' : '全年可用' + card.freeCount + '次' }}</template>
</text>
<text v-if="card.bookingDeadline" class="deadline-text">{{ card.bookingDeadline }}</text>
</view>
@@ -129,10 +131,10 @@
<!-- 模块5:Tab内容区域 -->
<view class="tab-content">
<view v-if="activeTab === 'notice'" class="content-section">
<rich-text class="notice-html" :nodes="scenic.bookingNoticeHtml" />
<rich-text class="notice-html" :nodes="bookingNoticeHtml" />
</view>
<view v-if="activeTab === 'intro'" class="content-section">
<rich-text class="notice-html" :nodes="scenic.introduction" />
<rich-text class="notice-html" :nodes="introductionHtml" />
</view>
</view>
@@ -145,7 +147,7 @@
<text class="notice-popup-title">{{ storeNotice.title }}</text>
</view>
<scroll-view class="notice-popup-body" scroll-y>
<rich-text :nodes="storeNotice.content" />
<rich-text :nodes="storeNoticeHtml" />
</scroll-view>
<view class="notice-popup-footer">
<view class="notice-popup-btn" @click="noticeVisible = false">
@@ -166,7 +168,7 @@ import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { post, getToken } from '../../../api/request'
interface NoticeItem { title: string; content: string }
interface CardBenefit { type: string; cardName: string; skuName: string; remainingCount: number; freeCount: number; bookingDeadline: string; cardModelId: string; resourceId: string; goodsId: string }
interface CardBenefit { type: string; cardName: string; skuName: string; remainingCount: number; freeCount: number; bookingDeadline: string; cardModelId: string; resourceId: string; goodsId: string; preferential: string; discountPercentage: number; discountAmount: number }
interface ScenicDetail {
id: number; name: string; price: number; freeCount: number; level: string
hours: string; needReservation: string; bookingDeadline: string
@@ -176,6 +178,23 @@ interface ScenicDetail {
}
const scenic = ref<ScenicDetail>({ tags: [], images: [], cards: [], bookingNotice: [] } as unknown as ScenicDetail)
/** 富文本 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 introductionHtml = computed(() => fixRichHtml(scenic.value.introduction))
const bookingNoticeHtml = computed(() => fixRichHtml(scenic.value.bookingNoticeHtml))
const storeNoticeHtml = computed(() => fixRichHtml(storeNotice.value.content))
const activeTab = ref<'notice' | 'intro'>('notice')
const currentSlide = ref(0)
const loading = ref(true)
@@ -303,7 +322,7 @@ async function onBookClick(card: CardBenefit) {
if (res?.hasValidCard) {
uni.navigateTo({
url: `/packages/user/pages/reserve/index?cardNumber=${res.cardNumber}&resourceId=${res.resourceId}&cardModelId=${card.cardModelId}`,
url: `/packages/user/pages/reserve/index?resourceId=${res.resourceId || card.resourceId}&cardModelId=${card.cardModelId}`,
})
} else {
if (String(card.cardModelId) === '820093340659013') {
@@ -716,7 +735,7 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
margin: 0 $spacing-3;
border-radius: 0 0 $radius-lg $radius-lg;
box-shadow: $shadow-sm;
padding: $spacing-3;
padding: 24rpx;
}
.content-section {
+24 -111
View File
@@ -1,20 +1,7 @@
<template>
<view class="page">
<!-- 固定自定义导航栏 -->
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content" :style="{ height: navContentHeight + 'px' }">
<view class="nav-side">
<view class="nav-back" @click="goBack">
<view class="back-arrow" />
</view>
</view>
<text class="nav-title">{{ navTitle }}</text>
<view class="nav-side" />
</view>
</view>
<!-- 页面主体(避开导航栏) -->
<view class="page-body" :style="{ paddingTop: navTotalHeight + 'px' }">
<!-- 页面主体 -->
<view class="page-body">
<!-- 吸顶搜索筛选区(跟随 scroll-view 滚动) -->
<view class="sticky-header">
<!-- 搜索框 -->
@@ -96,7 +83,7 @@
<!-- 名称 + 挂牌价 同行 -->
<view class="name-price-row">
<text class="scenic-name">{{ scenic.name }}</text>
<view class="price-group">
<view v-if="scenic.price && Number(scenic.price) > 0" class="price-group">
<text class="price-symbol">¥</text>
<text class="price-value">{{ scenic.price }}</text>
</view>
@@ -118,12 +105,20 @@
<!-- 剩余次数 + 预约标签 同行 -->
<view class="benefit-row">
<view v-if="scenic.remainingCount > 0" class="remaining-tag">
<text>全年还剩{{ scenic.remainingCount }}次</text>
<view v-if="scenic.preferential === 'DISCOUNT'" class="remaining-tag">
<text>可享{{ scenic.discountPercentage / 10 }}折优惠</text>
</view>
<view v-else-if="scenic.freeCount > 0" class="remaining-tag">
<text>全年最多可用{{ scenic.freeCount }}次</text>
<view v-else-if="scenic.preferential === 'PREFERENTIAL'" class="remaining-tag">
<text>可享特惠价{{ scenic.discountAmount / 100 }}元/次</text>
</view>
<template v-else>
<view v-if="scenic.remainingCount > 0" class="remaining-tag">
<text>全年还剩{{ scenic.remainingCount }}次</text>
</view>
<view v-else-if="scenic.freeCount > 0" class="remaining-tag">
<text>全年最多可用{{ scenic.freeCount }}次</text>
</view>
</template>
<view class="benefit-tag" :class="scenic.needReservation ? 'warning' : 'success'">
<text>{{ scenic.needReservation ? '需预约' : '免预约' }}</text>
</view>
@@ -213,6 +208,9 @@ interface ScenicItem {
type: string
region: string
image: string
preferential: string
discountPercentage: number
discountAmount: number
}
interface RegionNode {
@@ -221,36 +219,11 @@ interface RegionNode {
children: RegionNode[] | null
}
// ========== 导航栏动态高度 ==========
const statusBarHeight = ref(20)
const navContentHeight = ref(44)
const navTotalHeight = ref(64)
// ========== 导航栏(使用 pages.json 原生导航栏) ==========
const stickyHeaderHeight = ref(100)
const windowHeight = ref(0)
const scrollViewHeight = computed(() => Math.max(0, windowHeight.value - navTotalHeight.value - stickyHeaderHeight.value))
;(function initNavBar() {
try {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight || 20
} catch {
statusBarHeight.value = 20
}
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
const capsuleHeight = menuBtn.height
const capsuleTop = menuBtn.top
const capsuleBottom = capsuleTop + capsuleHeight
const contentH = (capsuleBottom - statusBarHeight.value) + (capsuleTop - statusBarHeight.value)
navContentHeight.value = Math.max(contentH, 44)
navTotalHeight.value = statusBarHeight.value + navContentHeight.value
} catch {
navContentHeight.value = 44
navTotalHeight.value = statusBarHeight.value + 44
}
})()
stickyHeaderHeight.value = 100
const scrollViewHeight = computed(() => Math.max(0, windowHeight.value - stickyHeaderHeight.value))
// ========== URL 参数 ==========
const cardNumber = ref('')
@@ -339,7 +312,10 @@ async function fetchScenicList(resetPage = true) {
tips: s.tips || '',
type: s.type || s.category || '',
region: s.region?.name || s.region || '',
image: s.image || s.pic || s.coverImage || '',
image: s.image || s.pic || s.coverImage || 'https://aliyunoss.hn12301.net/pxmall/px_jqbj.png',
preferential: s.preferential || 'FREE',
discountPercentage: s.discountPercentage ?? 0,
discountAmount: s.discountAmount ?? 0,
}))
if (resetPage) {
@@ -591,15 +567,6 @@ function clearSearch() {
fetchScenicList()
}
function goBack() {
const pages = getCurrentPages()
if (pages.length <= 1) {
uni.switchTab({ url: '/pages/index/index' })
} else {
uni.navigateBack()
}
}
function onCardClick(scenic: ScenicItem) {
uni.navigateTo({
url: `/packages/content/pages/scenic-detail?shopId=${scenic.shopId}`,
@@ -627,60 +594,6 @@ onShareTimeline(() => ({
font-family: 'SourceHanSerifCN', '思源宋体', serif;
}
/* ====== 固定导航栏 ====== */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: $color-card;
box-shadow: $shadow-sm;
}
.nav-content {
display: flex;
align-items: center;
padding: 0 $spacing-3;
}
.nav-back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-base;
flex-shrink: 0;
transition: background $transition-fast;
&:active {
background: $color-muted;
}
}
.back-arrow {
width: 20rpx;
height: 20rpx;
border-left: 4rpx solid $color-foreground;
border-bottom: 4rpx solid $color-foreground;
transform: rotate(45deg);
margin-left: 6rpx;
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-foreground;
}
.nav-side {
width: 80rpx;
flex-shrink: 0;
}
/* ====== 页面主体 ====== */
.page-body {
display: flex;
+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>
+8 -3
View File
@@ -15,7 +15,7 @@
{
"path": "pages/qrcode/index",
"style": {
"navigationBarTitleText": "入园码"
"navigationBarTitleText": "旅游卡"
}
},
{
@@ -155,8 +155,7 @@
{
"path": "pages/scenic-list",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": ""
"navigationBarTitleText": "全部合作景区"
}
},
{
@@ -171,6 +170,12 @@
"navigationBarTitleText": "通知公告"
}
},
{
"path": "pages/notice-detail",
"style": {
"navigationBarTitleText": "公告详情"
}
},
{
"path": "pages/usage-guide",
"style": {
+48 -32
View File
@@ -66,15 +66,15 @@
<view class="promo-price-row">
<view class="promo-price-block">
<text class="promo-currency">¥</text>
<text class="promo-price">198</text>
<text class="promo-price">{{ promoPrice }}</text>
</view>
<view class="promo-value-block">
<text class="promo-value">¥3800+</text>
<text class="promo-save">立省1万+</text>
<text class="promo-value">{{ promoShowPrice }}</text>
<text v-if="promoSave" class="promo-save">{{ promoSave }}</text>
</view>
</view>
<text class="promo-desc">一卡畅玩赣湘边核心景区</text>
<text class="promo-desc-sub">含武功山/君澜温泉/方特/橘子洲小火车/湘江游轮等</text>
<text class="promo-desc-sub">含武功山/方特/橘子洲小火车/湘江游轮等</text>
<text class="promo-highlight">单次最高可省299 | 单景区全年最高可免6次</text>
<view class="promo-cta">
<text class="cta-label">立即抢购|专属特惠</text>
@@ -113,7 +113,7 @@
<view class="spot-body">
<text class="spot-name">{{ spot.name }}</text>
<view class="spot-meta">
<text class="spot-price">¥{{ spot.price }}</text>
<text v-if="spot.price && Number(spot.price) > 0" class="spot-price">¥{{ spot.price }}</text>
<view class="spot-benefit">
<text>{{ spot.benefit }}</text>
</view>
@@ -125,20 +125,14 @@
</view>
</scroll-view>
<!-- 公告弹窗 -->
<AnnouncementModal
v-model="showNoticeModal"
:title="noticeModalTitle"
:body="noticeModalBody"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import AnnouncementModal from '../../components/announcement-modal.vue'
import { post } from '../../api/request'
import { BASE_URL } from '../../config'
import type { BannerItem } from '../../api/types'
import { useUserStore } from '../../store/user'
@@ -161,12 +155,40 @@ const displayNotices = computed(() =>
)
const userStore = useUserStore()
const CARD_GOODS_ID = '821926819630853'
const promoPrice = ref('198')
const promoShowPrice = ref('¥3800+')
const promoSave = ref('立省1万+')
function formatPrice(cents: number): string {
const n = cents / 100
return Number.isInteger(n) ? String(n) : n.toFixed(2)
}
async function fetchCardPrice() {
try {
const data = await post<any>(`${BASE_URL}/open-api/xgh/cardModelDetail?goodsId=${CARD_GOODS_ID}`, {})
const sku = data?.sku?.[0]
const buy = sku?.buyPrice || 0
const show = sku?.showPrice || 0
if (buy > 0) promoPrice.value = formatPrice(buy)
if (show > 0) {
promoShowPrice.value = `¥${formatPrice(show)}`
const save = show - buy
promoSave.value = save > 0 ? `立省${formatPrice(save)}` : ''
}
} catch {
// 保留默认展示值
}
}
onMounted(async () => {
fetchCardPrice()
try {
const [bannerData, noticeData, spotsData] = await Promise.all([
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'BANNER' }),
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'NOTICE' }),
post<any>('/mp-api/mpScenic/queryScenicList', { channel: 'pingxiang', pageNo: 1, pageSize: 6, phone: uni.getStorageSync('phone') || '' }),
post<any>('/mp-api/mpScenic/queryScenicList', { channel: 'pingxiang', pageNo: 1, pageSize: 8, phone: uni.getStorageSync('phone') || '' }),
])
banners.value = bannerData || []
announcements.value = (noticeData || []).map(n => ({
@@ -174,14 +196,14 @@ onMounted(async () => {
typeLabel: '公告',
}))
const list = spotsData?.list || spotsData || []
hotSpots.value = (Array.isArray(list) ? list : []).slice(0, 6).map((s: any) => ({
hotSpots.value = (Array.isArray(list) ? list : []).slice(0, 8).map((s: any) => ({
id: s.id || s.scenicId,
shopId: s.shopId || s.id || s.scenicId,
name: s.name || s.scenicName,
price: ((s.price || s.ticketPrice || 0) / 100).toFixed(0),
benefit: s.benefit || `免门票`,
benefit: s.preferential === 'DISCOUNT' ? '享折扣' : s.preferential === 'PREFERENTIAL' ? '享优惠' : '免门票',
region: s.region?.name || s.region || s.city || '',
image: s.image || s.pic || s.coverImage || '',
image: s.image || s.pic || s.coverImage || 'https://aliyunoss.hn12301.net/pxmall/px_jqbj.png',
}))
} catch {
// 静默失败
@@ -213,11 +235,11 @@ function onBannerClick(banner: BannerItem) {
/** 金刚区操作项 */
const quickActions = [
{ label: '抖音核销', icon: 'https://aliyunoss.hn12301.net/pxmall/douyin.png' },
{ label: '我要绑卡', icon: 'https://aliyunoss.hn12301.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hn12301.net/pxmall/reserve.png' },
{ label: '我要入园', icon: 'https://aliyunoss.hn12301.net/pxmall/check.png' },
{ label: '常见问题', icon: 'https://aliyunoss.hn12301.net/pxmall/faq.png' },
{ label: '抖音核销', icon: 'https://aliyunoss.hn12301.net/pxmall/douyin.png' },
]
/** 处理金刚区点击 */
@@ -243,10 +265,6 @@ function onQuickAction(item: { label: string }) {
return
}
if (item.label === '抖音核销') {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
uni.navigateTo({ url: '/packages/user/pages/douyin-verify' })
return
}
@@ -265,16 +283,14 @@ function onNoticeTouchStart() { swiperAutoplay.value = false }
/** 触摸结束后恢复自动播放 */
function onNoticeTouchEnd() { swiperAutoplay.value = true }
/** 公告弹窗 */
const showNoticeModal = ref(false)
const noticeModalTitle = ref('公告')
const noticeModalBody = ref('')
/** 打开公告详情弹窗 */
/** 打开公告详情页 */
function openNotice(item: BannerItem & { typeLabel: string }) {
noticeModalTitle.value = `${item.typeLabel}`
noticeModalBody.value = item.content || ''
showNoticeModal.value = true
uni.setStorageSync('notice_detail', {
title: item.title,
content: item.content || '',
videoUrl: (item as any).videoUrl || '',
})
uni.navigateTo({ url: '/packages/content/pages/notice-detail' })
}
/** 跳转到公告列表 */
@@ -284,7 +300,7 @@ function goMoreNotice() {
/** 跳转到年卡购买页 */
function goCardDetail() {
uni.navigateTo({ url: '/packages/user/pages/buy?goodsId=821926819630853' })
uni.navigateTo({ url: `/packages/user/pages/buy?goodsId=${CARD_GOODS_ID}` })
}
/** 跳转到联名卡购买页 */
+13 -1
View File
@@ -66,6 +66,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '../../store/user'
import { showToast } from '../../utils'
@@ -74,6 +75,13 @@ const agreed = ref(false)
const loading = ref(false)
const showContent = ref(false)
const preLoginCode = ref('')
const redirect = ref('')
onLoad((query) => {
if (query?.redirect) {
redirect.value = query.redirect as string
}
})
onMounted(async () => {
try {
@@ -137,7 +145,11 @@ async function handleLogin(e: AnyObject) {
showToast('登录成功', 'success')
setTimeout(() => {
uni.navigateBack()
if (redirect.value) {
uni.redirectTo({ url: redirect.value })
} else {
uni.navigateBack()
}
}, 1000)
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : '登录失败')
+2
View File
@@ -366,6 +366,8 @@ function formatPhone(phone?: string) {
/* Logout button */
.btn-area {
position: relative;
z-index: 3;
padding: $spacing-5 $spacing-3;
}
+6 -6
View File
@@ -84,7 +84,7 @@
<!-- 入园码二维码 -->
<view class="qrcode-section" v-if="!showFaceModal && !isLeaving">
<text class="qrcode-title">入园码</text>
<text class="qrcode-title">入园提示</text>
<view>为确保信息安全,现入园核销只能使用人脸核销。</view>
<!-- <view class="qrcode-box"> -->
<!-- 为确保信息安全,现入园核销只能使用人脸核销。 -->
@@ -344,10 +344,10 @@ function onFaceModalConfirm() {
}
const featureActions = [
{ label: '我要绑定', icon: 'https://aliyunoss.hntgov.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hntgov.net/pxmall/reserve.png' },
{ label: '预约/使用记录', icon: 'https://aliyunoss.hntgov.net/pxmall/record.png' },
{ label: '使用说明', icon: 'https://aliyunoss.hntgov.net/pxmall/scenic.png' },
{ label: '我要绑定', icon: 'https://aliyunoss.hn12301.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hn12301.net/pxmall/reserve.png' },
{ label: '预约/使用记录', icon: 'https://aliyunoss.hn12301.net/pxmall/record.png' },
{ label: '使用说明', icon: 'https://aliyunoss.hn12301.net/pxmall/scenic.png' },
]
function onFeatureClick(item: { label: string }) {
@@ -413,7 +413,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
<style lang="scss" scoped>
.page { min-height: 100vh; background: $color-background; font-family: $font-family-body; }
.scroll-content { height: 100vh; }
.scroll-content { }
/* === 加载中 === */
.loading-state {