feat: 客服热线弹窗、取消预约文案与旅游卡页布局优化

- 客服热线改为弹窗拉取 mpContent/queryList 客服信息(SERVICE_INFO),富文本电话可点击拨号
- 取消预约确认弹窗展示具体预约日期与景区名称,取消后隐藏取消按钮
- makePhoneCall 用户取消时静默处理,不再上报异常
- 景区列表搜索图标改为 OSS 图片
- 旅游卡页:人脸头像方形竖版、信息单行靠右、入园提示移至持卡人信息下方等布局优化
- project.config.json appid 同步为 wxae3b39ac443fedc3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-07 18:29:12 +08:00
co-authored by Claude Opus 4.8
parent f0911c67a0
commit 08e2f6888b
8 changed files with 312 additions and 90 deletions
+1 -1
View File
@@ -36,6 +36,6 @@
"tabIndent": "auto",
"tabSize": 2
},
"appid": "wx636bc46a6570e9fd",
"appid": "wxae3b39ac443fedc3",
"simulatorPluginLibVersion": {}
}
+1
View File
@@ -216,6 +216,7 @@ export interface BannerItem {
title: string
content: string | null
imageUrl: string
videoUrl?: string | null
linkUrl: string
sortOrder: number
category: string | null
+1 -1
View File
@@ -296,7 +296,7 @@ function onHoursClick() {
uni.showToast({ title: '营业时间仅供参考', icon: 'none' })
}
function onPhoneClick() {
uni.makePhoneCall({ phoneNumber: scenic.value.phone })
uni.makePhoneCall({ phoneNumber: scenic.value.phone, fail: () => {} })
}
function onCopyAddress() {
uni.setClipboardData({
+5 -25
View File
@@ -6,10 +6,11 @@
<view class="sticky-header">
<!-- 搜索框 -->
<view class="search-box">
<view class="search-icon">
<view class="icon-circle" />
<view class="icon-handle" />
</view>
<image
class="search-icon"
src="https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/fangdajing.png"
mode="aspectFit"
/>
<input
class="search-input"
v-model="searchText"
@@ -622,32 +623,11 @@ onShareTimeline(() => ({
}
.search-icon {
position: relative;
width: 32rpx;
height: 32rpx;
margin-right: $spacing-2;
}
.icon-circle {
width: 20rpx;
height: 20rpx;
border: 3rpx solid $uv-tips-color;
border-radius: 50%;
position: absolute;
top: 0;
left: 0;
}
.icon-handle {
width: 10rpx;
height: 3rpx;
background: $uv-tips-color;
position: absolute;
bottom: 4rpx;
right: 0;
transform: rotate(45deg);
}
.search-input {
flex: 1;
font-size: $font-size-base;
+1 -1
View File
@@ -640,7 +640,7 @@ function removePhoto() {
}
function callService() {
uni.makePhoneCall({ phoneNumber: '0731-88912301' })
uni.makePhoneCall({ phoneNumber: '0731-88912301', fail: () => {} })
}
function closeNoVoucher() {
+9 -2
View File
@@ -63,7 +63,7 @@
<!-- 操作 -->
<view class="record-actions">
<view
v-if="r.canCancel"
v-if="r.canCancel && r.status !== 'cancelled'"
class="btn-sm btn-sm--outline"
@tap="showCancelModal(r)"
>
@@ -110,7 +110,7 @@
<view class="cancel-modal">
<text class="cancel-modal__title">取消预约</text>
<view class="cancel-modal__body">
<text class="cancel-modal__text">确定取消该预约吗?未提前取消且未入园将扣除1次年卡使用次数,权益不可恢复。</text>
<text class="cancel-modal__text">确定取消 {{ formatReserveDate(cancelRecord?.date) }}{{ cancelRecord?.scenicName }} 的预约吗?</text>
</view>
<view class="cancel-modal__actions">
<view class="btn-modal btn-modal--outline" @tap="cancelPopup?.close()">
@@ -325,6 +325,13 @@ watch(activeTab, () => {
fetchRecords()
})
/** 将预约日期(如 2026-08-05)格式化为「2026 年 8 月 5 日」 */
function formatReserveDate(date?: string): string {
const m = (date || '').match(/(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})/)
if (!m) return date || ''
return `${m[1]} 年 ${Number(m[2])} 月 ${Number(m[3])} 日`
}
function showCancelModal(r: ReserveRecord) {
cancelRecord.value = r
cancelPopup.value?.open()
+262 -3
View File
@@ -45,7 +45,7 @@
<text class="menu-text">常见问题</text>
<view class="menu-chevron" />
</view>
<view class="menu-item menu-item--last" @click="callService">
<view class="menu-item menu-item--last" @click="goService">
<view class="menu-icon-wrap menu-icon-wrap--error">
<view class="icon-phone" />
</view>
@@ -61,11 +61,65 @@
<text>退出登录</text>
</view>
</view>
<!-- 客服热线弹窗 -->
<uni-popup ref="servicePopup" type="center" :mask-click="true">
<view class="service-modal">
<text class="service-modal__title">客服热线</text>
<scroll-view scroll-y class="service-modal__scroll">
<view v-if="serviceLoading" class="service-modal__state">
<text class="service-modal__tips">加载中...</text>
</view>
<view v-else-if="serviceList.length === 0" class="service-modal__state">
<text class="service-modal__tips">暂无客服信息</text>
</view>
<template v-else>
<view v-for="item in serviceList" :key="item.id" class="service-item">
<text class="service-item__title">{{ item.title }}</text>
<view v-if="item.content" class="service-item__divider" />
<template v-for="(part, i) in parseParts(item.content || '')" :key="i">
<rich-text
v-if="part.html"
class="service-item__content"
:nodes="fixRichHtml(part.html)"
/>
<text
v-else-if="part.phone"
class="service-item__phone"
@click="callPhone(part.phone)"
>{{ part.phone }}</text>
<video
v-else-if="part.videoSrc"
class="service-item__video"
:src="part.videoSrc"
controls
object-fit="contain"
/>
</template>
<image v-if="item.imageUrl" class="service-item__image" :src="item.imageUrl" mode="widthFix" />
<video
v-if="item.videoUrl"
class="service-item__video"
:src="item.videoUrl"
controls
object-fit="contain"
/>
</view>
</template>
</scroll-view>
<view class="service-modal__close" @tap="servicePopup?.close()">
<text class="service-modal__close-text">关闭</text>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '../../store/user'
import { post } from '../../api/request'
import type { BannerItem } from '../../api/types'
const userStore = useUserStore()
@@ -103,8 +157,107 @@ function goUsageGuide() { uni.navigateTo({ url: '/packages/content/pages/usage-g
/** 跳转订单列表 */
function goOrders() { uni.navigateTo({ url: '/packages/user/pages/order/list' }) }
/** 拨打客服热线 */
function callService() { uni.makePhoneCall({ phoneNumber: '400-123-0101' }) }
interface ContentPart {
html?: string
videoSrc?: string
phone?: string
}
/** 常见电话号码:手机号 / 400 / 座机 */
const PHONE_REGEX = /1[3-9]\d{9}|400-?\d{3}-?\d{4}|\d{3,4}-\d{7,8}/g
/** 仅含空白与标签的 html 片段无需渲染 */
function isBlankHtml(html: string): boolean {
return /^\s*(<[^>]+>\s*)*$/.test(html)
}
/** 富文本图片防溢出(与 notice-detail 一致) */
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}"`)
})
}
/** 将富文本拆分为 html 段、可拨号电话段与原生 video 段,保持原有上下顺序 */
function parseParts(html: string): ContentPart[] {
const parts: ContentPart[] = []
let htmlBuffer = ''
const flushHtml = () => {
if (htmlBuffer && !isBlankHtml(htmlBuffer)) parts.push({ html: htmlBuffer })
htmlBuffer = ''
}
/** 纯文本中提取电话号码,拆出可点击拨号段 */
const pushText = (text: string) => {
let last = 0
PHONE_REGEX.lastIndex = 0
let p: RegExpExecArray | null
while ((p = PHONE_REGEX.exec(text)) !== null) {
htmlBuffer += text.slice(last, p.index)
flushHtml()
parts.push({ phone: p[0] })
last = p.index + p[0].length
}
htmlBuffer += text.slice(last)
}
/** html 片段:标签保留进 html 段,仅在文本节点里提取电话 */
const pushHtmlSeg = (seg: string) => {
let last = 0
const tagRegex = /<[^>]+>/g
let t: RegExpExecArray | null
while ((t = tagRegex.exec(seg)) !== null) {
pushText(seg.slice(last, t.index))
htmlBuffer += t[0]
last = t.index + t[0].length
}
pushText(seg.slice(last))
}
let lastIndex = 0
const videoRegex = /<video[\s\S]*?<\/video>/gi
let m: RegExpExecArray | null
while ((m = videoRegex.exec(html)) !== null) {
pushHtmlSeg(html.slice(lastIndex, m.index))
flushHtml()
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
}
pushHtmlSeg(html.slice(lastIndex))
flushHtml()
return parts
}
/** 点击富文本中的电话号码拨号 */
function callPhone(phone: string) {
uni.makePhoneCall({ phoneNumber: phone, fail: () => {} })
}
const servicePopup = ref<any>(null)
const serviceList = ref<BannerItem[]>([])
const serviceLoading = ref(false)
/** 打开客服热线弹窗并拉取客服信息 */
async function goService() {
servicePopup.value?.open()
serviceLoading.value = true
try {
const data = await post<BannerItem[]>('/mpContent/queryList', { channel: 'pingxiang', type: 'SERVICE_INFO' })
serviceList.value = data || []
} catch {
serviceList.value = []
} finally {
serviceLoading.value = false
}
}
/** 手机号脱敏显示(138****5678) */
function formatPhone(phone?: string) {
@@ -392,4 +545,110 @@ function formatPhone(phone?: string) {
font-weight: $font-weight-medium;
}
}
/* 客服热线弹窗 */
.service-modal {
width: 600rpx;
background: $color-card;
border-radius: $radius-lg;
padding: 40rpx 32rpx;
}
.service-modal__title {
display: block;
text-align: center;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 32rpx;
}
.service-modal__scroll {
max-height: 60vh;
}
.service-modal__state {
display: flex;
justify-content: center;
padding: 60rpx 0;
}
.service-modal__tips {
font-size: $font-size-sm;
color: $uv-tips-color;
}
.service-item {
margin-bottom: 40rpx;
&:last-child {
margin-bottom: 0;
}
}
.service-item__title {
display: block;
font-size: $font-size-base;
font-weight: $font-weight-normal;
color: $uv-main-color;
line-height: 1.4;
}
.service-item__divider {
height: 1rpx;
background: rgba(0, 0, 0, 0.06);
margin: 24rpx 0;
}
.service-item__content {
font-size: $font-size-base;
color: $uv-main-color;
line-height: 1.8;
::v-deep img {
max-width: 100%;
height: auto;
display: block;
}
}
.service-item__phone {
font-size: $font-size-base;
color: $uv-primary;
text-decoration: underline;
}
.service-item__image {
width: 100%;
border-radius: 12rpx;
margin-top: 24rpx;
}
.service-item__video {
width: 100%;
height: 400rpx;
background: #000;
border-radius: 12rpx;
margin-top: 24rpx;
}
.service-modal__close {
margin-top: 32rpx;
display: flex;
align-items: center;
justify-content: center;
height: 80rpx;
background: $uv-primary-light;
border-radius: $radius-full;
&:active {
opacity: 0.8;
}
}
.service-modal__close-text {
font-size: $font-size-base;
color: $uv-primary;
font-weight: $font-weight-medium;
}
</style>
+32 -57
View File
@@ -82,28 +82,9 @@
</view>
</view>
<!-- 入园码二维码 -->
<view class="qrcode-section" v-if="!showFaceModal && !isLeaving">
<text class="qrcode-title">入园提示</text>
<view>为确保信息安全,现入园核销只能使用人脸核销。</view>
<!-- <view class="qrcode-box"> -->
<!-- 为确保信息安全,现入园核销只能使用人脸核销。 -->
<!-- <image
class="qrcode-img"
:src="userInfoData?.qrCode || '/static/qr-demo.png'"
mode="aspectFit"
/> -->
<!-- </view> -->
<!-- <view class="qrcode-countdown">
<view class="countdown-icon" />
<text class="countdown-text">{{ countdown }}秒后自动刷新</text>
</view> -->
</view>
<!-- 持卡人信息(页面底部) -->
<!-- 持卡人信息 -->
<view class="holder-panel">
<view class="holder-header">
<view class="avatar-wrap" @click="previewAvatar">
<view class="avatar-wrap" @click="previewAvatar">
<image
class="holder-avatar"
:src="userInfoData?.img || '/static/logo.png'"
@@ -113,40 +94,38 @@
<view class="zoom-icon" />
</view>
</view>
<view class="holder-main">
<text class="holder-name">{{ userInfoData?.userName || '-' }}</text>
</view>
</view>
<view class="holder-details">
<view class="detail-row">
<text class="detail-label">卡种名称</text>
<text class="detail-value">{{ currentCard.cardModelName }}</text>
<text class="detail-label">姓名:</text>
<text class="detail-value">{{ userInfoData?.userName || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">旅游卡卡号</text>
<text class="detail-label">手机:</text>
<text class="detail-value">{{ userInfoData?.mobile || userStore.userInfo?.phone || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">卡号:</text>
<view class="detail-value-wrap">
<text class="detail-value">{{ currentCard.cardNo }}</text>
<text class="copy-btn" @click="copyCardNo">复制</text>
</view>
</view>
<view class="detail-row">
<text class="detail-label">有效期至</text>
<text class="detail-value">{{ currentCard.validityTime }}</text>
<text class="detail-label">有效期至:</text>
<text class="detail-value">{{ formatDateOnly(currentCard.validityTime) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">证件类型</text>
<text class="detail-value">{{ userInfoData?.idType || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">证件号码</text>
<text class="detail-label">{{ userInfoData?.idType || '证件' }}:</text>
<text class="detail-value">{{ userInfoData?.idNo || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">手机号码</text>
<text class="detail-value">{{ userInfoData?.mobile || userStore.userInfo?.phone || '-' }}</text>
</view>
</view>
</view>
<!-- 入园提示(持卡人信息下方) -->
<view class="qrcode-section" v-if="!showFaceModal && !isLeaving">
<text class="qrcode-title">入园提示</text>
<view>为确保信息安全,现入园核销只能使用人脸核销。</view>
</view>
<!-- 底部占位(滑到底时与tabBar的间隔) -->
<view style="height: 48rpx;"/>
</scroll-view>
@@ -276,6 +255,12 @@ function onCardChange(e: any) {
}
}
/** 有效期只显示年月日(去掉时间部分) */
function formatDateOnly(time?: string): string {
if (!time) return '-'
return time.split(' ')[0]
}
function previewAvatar() {
if (!userInfoData.value?.img) return
uni.previewImage({
@@ -505,18 +490,14 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
/* === 持卡人信息 === */
.holder-panel {
display: flex; align-items: flex-start; gap: 12px;
margin: 0 20rpx; background: $color-card;
border-radius: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.06);
}
.holder-header {
display: flex; align-items: center; gap: 20rpx;
padding-bottom: 24rpx; margin-bottom: 20rpx;
border-bottom: 1rpx solid #f7f8fa;
}
.avatar-wrap { position: relative; width: 96rpx; height: 96rpx; flex-shrink: 0; }
.avatar-wrap { position: relative; width: 288rpx; height: 358.4rpx; flex-shrink: 0; }
.holder-avatar {
width: 96rpx; height: 96rpx; border-radius: 50%;
width: 288rpx; height: 358.4rpx; border-radius: 0;
border: 3rpx solid #eeeeee;
}
.avatar-zoom {
@@ -536,17 +517,11 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
bottom: -2rpx; right: -2rpx;
transform: rotate(45deg); border-radius: 1rpx;
}
.holder-main { flex: 1; }
.holder-name { font-size: $font-size-lg; font-weight: $font-weight-medium; color: $uv-main-color; display: block; }
.holder-tag {
display: inline-block; font-size: 22rpx; color: #00b42a;
background: #f0fff4; padding: 2rpx 12rpx; border-radius: 6rpx; margin-top: 6rpx;
}
.holder-details { display: flex; flex-direction: column; gap: 14rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: center; }
.detail-label { font-size: $font-size-sm; color: $uv-tips-color; }
.holder-details { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: space-between; gap: 16rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 12rpx; }
.detail-label { font-size: $font-size-sm; color: #909399; flex-shrink: 0; }
.detail-value-wrap { display: flex; align-items: center; gap: 12rpx; }
.detail-value { font-size: $font-size-sm; color: $uv-content-color; }
.detail-value { font-size: $font-size-sm; color: $uv-content-color; word-break: break-all; text-align: right; }
.copy-btn {
font-size: 22rpx; color: $uv-primary;
padding: 4rpx 16rpx; border: 1rpx solid $uv-primary;
@@ -557,7 +532,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
/* === 二维码区域 === */
.qrcode-section {
display: flex; flex-direction: column; align-items: center;
margin: 0 20rpx 20rpx; background: $color-card;
margin: 20rpx 20rpx 20rpx; background: $color-card;
border-radius: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.06);
}