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:
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 +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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user