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
@@ -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;