feat: VR ticket page integration (goods-vr-ticket) + improved .gitignore

- Add goods-vr-ticket page with ticket-header/venue-card/ticket-popup components
- Integrate VR redirect logic in goods-detail (detects vr_goods_config → redirect)
- Update pages.json to register goods-vr-ticket route
- Add iconfont improvements
- Update .gitignore: add node_modules, .agent, .claude, .vite, .vscode, .gitnexus, references, AGENTS.md, CLAUDE.md
master
bigemon 2026-05-14 07:59:23 +08:00
parent 4d17eaa577
commit 2c13951249
11 changed files with 2102 additions and 4 deletions

26
.gitignore vendored
View File

@ -1,7 +1,27 @@
# OS / IDE
.DS_Store
.hbuilderx
.hbuilderx/
.vscode/
*.log
unpackage
# Build output
unpackage/
sitemap.json
# AI Agent / Claude
.agent/
.claude/
.vite/
.gitnexus/
references/
AGENTS.md
CLAUDE.md
# Dependencies
node_modules/
# Prettier (project-level override, not shared)
.prettierrc.cjs
pages/diy/init_data.js
# Local init data (generated per environment)
pages/diy/init_data.js

View File

@ -44,6 +44,11 @@
display: flex;
font-size: inherit;
overflow: hidden;
/* 限制最大尺寸防止缩放异常 */
max-width: 100px;
max-height: 100px;
width: 1em;
height: 1em;
/* 因icon大小被设置为和字体大小一致而span等标签的下边缘会和字体的基线对齐故需设置一个往下的偏移比例来纠正视觉上的未对齐效果 */
vertical-align: -0.15em;
outline: none;

View File

@ -0,0 +1,441 @@
# VR票务详情页面 UniApp 移植规划文档
> 创建时间2026-05-13
> 状态:**Phase 3 导航优化完成(防闪烁/缓存预检查)**
---
## 📌 背景与目标
### 背景
- 后端 VR Ticket 插件 (`vr_ticket`) 已实现票务商品详情页模板 `ticket_detail.html`
- 前端参考项目 [show-start](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/references/show-start/src/App.vue) 提供了高质量的 UI 设计
- 需要将参考项目的 UI 移植到 UniApp 环境中并整合票务商品的4维选择逻辑
### 用户需求
1. **保持 show-start 的 popup 交互模式** 作为购票弹窗基础
2. **点击"立即购票"按钮** 后弹出购票弹窗
3. 弹窗内容包含:场次选择、场馆/演播室/分区选择、"选座"按钮
4. **选座功能**:独立的全屏遮罩座位选择器,选完后返回已选座位 tags
---
## 📋 数据结构规范Phase 2 核心)
### VR 票务扩展字段
从后端 API 返回的商品扩展数据中,票务商品会包含以下关键字段:
```typescript
interface VRTicketGoods {
id: number;
title: string;
images: string[];
is_vr_ticket: 1; // 票务商品标识
// VR 票务扩展数据(来自 vr_config 或 extension_data
vr_config?: {
// 场次规格列表(从 goods_spec_data 解析)
spec_list?: Array<{
spec_name: string; // 场次名称,如 "2026-06-01 14:00"
price: number; // 价格
inventory: number; // 库存
}>;
// 座位规格映射(从 seat_spec_map 解析)
seat_spec_map?: Record<string, {
spec: Array<{ type: string; value: string }>; // 4维规格
price: number;
inventory: number;
section: { name: string; color: string };
}>;
// 座位模板(座位图数据)
seat_template?: {
venue: { name: string; address: string; location: { lng: string; lat: string } };
rooms: Array<{
id: string;
name: string;
map: string[]; // 座位矩阵,如 ["AAAAA", "AAB__AA"]
sections: Array<{ char: string; name: string; price: number; color: string }>;
}>;
};
};
}
```
### 4维规格体系
票务商品的选座流程采用 **4维规格级联选择**
| 维度 | 字段名 | 说明 | 示例 |
|------|--------|------|------|
| 1 | `$vr-场次` | 演出场次/时间 | "2026-06-01 14:00" |
| 2 | `$vr-场馆` | 演出场馆 | "北京凯迪拉克中心" |
| 3 | `$vr-演播室` | 室内演出厅 | "主要展厅" |
| 4 | `$vr-分区` | 座位分区 | "VIP区"、"普通区" |
**选择级联逻辑**:选择场次 → 筛选可用场馆 → 选择场馆 → 筛选可用演播室 → 选择演播室 → 筛选可用分区 → 选择分区 → 加载座位图
---
## 📐 UI/UX 设计概览
### 页面结构
```
┌─────────────────────────────────────┐
│ Header Banner (海报 + 标题 + 收藏) │
├─────────────────────────────────────┤
│ Location Card (最近场馆) │
├─────────────────────────────────────┤
│ Service Tags (服务说明) │
├─────────────────────────────────────┤
│ 演出详情 Section │
├─────────────────────────────────────┤
│ 购票须知 Section │
├─────────────────────────────────────┤
│ 观演须知 Section │
├─────────────────────────────────────┤
│ Bottom Bar (客服 + 立即购票) │ ← Fixed 底部
└─────────────────────────────────────┘
```
### 购票弹窗 Popup 交互(核心功能区)
```
┌─────────────────────────────────────┐
│ [场次选择 - 横向卡片滚动] │ ← vr-session-grid
├─────────────────────────────────────┤
│ [选择场馆] │ ← vr-booking-block
├─────────────────────────────────────┤
│ [选择演播室] → [选择分区] │ ← 级联选择
├─────────────────────────────────────┤
│ [选座] 按钮 ← 打开座位选择器 │
│ │
│ 未选座时:尚未选择 [立刻选座] │
│ 已选座时: │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ A1 │ │ A2 │ │ A3 │ │ ← 已选座位 tags
│ └─────┘ └─────┘ └─────┘ │
│ 【重新选座】 │
├─────────────────────────────────────┤
│ 合计: ¥888 [确认购票] │ ← 底部购买栏
└─────────────────────────────────────┘
```
### 座位选择器(全屏遮罩)
```
┌─────────────────────────────────────┐
│ ← 返回 选择座位 [已完成 3 座] │
├─────────────────────────────────────┤
│ 🎵 舞 台 │ ← 舞台指示
├─────────────────────────────────────┤
│ A ■ ■ ■ ■ ■ │ ← 座位行A排
│ B ■ ■ ■ ■ ■ ■ ■ │ ← 可用座位:■ 点击选中
│ C ■ _ _ ■ ■ ■ │ ← 过道_
│ D ■ ■ ■ ■ ■ │ ← 已售:灰色
├─────────────────────────────────────┤
│ 图例: [VIP] [普通] [已售] │
└─────────────────────────────────────┘
```
### 座位状态定义
- **可用座位**:可点击选择,带分区颜色
- **已选座位**:高亮边框,数字标识
- **已售座位**:灰色,不可点击
- **过道/空位**:占位符,不渲染
---
## 🔄 阶段进展记录
### Phase 1: 基础 UI 移植 ✅ 已完成
- [x] 在 `pages.json` 中注册新页面路由
- [x] 创建 `pages/goods-vr-ticket/` 目录
- [x] 搬运 `App.vue` 内容到 `goods-vr-ticket.vue`
- [x] 适配 UniApp 语法scrollable、navigation 等)
- [x] 替换内联 SVG → iconfont 组件兼容<E585BC><E5AEB9>信小程序
- [x] 添加 popup 容器左右留白padding: 16px
- [x] 修复 popup-content 溢出问题overflow-x, word-break
### Phase 2: 购票弹窗与4维选择开发中
- [x] 购票弹窗基础结构(当前 popupType='buy'
- [ ] 接入商品 API 数据vr_config 解析)
- [ ] 场次选择器实现(横向滚动卡片)
- [ ] 场馆选择器实现
- [ ] 演播室/分区级联选择
- [ ] 座位选择器组件开发
- [ ] 已选座位 UI 展示
- [ ] 底部总价计算
### Phase 3: 完整购票流程(待开发)
- [ ] 观演人信息表单(每座必填)
- [ ] 订单提交 API 集成
- [ ] 跳转支付流程
### Phase 4: 优化与测试
- [ ] 滚动穿透处理catchtouchmove
- [ ] 真机/小程序测试
- [ ] 性能优化
---
## 📝 待办事项
### ✅ 已完成
- [x] 在 `pages.json` 中注册新页面路由
- [x] 创建 `pages/goods-vr-ticket/` 目录
- [x] 搬运 `App.vue` 内容到 `goods-vr-ticket.vue`
- [x] 适配 UniApp 语法scrollable、navigation 等)
- [x] 替换内联 SVG → iconfont 组件(兼容微信小程序)
- [x] 添加 popup 容器左右留白padding: 16px
- [x] 修复 popup-content 溢出问题overflow-x, word-break
- [x] 购票弹窗基础框架popupType='buy' 判断)
### ⏳ 后续待办
- [x] 在 `goods-detail.vue` 中添加跳转判断逻辑
- [ ] 解析商品数据中的 vr_config 等扩展字段
- [ ] 实现场次选择器(横向滚动卡片)
- [ ] 实现场馆选择器(场次筛选后可用)
- [ ] 实现演播室→分区级联选择
- [ ] 实现选座按钮与座位选择器(全屏遮罩)
- [ ] 实现已选座位 tags 展示
- [ ] 实现重新选座功能
- [ ] 连接后端 BuyService API 获取动态票务数据
- [ ] 实现底部购买栏联动(已选座数、总价)
- [ ] 添加 popup 滚动事件处理(:catchtouchmove
- [ ] 观演人信息表单(姓名/手机/身份证)
- [ ] 订单提交入口
- [ ] 测试页面在真机/小程序环境运行
---
## 🔌 API 接口规范
### 商品详情接口(扩展字段)
请求商品详情时,后端返回 `vr_config` 扩展数据:
```javascript
// GET /api/goods/detail?id=xxx
{
"code": 0,
"data": {
"id": 100,
"title": "王一浩「To U」巡演北京收官站",
"images": "[\"xxx.jpg\"]",
"is_vr_ticket": 1,
// VR 票务扩展
"vr_config": {
"goods_spec_data": [
{ "spec_name": "2026-06-01 14:00", "price": 580, "inventory": 100 },
{ "spec_name": "2026-06-01 20:00", "price": 680, "inventory": 50 }
],
"seat_spec_map": {
"room_001_A_1": {
"spec": [
{ "type": "$vr-场次", "value": "2026-06-01 14:00" },
{ "type": "$vr-场馆", "value": "北京凯迪拉克中心" },
{ "type": "$vr-演播室", "value": "主要展厅" },
{ "type": "$vr-分区", "value": "VIP区" }
],
"price": 580,
"inventory": 1,
"section": { "name": "VIP区", "color": "#ff4d4f" }
}
},
"vr_seat_template": {
"venue": { "name": "北京凯迪拉克中心", "address": "...", "location": { "lng": "116.4", "lat": "39.9" } },
"rooms": [...]
}
}
}
}
```
### 订单提交接口
```javascript
// POST /api/buy/add
{
"goods_data": [
{
"goods_id": 100,
"spec": [...], // 4维规格数组
"stock": 1,
"order_base": {
"extension_data": {
"attendee": {
"real_name": "张三",
"phone": "13800138000",
"id_card": "110101199001011234"
}
}
}
}
],
"buy_type": "goods",
"address_id": "0",
"site_model": "2"
}
```
---
## 🛠️ 技术实现要点
### 1. 4维规格筛选逻辑
参考 `ticket_detail.html` 中的 `filterSeats` 方法:
```javascript
// 根据当前选择的4维过滤可用座位
filterSeats: function() {
var matchSession = this.currentSession != null;
var matchVenue = this.currentVenue != null;
var matchRoom = this.currentRoom != null;
var matchSection = this.currentSection != null;
// 遍历所有座位,检查是否满足当前选择条件
for (var i = 0; i < seatInfo.spec.length; i++) {
if (seatInfo.spec[i].type === '$vr-场次' && seatInfo.spec[i].value === this.currentSession) {
matchSession = true;
}
// ... 同理其他维度
}
}
```
### 2. 座位状态管理
- `selectedSeats[]`:已选座位数组
- `soldSeats{}`:已售座位映射
- 切换场次时清空已选座位
### 3. 级联选择
- 选择「场次」后 → 重置「场馆/演播室/分区」,重新计算可选场馆
- 选择「场馆」后 → 筛选出<E98089><E587BA>场馆下的演播室
- 选择「演播室」后 → 筛选出该演播室下的分区,显示分区选择器
### 4. 座位渲染
座位图使用字符矩阵渲染:
- `A`/`B`/...:座位字符
- `_`:过道/空位
---
## 📦 文件结构
```
pages/goods-vr-ticket/
├── goods-vr-ticket.vue # 主页面(已移植 Phase 1
├── goods-vr-ticket.css # 独立样式(可选)
└── components/
├── vr-session-select/ # 场次选择器组件
├── vr-venue-select/ # 场馆选择器组件
├── vr-seat-selector/ # 座位选择器(全屏遮罩)
└── vr-attendee-form/ # 观演人表单
```
---
## 🔗 相关参考
| 文件 | 说明 |
|------|------|
| [references/show-start/src/App.vue](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/references/show-start/src/App.vue) | 源 UI 参考Vue 2 |
| [pages/goods-detail/goods-detail.vue](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/pages/goods-detail/goods-detail.vue) | 商品详情页(跳转入口) |
| [vr_ticket/ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) | 后端票务模板参考4维选择+座位图) |
| [pages.json](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/pages.json) | 页面路由配置 |
---
## 🎯 关键文件
| 文件 | 状态 | 说明 |
|------|------|------|
| pages/goods-vr-ticket/goods-vr-ticket.vue | ✅ 已移植 | 主页面Phase 1 完成) |
| pages/goods-detail/goods-detail.vue | ⏳ 待修改 | 添加 is_vr_ticket 跳转判断 |
| components/iconfont/iconfont.vue | ✅ 已加固 | iconfont 组件 |
---
> ⚠️ **注意**:当前 Phase 2 正在开发中,核心为 4维规格选择器 + 座位选择器。Phase 3 将实现观演人表单和订单提交。座时:尚未选择 [立刻选座] │
│ 已选座时: │
│ [A区-2排-3号] [A区-2排-4号] │
│ 【重新选座】 │
├─────────────────────────────────────┤
│ [确认购票] │
└─────────────────────────────────────┘
点击[选座] → 打开座位选择器(全屏遮罩)
```
---
## 🗂️ 文件结构Phase 1 完成后)
```
pages/goods-vr-ticket/
├── goods-vr-ticket.vue # 主页面(移植自 App.vue
└── goods-vr-ticket.css # 独立样式文件(可选)
```
---
## 📝 待办事项
### ✅ 已完成
- [x] 在 `pages.json` 中注册新页面路由
- [x] 创建 `pages/goods-vr-ticket/` 目录
- [x] 搬运 `App.vue` 内容到 `goods-vr-ticket.vue`
- [x] 适配 UniApp 语法scrollable、navigation 等)
- [x] 替换内联 SVG → iconfont 组件(兼容微信小程序)
- [x] 添加 popup 容器左右留白padding: 16px
- [x] 修复 popup-content 溢出问题overflow-x, word-break
### ⏳ 后续待办
- [ ] 在 `goods-detail.vue` 中添加跳转判断逻辑
- [ ] 对 商品数据里的vr_config 等数据进行解析,
- [ ] 实现购票弹窗 Popup 交互移植老的4 维规格选择到新的购票弹窗 Popup 中。
- [ ] 实现购票弹窗内的选座逻辑
- [ ] 连接后端 BuyService API动态票务数据
- [ ] 优化 popup 滚动事件处理(:catchtouchmove
- [ ] 测试页面在真机/小程序环境运行
---
## 🔄 阶段进展记录
### 2026-05-13 样式迁移完成
- ✅ 移除 inline SVG改用 iconfont 组件
- ✅ map-icon 容器添加边框
- ✅ venue-popup-info 添加地址图标icon-location
- ✅ popup-container 添加 padding: 0 16px
- ✅ popup-content 修复溢出word-break, box-sizing
### 2026-05-14 导航优化完成
- ✅ goods-detail.vue 添加白色遮罩初始覆盖200ms后渐变消失
- ✅ 添加缓存预检查快速判断VR票务无需等待API
- ✅ goods-vr-ticket.vue 加载遮罩 + pageShow 显示控制
- ✅ 修复返回导航:智能判断页面栈,无历史则跳转首页
- ✅ goods-detail 使用 redirectTo 替代 navigateTo避免循环
### 关键文件
| 文件 | 状态 |
|------|------|
| pages/goods-vr-ticket/goods-vr-ticket.vue | ✅ 已移植 |
| pages/goods-vr-ticket/goods-vr-ticket.css | ✅ 已移植 |
| pages/goods-vr-ticket/components/ticket-header/index.vue | ✅ 已移植 |
| pages/goods-vr-ticket/components/venue-card/index.vue | ✅ 已移植 |
| pages/goods-vr-ticket/components/ticket-popup/index.vue | ✅ 已移植 |
| components/iconfont/iconfont.vue | ✅ 已加固尺寸 |
| pages/goods-detail/goods-detail.vue | ✅ 已加固导航 |
---
## 🔗 相关参考
| 文件 | 说明 |
|------|------|
| [references/show-start/src/App.vue](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/references/show-start/src/App.vue) | 源 UI 参考Vue 2 |
| [pages/goods-detail/goods-detail.vue](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/pages/goods-detail/goods-detail.vue) | 商品详情页(跳转入口) |
| [vr_ticket/ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) | 后端票务模板参考 |
| [pages.json](file:///Users/bigemon/WorkSpace/vr-shopxo-uniapp/pages.json) | 页面路由配置 |
---
> ⚠️ **注意**:当前为 Phase 1 规划,仅实现初步移植和测试环境搭建。后续 Phase 2-4 将逐步实现座位选择器、4维规格选择、完整购票流程等高级功能。

View File

@ -211,6 +211,19 @@
}
]
},
{
"root": "pages/goods-vr-ticket",
"pages": [
{
"path": "goods-vr-ticket",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true,
"navigationBarTitleText": ""
}
}
]
},
{
"root": "pages/login",
"pages": [

View File

@ -9,10 +9,12 @@
top: 28px;
z-index: 11;
}
.top-nav-left-icon {
left: auto;
margin-left: 20rpx;
}
.top-nav-left-icon .icon {
background: rgb(255 255 255 / 70%);
width: 25px;
@ -23,6 +25,7 @@
text-align: center;
padding: 3px;
}
.top-nav {
position: absolute;
bottom: 0;
@ -32,20 +35,24 @@
padding-bottom: 5px;
/* #endif */
}
.top-nav-content {
margin: 0 auto;
width: 310rpx;
margin-left: calc(50% - 170rpx);
}
.top-nav-content text {
font-size: 30rpx;
/* #ifdef H5 || APP */
font-size: 14px;
/* #endif */
}
.top-nav-content text:not(:last-child) {
margin-right: 30rpx;
}
.top-nav-content .nav-active {
font-weight: 500;
padding-bottom: 2rpx;
@ -53,6 +60,25 @@
border-width: 0 0 2px 0;
}
/**
/* VR 票务跳转遮罩 */
.vr-redirect-cover {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #fff;
z-index: 9999;
opacity: 1;
transition: opacity 0.3s ease;
}
.vr-redirect-cover-fade {
opacity: 0;
pointer-events: none;
}
/**
*
*/
@ -62,6 +88,7 @@
top: 62px;
left: 20rpx;
}
.nav-more-view .triangle {
width: 0;
height: 0;
@ -71,12 +98,15 @@
top: -5px;
left: calc(20rpx + 42px);
}
.nav-more-view .content {
background: rgb(0 0 0 / 0.7);
}
.nav-more-view .content .item {
font-size: 12px;
}
.nav-more-view .content .item:not(:last-child) {
border-bottom: 1px solid #585858;
}
@ -100,48 +130,61 @@
margin-right: 15rpx;
position: relative;
}
.goods-buy-nav .image {
width: 40rpx;
height: 40rpx;
margin: 10rpx 0 5rpx 0;
}
.goods-buy-nav .dis-block {
margin-top: -10rpx;
}
.goods-buy-nav .badge-icon {
position: absolute;
top: 2rpx;
left: calc(50% + 25rpx);
z-index: 1;
}
.goods-buy-nav-btn-number-0 .btn,
.goods-buy-nav-btn-number-1 .btn {
width: calc(100% - 15rpx) !important;
}
.goods-buy-nav-btn-number-2 .btn {
width: calc(50% - 15rpx) !important;
}
.goods-buy-nav-btn-number-3 .btn {
width: calc(33.33% - 15rpx) !important;
}
.goods-buy-nav-btn-number-4 .btn {
width: calc(25% - 15rpx) !important;
}
.goods-buy-nav-btn-number-5 .btn {
width: calc(20% - 15rpx) !important;
}
.goods-buy-nav-btn-number-6 .btn {
width: calc(16.66% - 15rpx) !important;
}
.goods-buy-nav-btn-number-7 .btn {
width: calc(14.28% - 15rpx) !important;
}
.goods-buy-nav-btn-number-8 .btn {
width: calc(12.5% - 15rpx) !important;
}
.goods-buy-nav-btn-number-9 .btn {
width: calc(11.11% - 15rpx) !important;
}
.goods-buy-nav-btn-number-10 .btn {
width: calc(10% - 15rpx) !important;
}
@ -153,16 +196,19 @@
position: relative;
min-height: 86rpx;
}
.goods-base-content .goods-title-content .goods-title {
font-size: 30rpx;
line-height: 40rpx;
font-weight: bold;
width: calc(100% - 200rpx);
}
.goods-base-content image {
width: 40rpx;
height: 40rpx;
}
.goods-base-content .collect {
width: 76rpx;
}
@ -176,16 +222,19 @@
left: 0;
z-index: 1;
}
.goods-video video {
width: 100%;
height: 55vh !important;
}
.goods-video-submit {
left: 50rpx;
top: auto;
bottom: 60rpx;
z-index: 2;
}
.goods-video-submit image {
width: 80rpx;
height: 80rpx !important;
@ -199,25 +248,31 @@
.goods-base-right-opt {
min-height: 145rpx;
}
.price-content {
width: calc(100% - 200rpx);
}
.goods-base-right-opt {
width: 200rpx;
}
.price-content,
.goods-base-right-opt {
box-sizing: border-box;
position: relative;
height: 100%;
}
.goods-base-right-opt image {
width: 40rpx;
height: 40rpx;
}
.goods-base-right-opt .collect {
width: 76rpx;
}
.price-content .price-icon {
padding: 0px 8rpx;
color: #fff;
@ -225,9 +280,11 @@
background: #FF5722;
font-size: 24rpx;
}
.price-content .sales-price {
font-size: 42rpx;
}
.price-content .item:not(:last-child) {
margin-bottom: 10rpx;
}
@ -248,13 +305,16 @@
.countdown-content {
width: 38%;
}
.countdown-content .time-title {
font-size: 38rpx;
margin-bottom: 10rpx;
}
.goods-base-price-countdown {
background: #e84742 !important;
}
.goods-base-price-countdown .price-content {
background: #f9534e;
background-repeat: no-repeat;
@ -262,21 +322,25 @@
width: 60%;
min-height: 164rpx;
}
.goods-base-price-countdown .price-content .price-icon {
border-color: #fff;
color: #fff;
background: transparent;
}
.goods-base-price-countdown .price-content .sales-price,
.goods-base-price-countdown .price-content .sales-price-unit,
.goods-base-price-countdown .price-content .points-price-value,
.goods-base-price-countdown .price-content .points-price-unit {
color: #fff !important;
}
.goods-base-price-countdown .price-content .original-price,
.goods-base-price-countdown .price-content .unit{
.goods-base-price-countdown .price-content .unit {
color: #ffcece !important;
}
.price-content .price-icon.seckill {
position: absolute;
right: 20rpx;
@ -291,9 +355,11 @@
padding: 8rpx 10rpx;
width: calc(50% - 20rpx);
}
.goods-parameters .item:nth-child(2n-1) {
float: left;
}
.goods-parameters .item:nth-child(2n) {
float: right;
}
@ -306,9 +372,11 @@
overflow-y: scroll;
overflow-x: hidden;
}
.popup-params-container .item .name {
width: 220rpx;
}
.popup-params-container .item .value {
width: calc(100% - 240rpx);
}
@ -331,9 +399,11 @@
background: #fffbfc;
color: #d2354c;
}
.goods-panel-container view {
padding: 15rpx 0;
}
.goods-panel-container view:not(:first-child) {
border-top: 1px dashed #fff1f2;
}
@ -360,6 +430,7 @@
-moz-box-shadow: 0 8px 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 8px 10px rgb(0 0 0 / 20%);
}
.plugins-salerecords-tips image,
.plugins-salerecords-tips-top-left image,
.plugins-salerecords-tips-top-right image,
@ -371,15 +442,18 @@
height: 50rpx;
border-radius: 50%;
}
.plugins-salerecords-tips-top-left {
left: 5%;
top: 13%;
}
.plugins-salerecords-tips-top-right {
right: 5%;
top: 13%;
left: auto;
}
.plugins-salerecords-tips-top-center {
top: 13%;
}
@ -388,9 +462,11 @@
.plugins-salerecords-tips-top-left {
top: 5%;
}
.plugins-salerecords-tips-top-right {
top: 5%;
}
.plugins-salerecords-tips-top-center {
top: 5%;
}
@ -401,12 +477,14 @@
bottom: 10%;
top: auto;
}
.plugins-salerecords-tips-bottom-right {
right: 5%;
bottom: 10%;
left: auto;
top: auto;
}
.plugins-salerecords-tips-bottom-center {
bottom: 10%;
top: auto;
@ -418,6 +496,7 @@
.coupon-padding {
padding: 2rpx;
}
.mini-coupon {
position: relative;
display: inline-block;
@ -426,6 +505,7 @@
border-radius: 4rpx;
padding: 0 16rpx;
}
.mini-coupon::before,
.mini-coupon::after {
content: '';
@ -437,64 +517,80 @@
top: 50%;
transform: translateY(-50%);
}
.mini-coupon::before {
left: -8rpx;
}
.mini-coupon::after {
right: -8rpx;
}
.mini-coupon-br::before,
.mini-coupon-br::after {
border: 1px solid #FE5500;
}
.coupon-srcoll {
white-space: nowrap;
overflow-x: auto;
}
.coupon-srcoll::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.received-coupon {
background: #FFEBE0;
border: 2rpx solid #FE5500;
color: #FE5500;
}
.not-received-coupon {
background: #FE5500;
border: 2rpx solid #FE5500;
color: #fff;
}
.received-coupon .divider-l::before {
background: red;
}
.not-received-coupon .divider-l::before {
background: #fff;
}
.plugins-coupon-container-view .item-title {
min-width: 70rpx;
}
.plugins-coupon-container-view .item:not(:last-child) {
margin-right: 10rpx;
}
.plugins-coupon-container {
max-height: 50vh;
overflow-y: scroll;
overflow-x: hidden;
margin-top: 20rpx;
}
.plugins-coupon-container .v-left {
padding: 20rpx 10rpx 20rpx 20rpx;
}
.plugins-coupon-container .item:not(:last-child) {
margin-bottom: 20rpx;
}
.plugins-coupon-container .item,
.plugins-coupon-container .v-right,
.plugins-coupon-container .v-right .circle {
height: 175rpx;
}
.plugins-coupon-container .v-left .base .price {
font-size: 46rpx;
}
@ -505,17 +601,21 @@
.plugins-label navigator:not(:last-child) {
margin-right: 20rpx;
}
.plugins-label navigator {
margin-bottom: 20rpx;
}
.plugins-label-top-left,
.plugins-label-top-center,
.plugins-label-top-right {
top: calc(var(--status-bar-height) + 130rpx);
}
.plugins-label-text {
padding: 20rpx 20rpx 0 20rpx;
}
.plugins-label-bottom-left,
.plugins-label-bottom-center,
.plugins-label-bottom-right {
@ -531,14 +631,17 @@
border-color: #ffe2e0;
color: #dd514c;
}
.plugins-intellectstools-content-top-container {
background: #daeeff;
color: #2b6e8f;
border: 1px solid #c9e9ff;
}
.plugins-intellectstools-content-top-container .panel-content .item:first-child {
padding-top: 0 !important;
}
.plugins-intellectstools-content-top-container .panel-title,
.plugins-intellectstools-content-top-container .panel-content .item:not(:last-child) {
border-bottom: 1px solid #c8e4fb !important;
@ -561,13 +664,16 @@
width: 32rpx;
height: 32rpx !important;
}
.plugins-goodsservice-container .item image {
width: 70rpx;
height: 70rpx !important;
}
.plugins-goodsservice-container .item .left {
width: 80rpx;
}
.plugins-goodsservice-container .item .right {
width: calc(100% - 90rpx);
}

View File

@ -1,5 +1,8 @@
<template>
<view :class="theme_view">
<!-- VR 票务跳转遮罩 -->
<view :class="['vr-redirect-cover', vr_cover_fade ? 'vr-redirect-cover-fade' : '']"></view>
<!-- 顶部导航 -->
<view v-if="goods != null" :class="'page ' + (is_single_page == 1 ? ' single-page-top' : '')">
<!-- 单页模式不展示导航栏 -->
@ -710,6 +713,9 @@
goods_bottom_nav_status: true,
//
goods_bottom_opt_nav_status: app.globalData.get_config('plugins_base.realstore', null) == null,
// VR
vr_redirect_cover: true,
vr_cover_fade: false,
//
nav_more_status: false,
nav_more_timer: null,
@ -843,6 +849,20 @@
//
is_opt_cart: params.is_opt_cart === undefined ? app.globalData.data.is_goods_bottom_opt_cart || 0 : parseInt(params.is_opt_cart || 0),
});
// VR
setTimeout(() => {
this.setData({ vr_cover_fade: true });
}, 200);
// VR
var cachedGoods = app.globalData.goods_data_cache_handle(params.id);
if (cachedGoods && cachedGoods.vr_goods_config) {
uni.redirectTo({
url: '/pages/goods-vr-ticket/goods-vr-ticket?id=' + params.id
});
return;
}
},
onShow() {
@ -960,6 +980,16 @@
//
this.init_result_data_handle(goods);
// VR
var isVRTicket = (goods.vr_goods_config || null) != null;
if (isVRTicket) {
uni.redirectTo({
url: '/pages/goods-vr-ticket/goods-vr-ticket?id=' + goods.id
});
return;
}
//
var plugins_seckill_data = data.plugins_seckill_data || null;
var upd_data = {

View File

@ -0,0 +1,114 @@
<template>
<view class="ticket-header">
<view class="poster">
<image class="poster-img" :src="posterUrl" :mode="posterMode" />
</view>
<view class="header-info">
<text class="title">{{ title }}</text>
<text class="tag">{{ tag }}</text>
<view class="collect-btn" @click="handleCollect">
<text class="heart-icon">{{ collected ? '♥' : '♡' }}</text>
<text>{{ collected ? '已收藏' : '收藏演出' }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'TicketHeader',
props: {
posterUrl: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
tag: {
type: String,
default: ''
},
collected: {
type: Boolean,
default: false
},
posterMode: {
type: String,
default: 'aspectFill'
}
},
methods: {
handleCollect() {
this.$emit('collect');
}
}
};
</script>
<style>
.ticket-header {
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 80, 255, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(255, 0, 127, 0.1) 0%, transparent 40%),
linear-gradient(160deg, #1a1625 0%, #2d2840 50%, #1f1b2e 100%);
padding: 54px 20px 64px 20px;
display: flex;
align-items: stretch;
color: #fff;
position: relative;
}
.poster {
flex-shrink: 0;
width: 105px;
height: 140px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
.poster-img {
width: 100%;
height: 100%;
}
.header-info {
margin-left: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
font-size: 18px;
font-weight: bold;
line-height: 1.4;
margin: 0;
margin-top: 4px;
}
.tag {
font-size: 12px;
color: #d0cdd8;
margin-top: 10px;
text-align: left;
}
.collect-btn {
margin-top: auto;
align-self: flex-start;
background-color: rgba(0, 0, 0, 0.4);
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
display: flex;
align-items: center;
}
.heart-icon {
font-size: 14px;
margin-right: 4px;
}
</style>

View File

@ -0,0 +1,337 @@
<template>
<view class="ticket-popup" v-if="visible" @click="handleMaskClick">
<view class="popup-inner" @click.stop>
<!-- Popup Header: 服务说明 -->
<view class="popup-header" v-if="type === 'service'">
<text class="popup-title-text">服务说明</text>
<text class="popup-close" @click="close"></text>
</view>
<!-- Popup Header: 所有场馆 -->
<view class="popup-header" v-if="type === 'venue'">
<text class="popup-title-text">所有场馆</text>
<text class="popup-close" @click="close"></text>
</view>
<!-- Popup Header: 须知 (带tabs) -->
<view class="popup-header has-tabs" v-if="type === 'notice'">
<view class="popup-tabs">
<text
class="tab-item"
:class="{ active: activeTab === 'buy' }"
@click="activeTab = 'buy'"
>购票须知</text>
<text
class="tab-item"
:class="{ active: activeTab === 'watch' }"
@click="activeTab = 'watch'"
>观演须知</text>
</view>
<text class="popup-close" @click="close"></text>
</view>
<!-- Popup Content -->
<scroll-view class="popup-content" scroll-y :scroll-top="0">
<!-- 服务说明 -->
<template v-if="type === 'service'">
<view class="service-item" v-for="(item, index) in services" :key="index">
<view class="service-item-title">
<text :class="item.status === 'success' ? 'icon-success' : 'icon-warning'">
{{ item.status === 'success' ? '✔' : '!' }}
</text>
{{ item.title }}
</view>
<view class="service-item-content">{{ item.desc }}</view>
</view>
</template>
<!-- 须知 -->
<template v-if="type === 'notice'">
<view class="service-item" v-for="(item, index) in currentList" :key="index">
<view class="service-item-title notice-pure-title">{{ item.title }}</view>
<view class="service-item-content">{{ item.desc }}</view>
</view>
</template>
<!-- 场馆列表 -->
<template v-if="type === 'venue'">
<view class="venue-item" v-for="(item, index) in venues" :key="index" @click="handleVenueClick(item)">
<view class="venue-info">
<view class="venue-row">
<iconfont name="icon-location" color="#ff404f" size="20px" propStyle="margin-right: 6px;"></iconfont>
<text class="venue-name">{{ item.name }}</text>
</view>
<text class="venue-address">{{ item.address }}</text>
</view>
<view class="venue-nav">
<view class="nav-circle">
<iconfont name="icon-map-navigator" color="#fff" size="32rpx"></iconfont>
</view>
</view>
</view>
</template>
</scroll-view>
</view>
</view>
</template>
<script>
export default {
name: 'TicketPopup',
props: {
visible: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'service', // service | venue | notice
validator: val => ['service', 'venue', 'notice'].includes(val)
},
//
services: {
type: Array,
default: () => []
},
//
venues: {
type: Array,
default: () => []
},
//
buyNotices: {
type: Array,
default: () => []
},
//
watchNotices: {
type: Array,
default: () => []
},
// tab
defaultTab: {
type: String,
default: 'buy'
}
},
data() {
return {
activeTab: this.defaultTab
};
},
computed: {
currentList() {
return this.activeTab === 'buy' ? this.buyNotices : this.watchNotices;
}
},
watch: {
defaultTab(val) {
this.activeTab = val;
}
},
methods: {
handleMaskClick() {
this.$emit('close');
},
close() {
this.$emit('close');
},
handleVenueClick(venue) {
this.$emit('nav', venue);
}
}
};
</script>
<style>
/* Popup 容器样式 - 复用主 CSS 文件中的定义 */
.ticket-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: flex-end;
}
.popup-inner {
width: 100%;
height: 70vh;
background-color: #fff;
border-radius: 16px 16px 0 0;
display: flex;
flex-direction: column;
max-height: 70vh;
padding: 0 16px;
}
.popup-header {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
flex-shrink: 0;
}
.popup-header.has-tabs {
justify-content: flex-start;
}
.popup-title-text {
font-size: 18px;
font-weight: bold;
color: #111;
}
.popup-tabs {
display: flex;
gap: 24px;
}
.tab-item {
font-size: 16px;
color: #666;
position: relative;
transition: all 0.2s;
}
.tab-item.active {
font-size: 20px;
font-weight: bold;
color: #111;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: -12px;
left: 50%;
transform: translateX(-50%);
width: 50%;
height: 3px;
background-color: #ff404f;
border-radius: 2px;
}
.popup-close {
position: absolute;
right: 20px;
font-size: 20px;
color: #333;
font-weight: 300;
}
.popup-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px 20px 40px 20px;
box-sizing: border-box;
word-break: break-word;
}
/* 服务/须知列表项 */
.service-item {
margin-bottom: 24px;
}
.service-item-title {
font-size: 16px;
font-weight: bold;
color: #111;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.notice-pure-title {
font-weight: normal;
}
.service-item-content {
font-size: 14px;
color: #666;
line-height: 1.6;
padding-left: 0;
text-align: left;
}
.icon-success, .icon-warning {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #fff;
margin-right: 6px;
}
.icon-success {
background-color: #2ac55a;
}
.icon-warning {
background-color: transparent;
color: #ff404f;
border: 1px solid #ff404f;
}
/* 场馆列表项 */
.venue-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px;
border-bottom: 1px solid #f0f0f0;
}
.venue-item:last-child {
border-bottom: none;
}
.venue-info {
flex: 1;
text-align: left;
padding-right: 16px;
}
.venue-row {
display: flex;
align-items: center;
margin-bottom: 6px;
line-height: 1;
}
.venue-name {
font-size: 16px;
font-weight: bold;
color: #111;
margin-bottom: 6px;
}
.venue-address {
font-size: 13px;
color: #999;
line-height: 1.4;
}
.venue-nav {
flex-shrink: 0;
}
.nav-circle {
width: 36px;
height: 36px;
background-color: #d2cfcf;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,119 @@
<template>
<view class="venue-card" @click="handleClick">
<view class="venue-badge" v-if="isNearest"></view>
<view class="venue-content">
<view class="venue-info">
<text class="venue-name">{{ venue.name || '获取中...' }}</text>
<text class="venue-address" @click.stop="handleAddressClick">{{ venue.address || '获取中...' }}</text>
</view>
<view class="venue-action">
<view class="map-icon">
<iconfont name="icon-location" color="#e61818" size="16px"></iconfont>
</view>
<text class="map-more">所有场馆 ></text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'VenueCard',
props: {
venue: {
type: Object,
default: () => ({})
},
isNearest: {
type: Boolean,
default: false
}
},
methods: {
handleClick() {
this.$emit('click', this.venue);
},
handleAddressClick() {
this.$emit('nav', this.venue);
}
}
};
</script>
<style>
.venue-card {
margin-top: 10px;
background-color: #fcfcfc;
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
background-image: radial-gradient(circle at right center, rgba(240,240,240,0.8) 0%, transparent 50%);
}
.venue-badge {
display: inline-block;
font-size: 12px;
color: #fff;
background-color: #ff404f;
padding: 1px 6px;
border-radius: 4px;
margin-bottom: 4px;
width: fit-content;
}
.venue-content {
display: flex;
justify-content: space-between;
align-items: stretch;
width: 100%;
}
.venue-info {
flex: 1;
text-align: left;
display: flex;
flex-direction: column;
justify-content: center;
}
.venue-name {
font-size: 15px;
font-weight: bold;
color: #111;
}
.venue-address {
font-size: 12px;
color: #999;
margin-top: 6px;
}
.venue-action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-left: 12px;
}
.map-icon {
width: 22px;
height: 22px;
background-color: #ffffff;
border: 1.5px solid #f76464;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.map-more {
font-size: 11px;
color: #999;
margin-top: 4px;
text-align: center;
}
</style>

View File

@ -0,0 +1,569 @@
/* =========== 基础页面样式 =========== */
.ticket-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f5f6f8;
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
.header-banner {
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 80, 255, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(255, 0, 127, 0.1) 0%, transparent 40%),
linear-gradient(160deg, #1a1625 0%, #2d2840 50%, #1f1b2e 100%);
padding: 54px 20px 64px 20px;
display: flex;
align-items: stretch;
color: #fff;
position: relative;
}
.poster {
flex-shrink: 0;
width: 105px;
height: 140px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.poster-img {
width: 100%;
height: 100%;
}
.header-info {
margin-left: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.header-info .title {
font-size: 18px;
font-weight: bold;
line-height: 1.4;
margin: 0;
margin-top: 4px;
}
.header-info .tag {
font-size: 12px;
color: #d0cdd8;
margin-top: 10px;
text-align: left;
}
.collect-btn {
margin-top: auto;
align-self: flex-start;
background-color: rgba(0, 0, 0, 0.4);
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
display: flex;
align-items: center;
}
.heart-icon {
font-size: 14px;
margin-right: 4px;
}
/* =========== Main Content =========== */
.main-content {
background-color: #fff;
border-radius: 16px 16px 0 0;
margin-top: -24px;
padding: 20px;
position: relative;
z-index: 10;
}
/* Location Card */
.location-card {
margin-top: 10px;
background-color: #fcfcfc;
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
background-image: radial-gradient(circle at right center, rgba(240, 240, 240, 0.8) 0%, transparent 50%);
}
.location-content {
display: flex;
justify-content: space-between;
align-items: stretch;
width: 100%;
}
.location-info .venue {
font-size: 15px;
font-weight: bold;
color: #111;
text-align: left;
}
.location-info .address {
font-size: 12px;
color: #999;
margin-top: 6px;
}
.location-info {
flex: 1;
text-align: left;
display: flex;
flex-direction: column;
justify-content: center;
}
.venue-badge {
display: inline-block;
font-size: 12px;
color: #fff;
background-color: #ff404f;
padding: 1px 6px;
border-radius: 4px;
margin-bottom: 4px;
}
.map-more {
font-size: 11px;
color: #999;
margin-top: 4px;
text-align: center;
}
.map-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-left: 12px;
}
.map-icon {
width: 22px;
height: 22px;
background-color: #ffffff;
border: 1.5px solid #f76464;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pin-icon {
width: 16px;
height: 16px;
fill: #e61818;
}
/* Service Header */
.service-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: 20px;
}
.service-tags {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1;
}
.service-tags-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
.service-tags-wrapper::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 30px;
background: linear-gradient(to right, transparent, #fff);
}
.service-more {
font-size: 13px;
color: #999;
margin-left: 8px;
}
.tag-item {
font-size: 11px;
color: #333;
display: flex;
align-items: center;
}
.icon-success,
.icon-warning {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #fff;
margin-right: 6px;
transform: scale(0.9);
}
.icon-success {
background-color: #2ac55a;
}
.icon-warning {
background-color: transparent;
color: #ff404f;
border: 1px solid #ff404f;
}
/* =========== Section Card =========== */
.mt-10 {
margin-top: 10px;
}
.section-card {
background-color: #fff;
padding: 24px 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #111;
margin: 0;
}
.section-more {
font-size: 13px;
color: #999;
}
.notice-grid {
display: flex;
flex-wrap: wrap;
gap: 20px 16px;
margin-top: 20px;
}
.notice-item {
width: calc(50% - 8px);
min-width: 0;
}
.notice-title {
font-size: 14px;
font-weight: bold;
color: #333;
display: flex;
align-items: center;
}
.notice-title .dot {
width: 4px;
height: 4px;
background-color: #d8d8d8;
border-radius: 50%;
margin-right: 6px;
flex-shrink: 0;
}
.notice-desc {
font-size: 12px;
color: #999;
margin-top: 6px;
padding-left: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
/* =========== Bottom Bar =========== */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
display: flex;
align-items: center;
padding: 10px 20px;
padding-bottom: calc(10px + constant(safe-area-inset-bottom));
padding-bottom: calc(10px + env(safe-area-inset-bottom));
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.03);
z-index: 100;
}
.customer-service {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 24px;
color: #333;
}
.service-icon {
font-size: 20px;
margin-bottom: 2px;
}
.customer-service text {
font-size: 10px;
}
.buy-button {
flex: 1;
background-color: #ff404f;
color: #fff;
border: none;
border-radius: 25px;
padding: 14px 0;
font-size: 16px;
font-weight: bold;
text-align: center;
line-height: 1;
}
/* =========== Popup 样式 =========== */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
align-items: flex-end;
}
.popup-container {
width: 100%;
height: 70vh;
background-color: #fff;
border-radius: 16px 16px 0 0;
display: flex;
flex-direction: column;
max-height: 70vh;
padding: 0 16px;
}
.popup-header {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
position: relative;
flex-shrink: 0;
}
.popup-header.has-tabs {
justify-content: flex-start;
}
.popup-title-text {
font-size: 18px;
font-weight: bold;
color: #111;
}
.popup-tabs {
display: flex;
gap: 24px;
}
.tab-item {
font-size: 16px;
color: #666;
position: relative;
transition: all 0.2s;
}
.tab-item.active {
font-size: 20px;
font-weight: bold;
color: #111;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: -12px;
left: 50%;
transform: translateX(-50%);
width: 50%;
height: 3px;
background-color: #ff404f;
border-radius: 2px;
}
.popup-close {
position: absolute;
right: 20px;
font-size: 20px;
color: #333;
font-weight: 300;
}
.popup-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px 20px 40px 20px;
box-sizing: border-box;
word-break: break-word;
}
/* 弹窗内的说明列表样式 */
.service-popup-global-desc {
font-size: 14px;
color: #666;
line-height: 1.5;
margin-bottom: 24px;
}
.service-popup-item {
margin-bottom: 24px;
}
.service-popup-item-title {
font-size: 16px;
font-weight: bold;
color: #111;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.notice-pure-title {
/* 须知弹窗里没有icon可以微调样式 */
}
.service-popup-item-content {
font-size: 14px;
color: #666;
line-height: 1.6;
padding-left: 0;
text-align: left;
}
/* 带有icon时文字需要和icon对齐缩进处理 */
.service-popup-item-title .icon-success,
.service-popup-item-title .icon-warning {
margin-right: 8px;
}
/* 场馆列表样式 */
.location-card {
cursor: pointer;
}
.venue-popup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.venue-popup-item:last-child {
border-bottom: none;
}
.venue-popup-info {
flex: 1;
text-align: left;
padding-right: 16px;
}
.venue-info-row {
display: flex;
align-items: center;
margin-bottom: 6px;
line-height: 1;
}
.venue-popup-name {
font-size: 16px;
font-weight: bold;
color: #111;
margin-bottom: 6px;
}
.venue-popup-address {
font-size: 13px;
color: #999;
line-height: 1.4;
}
.venue-popup-nav {
flex-shrink: 0;
}
.nav-icon-circle {
width: 36px;
height: 36px;
background-color: #d2cfcf;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
/* =========== 顶部导航按钮 =========== */
.pf {
position: fixed;
}
.top-nav-left-icon {
position: fixed;
left: 12px;
top: 44px;
display: flex;
align-items: center;
z-index: 9999;
}
.back-icon {
width: 32px;
height: 32px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.round {
border-radius: 50%;
}
.cp {
cursor: pointer;
}

View File

@ -0,0 +1,344 @@
<template>
<view class="ticket-page">
<!-- 顶部返回按钮 -->
<view class="top-nav-left-icon pf" style="top: 44px;">
<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"
@collect="toggleCollect"
/>
<!-- 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">
<view class="service-icon">🎧</view>
<text>客服</text>
</view>
<button class="buy-button" @click="openTicketPopup"></button>
</view>
<!-- Popup Container - 使用弹窗组件 -->
<ticket-popup
:visible="popupVisible"
:type="popupType"
:services="services"
:venues="venues"
:buy-notices="buyNotices"
:watch-notices="watchNotices"
:default-tab="noticeActiveTab"
@close="closePopup"
@nav="openAmap"
/>
</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';
export default {
name: 'GoodsVrTicket',
//
components: {
TicketHeader,
VenueCard,
TicketPopup
},
data() {
return {
// App
app: null,
// ID
goodsId: null,
//
goodsData: {},
// VR
vrConfig: [],
//
venues: [],
//
currentVenue: {},
//
collected: false,
//
popupVisible: false,
popupType: 'service',
noticeActiveTab: 'buy',
// ( API )
services: [
{ title: '电子票', desc: '现场验票时请观演人出示APP票夹中的电子票二维码验票入场不支持截屏。', status: 'success' },
{ title: '不支持转赠', desc: '本场演出实行实名制购票观演政策,下单时需填写观演人信息。购票完成后,观演人信息不可更改,需携带购票时填写的证件验证入场观演。', status: 'warning' },
{ title: '不支持退换票', desc: '票品为有价票券,非普通商品,其背后承载的文化服务具有时效性,稀缺性等特征,不支持退换。', status: 'warning' }
],
//
buyNotices: [
{ title: '实名购票', desc: '本场演出实行实名制购票观演政策,一证一票。' },
{ title: '演出时长以现场为准', desc: '受现场不可控因素影响,实际演出时长以现场为准。' },
{ title: '定时购说明', desc: '使用定时购服务后将在开票时为您自动购票不保证购票成功也不影响开票后的正常购票使用需扣减票价等额90%的秀豆积分。' },
{ title: '候补说明', desc: '使用候补服务后将按照订单出票时间先后顺序候补不保证一定成功候补服务费一般为订单费用的5.5%。' }
],
//
watchNotices: [
{ title: '站席观演', desc: '场地内不设座位,均为站席观演。' },
{ title: '禁止携带物品', desc: '由于安保和版权的原因,大多数演出、展览及比赛场所禁止携带食品、饮料、专业摄录设备、打火机等物品。' },
{ title: '退换政策', desc: '票品为有价票券,非普通商品,其背后承载的文化服务具有时效性,稀缺性等特征,不支持退换。' }
]
};
},
computed: {
// tab
currentNoticeList() {
return this.noticeActiveTab === 'buy' ? this.buyNotices : this.watchNotices;
}
},
methods: {
//
goBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
//
uni.navigateBack();
} else {
//
uni.reLaunch({
url: '/pages/index/index'
});
}
},
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);
} 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);
}
}
});
},
//
handleGoodsData(goods) {
//
this.goodsData = goods;
// 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);
//
this.initVenueData();
},
//
initVenueData() {
// 使 vrConfig
const rawApiData = this.vrConfig || [];
this.venues = rawApiData.map(item => item.template_snapshot.venue);
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() {
this.collected = !this.collected;
// TODO: API
},
//
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;
}
}
};
</script>
<style>
@import "./goods-vr-ticket.css";
</style>