vr-shopxo-uniapp/pages/goods-vr-ticket/goods-vr-ticket.vue

620 lines
21 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view class="ticket-page">
<!-- 顶部返回按钮 -->
<view class="top-nav-left-icon pf" style="top: 44px;" v-show="!seatSelectorVisible">
<view class="icon back-icon round cp" @click="goBack"></view>
</view>
<!-- Header Banner - 使用头部组件 -->
<ticket-header
:poster-url="goodsData.images"
:title="goodsData.title"
:tag="goodsData.simple_desc"
:collected="collected"
:peer-goods="peerGoods"
:current-goods-id="goodsId"
:current-goods-timestamp="goodsData.batch_number_expire || 0"
:current-goods-title="goodsData.title"
:session-meta="sessionMeta"
@collect="toggleCollect"
@switch-date="handleSwitchDate"
/>
<!-- Main Content -->
<view class="main-content">
<!-- Location Card - 使用场馆卡片组件 -->
<venue-card
:venue="currentVenue"
:is-nearest="true"
@nav="openAmap"
@click="openPopup('venue')"
/>
<!-- Service Header -->
<view class="service-header" @click="openPopup('service')">
<view class="service-tags-wrapper">
<view class="service-tags">
<view class="tag-item" v-for="(item, index) in services" :key="index">
<text :class="item.status === 'success' ? 'icon-success' : 'icon-warning'">
{{ item.status === 'success' ? '✔' : '!' }}
</text>
{{ item.title }}
</view>
</view>
</view>
<view class="service-more">></view>
</view>
</view>
<!-- Section: 演出详情 -->
<view class="section-card mt-10">
<view class="section-header">
<text class="section-title">演出详情</text>
</view>
</view>
<!-- Section: 购票须知 -->
<view class="section-card mt-10">
<view class="section-header">
<text class="section-title">购票须知</text>
<view class="section-more" @click="openPopup('notice', 'buy')">全部 ></view>
</view>
<view class="notice-grid">
<view class="notice-item" v-for="(item, index) in buyNotices.slice(0, 4)" :key="index">
<view class="notice-title"><view class="dot"></view>{{ item.title }}</view>
<view class="notice-desc">{{ item.desc }}</view>
</view>
</view>
</view>
<!-- Section: 观演须知 -->
<view class="section-card mt-10" :style="{ marginBottom: '90px' }">
<view class="section-header">
<text class="section-title">观演须知</text>
<view class="section-more" @click="openPopup('notice', 'watch')">全部 ></view>
</view>
<view class="notice-grid">
<view class="notice-item" v-for="(item, index) in watchNotices.slice(0, 4)" :key="index">
<view class="notice-title"><view class="dot"></view>{{ item.title }}</view>
<view class="notice-desc">{{ item.desc }}</view>
</view>
</view>
</view>
<!-- Bottom Bar -->
<view class="bottom-bar">
<!-- 没选座时显示客服 -->
<view class="customer-service" v-if="confirmedSeats.length === 0">
<iconfont name="icon-customer-service" size="50rpx" color="#666"></iconfont>
<text>客服</text>
</view>
<!-- 选完座显示合计 -->
<view class="selected-info" v-else>
<view class="info-top" @click="seatSelectorVisible = true">已选 {{ confirmedSeats.length }} 座 <text class="reselect-text">重新选座 ></text></view>
<view class="info-bottom">合计 <text class="price-text">¥{{ totalConfirmedPrice }}</text></view>
</view>
<!-- 可购票时显示正常按钮 -->
<button
class="buy-button"
v-if="hasAvailableSession"
@click="confirmedSeats.length === 0 ? openTicketPopup() : goToOrder()">
{{ confirmedSeats.length === 0 ? '' : '' }}
</button>
<!-- -->
<button class="buy-button buy-button-disabled" v-else></button>
</view>
<!-- Popup Container - 使 -->
<ticket-popup
:visible="popupVisible"
:type="popupType"
:services="services"
:venues="venues"
:buy-notices="buyNotices"
:watch-notices="watchNotices"
:default-tab="noticeActiveTab"
:tree-data="treeData"
:seat-templates="seatTemplates"
:session-meta="sessionMeta"
@close="closePopup"
@nav="openAmap"
@confirm-buy="handleConfirmBuy"
/>
<!-- Seat Selector (全屏遮罩选座) -->
<vr-seat-selector
:visible="seatSelectorVisible"
:selection="currentSelection"
:tree-data="treeData"
:seat-templates="seatTemplates"
@close="seatSelectorVisible = false"
@confirm="handleConfirmSeats"
/>
</view>
</template>
<script>
// 引入子组件
import TicketHeader from './components/ticket-header/index.vue';
import VenueCard from './components/venue-card/index.vue';
import TicketPopup from './components/ticket-popup/index.vue';
import VrSeatSelector from './components/vr-seat-selector/index.vue';
import iconfont from '@/components/iconfont/iconfont.vue';
export default {
name: 'GoodsVrTicket',
// 注册子组件
components: {
TicketHeader,
VenueCard,
TicketPopup,
VrSeatSelector,
iconfont
},
data() {
return {
// App 实例
app: null,
// 商品ID
goodsId: null,
// 商品数据
goodsData: {},
// VR 配置数据
vrConfig: [],
// 场馆列表
venues: [],
// 当前选中的场馆
currentVenue: {},
// 收藏状态
collected: false,
// 弹窗状态
popupVisible: false,
popupType: 'service',
noticeActiveTab: 'buy',
// 当前选中的4维规格
currentSelection: null,
// 是否显示座位选择器
seatSelectorVisible: false,
// 已确认选中的座位
confirmedSeats: [],
// Tree API 数据
treeData: null,
seatTemplates: null,
// 同场次关联商品(用于多日期切换)
peerGoods: [],
// 场次元数据(用于停售控制)
sessionMeta: [],
// 服务说明数据 (可从 API 获取)
defaultServices: [
{ title: '电子票', desc: '现场验票时请观演人出示APP票夹中的电子票二维码验票入场不支持截屏。', status: 'success' },
{ title: '不支持转赠', desc: '本场演出实行实名制购票观演政策,下单时需填写观演人信息。购票完成后,观演人信息不可更改,需携带购票时填写的证件验证入场观演。', status: 'warning' },
{ title: '不支持退换票', desc: '票品为有价票券,非普通商品,其背后承载的文化服务具有时效性,稀缺性等特征,不支持退换。', status: 'warning' }
],
// 购票须知默认值
defaultBuyNotices: [
{ title: '实名购票', desc: '本场演出实行实名制购票观演政策,一证一票。' },
{ title: '演出时长以现场为准', desc: '受现场不可控因素影响,实际演出时长以现场为准。' },
{ title: '定时购说明', desc: '使用定时购服务后将在开票时为您自动购票不保证购票成功也不影响开票后的正常购票使用需扣减票价等额90%的秀豆积分。' },
{ title: '候补说明', desc: '使用候补服务后将按照订单出票时间先后顺序候补不保证一定成功候补服务费一般为订单费用的5.5%。' }
],
// 观演须知默认值
defaultWatchNotices: [
{ title: '站席观演', desc: '场地内不设座位,均为站席观演。' },
{ title: '禁止携带物品', desc: '由于安保和版权的原因,大多数演出、展览及比赛场所禁止携带食品、饮料、专业摄录设备、打火机等物品。' },
{ title: '退换政策', desc: '票品为有价票券,非普通商品,其背后承载的文化服务具有时效性,稀缺性等特征,不支持退换。' }
]
};
},
computed: {
// 根据当前选中的 tab 动态返回对应的须知列表
currentNoticeList() {
return this.noticeActiveTab === 'buy' ? this.buyNotices : this.watchNotices;
},
// 购票须知:优先使用场馆配置,为空时回退默认值
buyNotices() {
const venueNotices = this.currentVenue?.notices?.buy;
if (venueNotices && venueNotices.length > 0) {
return venueNotices;
}
return this.defaultBuyNotices;
},
// 观演须知:优先使用场馆配置,为空时回退默认值
watchNotices() {
const venueNotices = this.currentVenue?.notices?.watch;
if (venueNotices && venueNotices.length > 0) {
return venueNotices;
}
return this.defaultWatchNotices;
},
// 服务说明:优先使用场馆配置,为空时回退默认值
services() {
const venueServices = this.currentVenue?.notices?.service;
if (venueServices && venueServices.length > 0) {
return venueServices;
}
return this.defaultServices;
},
totalConfirmedPrice() {
return this.confirmedSeats.reduce((sum, seat) => sum + Number(seat.price || 0), 0).toFixed(2);
},
// 是否有可用场次(未被停售)
hasAvailableSession() {
const now = Math.floor(Date.now() / 1000);
// 如果 sessionMeta 为空,视为有可用场次(保持向后兼容)
if (!this.sessionMeta || this.sessionMeta.length === 0) {
return true;
}
// 只要有一个场次的 batch_expire_ts > now就认为有可用场次
return this.sessionMeta.some(session => session.batch_expire_ts > now);
}
},
methods: {
// 返回上一页
goBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
// 有历史页面,返回上一页
uni.navigateBack();
} else {
// 无历史页面,返回首页
uni.reLaunch({
url: '/pages/index/index'
});
}
},
// 切换日期(点击 peer_goods 中的商品)
handleSwitchDate(goodsId) {
console.log('[VR Ticket] Switch to goods:', goodsId);
// 重新加载目标商品页面
uni.redirectTo({
url: `/pages/goods-vr-ticket/goods-vr-ticket?id=${goodsId}`
});
},
onLoad(params) {
// 获取 app 实例
const app = getApp();
this.app = app;
// 获取页面参数
if (params && params.id) {
this.goodsId = params.id;
}
// 尝试从缓存获取商品数据
var goods = app.globalData.goods_data_cache_handle(this.goodsId);
if (goods != null) {
console.log('[VR Ticket] goods from cache:', goods);
this.handleGoodsData(goods);
this.loadTreeData();
} else {
// 请求 API 获取商品详情
this.loadGoodsDetail();
}
},
// 加载商品详情
loadGoodsDetail() {
var self = this;
uni.request({
url: this.app.globalData.get_request_url('detail', 'goods'),
method: 'POST',
data: { id: this.goodsId },
dataType: 'json',
success: function(res) {
if (res.data.code == 0) {
var goods = res.data.data.goods;
console.log('[VR Ticket] goods from API:', goods);
self.handleGoodsData(goods);
// 更新商品缓存,确保 user_is_favor 等字段同步
self.app.globalData.goods_data_cache_handle(goods.id, goods);
self.loadTreeData();
}
}
});
},
// 处理商品数据
handleGoodsData(goods) {
// 存储商品数据
this.goodsData = goods;
// 初始化收藏状态(从商品数据获取)
this.collected = (goods.user_is_favor == 1);
// 解析 vr_goods_config
var vrConfig = goods.vr_goods_config;
if (typeof vrConfig === 'string') {
try {
vrConfig = JSON.parse(vrConfig);
} catch (e) {
vrConfig = [];
}
}
this.vrConfig = vrConfig;
console.log('[VR Ticket] vrConfig parsed:', vrConfig);
},
// 加载 VR Ticket 层级树数据
loadTreeData() {
var self = this;
uni.request({
url: this.app.globalData.get_request_url('tree', 'goods', 'vr_ticket', 'goods_id=' + this.goodsId + '&group_by=session,venue,room,section'),
method: 'GET',
dataType: 'json',
success: function(res) {
if (res.data.code == 0) {
console.log('[VR Ticket] Tree API success:', res.data.data);
self.handleTreeData(res.data.data);
} else {
uni.showToast({ title: res.data.msg || '获取票务数据失败', icon: 'none' });
}
},
fail: function() {
uni.showToast({ title: '网络错误,获取票务数据失败', icon: 'none' });
}
});
},
// 处理 Tree API 数据
handleTreeData(apiData) {
this.treeData = apiData.tree;
this.seatTemplates = apiData.seat_templates;
// 提取会话元数据和同场次商品
this.sessionMeta = apiData.session_meta || [];
this.peerGoods = apiData.peer_goods || [];
// 提取场馆数据
const venuesMap = {};
Object.values(apiData.seat_templates || {}).forEach(template => {
if (template.seat_map && template.seat_map.venue) {
venuesMap[template.seat_map.venue.name] = template.seat_map.venue;
}
});
this.venues = Object.values(venuesMap);
if (this.venues.length > 0) {
this.findClosestVenue();
}
},
// 找出距离最近的场馆
findClosestVenue() {
// 模拟当前用户坐标 (例如: 北京市朝阳区)
// 在实际项目中,可以使用 uni.getLocation 获取真实经纬度
const currentLoc = { lat: 39.9042, lng: 116.4074 };
let closest = this.venues[0];
let minDistance = Infinity;
this.venues.forEach(venue => {
if (venue.location && venue.location.lat && venue.location.lng) {
const lat = parseFloat(venue.location.lat);
const lng = parseFloat(venue.location.lng);
// 使用 Haversine 公式计算距离
const distance = this.calculateDistance(currentLoc.lat, currentLoc.lng, lat, lng);
if (distance < minDistance) {
minDistance = distance;
closest = venue;
}
}
});
this.currentVenue = closest || this.venues[0];
},
// Haversine 距离计算 (单位:米)
calculateDistance(lat1, lng1, lat2, lng2) {
const radLat1 = lat1 * Math.PI / 180.0;
const radLat2 = lat2 * Math.PI / 180.0;
const a = radLat1 - radLat2;
const b = (lng1 - lng2) * Math.PI / 180.0;
let s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
return s * 6378137.0;
},
// 导航到高德地图
openAmap(venue) {
const { location, name } = venue;
if (!location || !location.lat || !location.lng) {
uni.showToast({
title: '该场馆暂无位置信息',
icon: 'none'
});
return;
}
// 使用 uni.openLocation 打开地图导航
uni.openLocation({
latitude: parseFloat(location.lat),
longitude: parseFloat(location.lng),
name: name,
address: venue.address,
fail: () => {
// 如果失败,使用高德 URL Scheme
const url = `https://uri.amap.com/marker?position=${location.lng},${location.lat}&name=${encodeURIComponent(name)}`;
// #ifdef H5
window.open(url, '_blank');
// #endif
// #ifndef H5
plus.runtime.openWeb(url);
// #endif
}
});
},
// 切换收藏状态
toggleCollect() {
var self = this;
// 登录检查
var user = this.app.globalData.get_user_info(this, 'toggleCollect');
if (user == false) {
return;
}
uni.showLoading({
title: '处理中...',
});
uni.request({
url: this.app.globalData.get_request_url('favor', 'goods'),
method: 'POST',
data: {
id: this.goodsId,
},
dataType: 'json',
success: function(res) {
uni.hideLoading();
if (res.data.code == 0) {
// 更新收藏状态
self.collected = res.data.data.status == 1;
// 更新商品缓存,确保数据一致性
var goods = self.app.globalData.goods_data_cache_handle(self.goodsId);
if (goods) {
goods.user_is_favor = res.data.data.status;
self.app.globalData.goods_data_cache_handle(self.goodsId, goods);
}
self.app.globalData.showToast(res.data.msg, 'success');
} else {
if (self.app.globalData.is_login_check(res.data, self, 'toggleCollect')) {
self.app.globalData.showToast(res.data.msg);
}
}
},
fail: function() {
uni.hideLoading();
self.app.globalData.showToast('网络错误,请重试');
}
});
},
// 打开购票弹窗
openTicketPopup() {
this.popupType = 'buy';
this.popupVisible = true;
},
// 打开弹窗type 区分是服务还是须知tab 区分须知的默认选中项
openPopup(type, tab = 'buy') {
this.popupType = type;
if (type === 'notice') {
this.noticeActiveTab = tab;
}
this.popupVisible = true;
},
// 关闭弹窗
closePopup() {
this.popupVisible = false;
},
// 确认购买/进入选座
handleConfirmBuy(selection) {
console.log('[VR Ticket] Confirm buy with selection:', selection);
this.currentSelection = selection;
this.closePopup();
// 显示座位选择器
this.seatSelectorVisible = true;
},
// 确认选座(选座完毕直接跳转到订单页)
handleConfirmSeats(seats) {
console.log('[VR Ticket] Confirmed seats:', seats);
this.confirmedSeats = seats;
this.seatSelectorVisible = false;
// 直接跳转到订单确认页
this.goToOrder();
},
// 跳转到订单提交页
goToOrder() {
// 验证登录状态
var user = this.app.globalData.get_user_info(this, 'goToOrder');
if (user == false) {
return;
}
// 验证已选座位
if (this.confirmedSeats.length === 0) {
this.app.globalData.showToast('请先选择座位');
return;
}
// 获取当前选中 session 的 session_datetime
const sessionItem = this.sessionMeta.find(m => m.session === this.currentSelection?.session);
const sessionDatetime = sessionItem?.session_datetime || '';
// 获取海报URL支持 images 为字符串或数组两种格式)
let posterImg = '';
if (typeof this.goodsData.images === 'string') {
posterImg = this.goodsData.images || '';
} else if (Array.isArray(this.goodsData.images) && this.goodsData.images.length > 0) {
posterImg = this.goodsData.images[0] || '';
}
// 组装跳转参数
const params = {
goodsId: this.goodsId,
seats: encodeURIComponent(JSON.stringify(this.confirmedSeats)),
selection: encodeURIComponent(JSON.stringify(this.currentSelection)),
buyNotices: encodeURIComponent(JSON.stringify(this.buyNotices)),
services: encodeURIComponent(JSON.stringify(this.services)),
batchExpire: this.goodsData.batch_number_expire || '',
sessionDatetime: sessionDatetime,
posterUrl: posterImg,
venueAddress: this.currentVenue?.address || ''
};
// 拼接 URL 参数
let url = '/pages/goods-vr-ticket-order/goods-vr-ticket-order?';
Object.keys(params).forEach(key => {
url += `${key}=${params[key]}&`;
});
console.log('[VR Ticket] Navigate to order confirm:', url);
uni.navigateTo({
url: url.slice(0, -1) // 移除末尾的 &
});
}
},
// 下拉刷新
onPullDownRefresh() {
console.log('[VR Ticket] Pull down to refresh');
var self = this;
// 重新加载商品详情和树数据
uni.request({
url: this.app.globalData.get_request_url('detail', 'goods'),
method: 'POST',
data: { id: this.goodsId },
dataType: 'json',
success: function(res) {
if (res.data.code == 0) {
var goods = res.data.data.goods;
console.log('[VR Ticket] Refresh goods from API:', goods);
self.handleGoodsData(goods);
// 重新加载 Tree 数据(刷新座位库存)
self.loadTreeData();
} else {
uni.showToast({ title: res.data.msg || '刷新失败', icon: 'none' });
}
// 停止下拉刷新动画
uni.stopPullDownRefresh();
},
fail: function() {
uni.showToast({ title: '网络错误,刷新失败', icon: 'none' });
uni.stopPullDownRefresh();
}
});
}
};
</script>
<style>
@import "./goods-vr-ticket.css";
</style>