项目合作开发
一、页面基础信息展示
-
影片与场次信息 页面顶部清晰展示当前观影影片《蜘蛛侠:纵横宇宙》,同步标注放映时间「今天 19:30」、放映厅规格「IMAX 激光」、厅号「3 号厅」,让用户第一时间确认观影场次无误。
-
座位状态图例说明 页面上方设置 4 类座位状态标识,直观区分不同座位属性:
-
深蓝色方块:可选座位,未被购买,可直接点击选中;
-
浅紫色方块:已选座位,用户点击后标记,计入订单;
-
纯黑色方块:已售座位,其他用户已购买,无法选择;
-
粉红色方块:情侣座,双人连体专属座位,适合结伴情侣选购。
- 银幕方位指引 座位区域最上方标注「银幕」字样,明确观影朝向,方便用户判断座位前后、左右观影视角。
二、座位布局规则
-
排号划分 纵向左侧数字 1-20 代表排数,1 排为最靠近银幕的前排,数字越大越靠后;
-
座位编号 横向数字为单排内座位号,中间存在过道分隔,左右两大区域,10 排设有专属情侣连体座位区;
-
特殊禁选座位 黑色遮挡座位为已售出 / 设备占位,全程无法点击选择,避免用户误选。
三、核心选座操作流程
-
挑选座位 点击任意深蓝色可选座位,座位会变为浅紫色「已选」状态;10 排粉色情侣座可直接点击选购双人位。
-
选座数量限制 页面左下角提示规则:单次最多选择 4 个座位,超出数量无法新增选中,适配单人、双人、三四人结伴观影需求。
-
订单金额实时计算 右下角「合计」区域会根据选中座位数量自动计算总票价,未选座时显示 ¥0。
-
确认下单 选好满意座位后,点击右下角深色「确认选座」按钮,即可进入购票支付环节,锁定选中座位。
四、功能实用优势
-
可视化选座:完整还原影厅真实座位排布,直观看清空位、已售、情侣座分布,不用到现场就能挑选心仪视角;
-
状态区分清晰:四种颜色标识无认知门槛,快速分辨能否选购;
-
限制防误操作:最高 4 座选购限制,避免单次多选造成票务资源浪费;
-
信息一体化:影片、时间、影厅、座位、总价同页展示,操作链路简短,购票流程简单易懂。
五、使用小提示
-
前排(1-6 排)距离银幕近,视觉冲击强但易视觉疲劳;中后段 8-15 排为观影黄金区域,画面比例观感最佳;
-
情侣优先选择 10 排粉色连体情侣座,私密性更好;
-
若心仪座位显示黑色,代表已被他人购买,可选择同排相邻空位或更换前后排。
-
当前版本情侣座功能暂无自动连坐匹配机制,仅作页面展示效果,开发者可根据实际业务需求,对该情侣座展示模块进行删除、替换或功能优化升级。
六、功能适用场景
-
个人独自观影场景:用户单人观影时,可通过该功能快速挑选单人最优座位,优先选择影厅中间黄金席位,操作简单、选座高效,无需人工协助,自助完成购票选座,适配日常休闲、独自看片、周末放松等个人观影需求。
-
情侣双人观影场景:情侣约会观影时,可精准选择专属粉色情侣连体座位,座位紧邻、私密性更强,贴合双人约会的观影需求,同时可直观避开人流密集区域,提升观影体验。
-
亲友结伴观影场景:朋友、家人、亲子等多人结伴观影(2-4人)时,依托单次最多选4座的功能规则,可一键挑选相邻连座,避免座位分散,保障结伴观影的互动性,适配家庭观影、朋友聚会、同学团建等场景。
-
观影选位对比参考场景:用户提前规划观影场次时,可通过可视化座位布局,实时查看座位售卖情况,对比不同排数、不同区域的观影视角,根据自身需求避开过近、过偏的座位,提前锁定优质席位,避免到店无好座的情况。
-
日常快速购票场景:日常刚需观影、临时追剧、院线新片打卡等场景下,依托页面一体化信息展示,快速确认影片、场次、影厅信息,一键选座下单,简化购票流程,节省排队、选座时间,适配快节奏的观影需求。
-
观影避坑选座场景:针对热门影片、黄金场次座位紧张的情况,用户可通过座位颜色标识,快速甄别已售、可售座位,及时更换备选座位,高效完成购票,避免心仪座位售罄导致观影计划受阻。
-
演示图片如下


<template>
<view class="page">
<!-- 顶部影厅信息 -->
<view class="header">
<view class="movie-info">
<text class="movie-title">蜘蛛侠:纵横宇宙</text>
<text class="movie-meta">今天 19:30 | IMAX激光 | 3号厅</text>
</view>
</view>
<!-- 图例 -->
<view class="legend">
<view class="legend-item">
<view class="legend-dot available" />
<text class="legend-text">可选</text>
</view>
<view class="legend-item">
<view class="legend-dot selected" />
<text class="legend-text">已选</text>
</view>
<view class="legend-item">
<view class="legend-dot sold" />
<text class="legend-text">已售</text>
</view>
<view class="legend-item">
<view class="legend-dot couple" />
<text class="legend-text">情侣座</text>
</view>
</view>
<!-- 银幕提示 -->
<view class="screen-wrap">
<view class="screen-bar">
<text class="screen-text">--- 银 幕 ---</text>
</view>
</view>
<!-- 座位区域(可缩放) -->
<view class="seat-container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
<view class="seat-map" :style="seatMapStyle">
<!-- 行号 + 座位 -->
<view v-for="(row, rowIndex) in seatLayout" :key="rowIndex" class="seat-row">
<text class="row-label">{{ rowIndex + 1 }}</text>
<view v-for="(seat, colIndex) in row" :key="colIndex" class="seat-wrap">
<!-- 过道间隔 -->
<view v-if="seat === 'aisle'" class="aisle" />
<!-- 普通座位 -->
<view v-else class="seat" :class="getSeatClass(seat)"
@tap="onSeatTap(rowIndex, colIndex, seat)">
<text v-if="seat.status !== 'sold'" class="seat-num">{{ colIndex + 1 }}</text>
<image v-if="seat.status === 'sold'" src="/static/icons/seat-sold.png" class="seat-icon"
mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
<!-- 缩放提示 -->
<view class="zoom-tip">
<text class="zoom-tip-text">双指捏合可缩放座位图 · 当前 {{ Math.round(scale * 100) }}%</text>
</view>
<!-- 底部结算栏 -->
<view class="bottom-bar">
<view class="selected-info">
<view v-if="selectedSeats.length === 0" class="no-select">
<text class="no-select-text">请选择座位(最多4个)</text>
</view>
<view v-else class="selected-seats">
<view v-for="(s, i) in selectedSeats" :key="i" class="seat-tag">
<text class="seat-tag-text">{{ s.row + 1 }}排{{ s.col + 1 }}座</text>
</view>
</view>
</view>
<view class="price-block">
<text class="price-label">合计</text>
<text class="price-value">¥{{ totalPrice }}</text>
</view>
<view class="confirm-btn" :class="{ active: selectedSeats.length > 0 }" @tap="onConfirm">
<text class="confirm-text">确认选座</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'SeatSelection',
data() {
return {
// 票价
ticketPrice: 68,
// 缩放相关
scale: 0.8,
minScale: 0.8,
maxScale: 2,
lastScale: 1,
lastDistance: 0,
// 平移相关
translateX: 0,
translateY: 0,
lastTranslateX: 0,
lastTranslateY: 0,
lastMidX: 0,
lastMidY: 0,
// 已选座位
selectedSeats: [],
maxSelect: 4,
// 座位数据:'aisle' 为过道,对象为座位
// status: available | sold | couple
seatLayout: [],
// 页面加载后自动计算
fitScale: 0.8 // 自动适配后的初始比例
}
},
computed: {
seatMapStyle() {
console.log('当前scale=', this.scale)
return {
transform: `
translate(${this.translateX}px, ${this.translateY}px)
scale(${this.scale})
`,
transformOrigin: 'center center',
transition: 'none',
}
},
totalPrice() {
return this.selectedSeats.length * this.ticketPrice
},
},
created() {
this.initSeats()
},
methods: {
// 初始化座位布局 对接api
initSeats() {
const rows = 20
const cols = 14
// 随机已售座位
const soldSet = new Set()
for (let i = 0; i < 20; i++) {
soldSet.add(`${Math.floor(Math.random() * rows)}-${Math.floor(Math.random() * cols)}`)
}
const layout = []
for (let r = 0; r < rows; r++) {
const row = []
for (let c = 0; c < cols; c++) {
// 第7列添加过道
if (c === 6) row.push('aisle')
const key = `${r}-${c}`
const isCouple = (r === 9 && c >= 4 && c <= 9) // 最后一排情侣座
row.push({
row: r,
col: c,
status: soldSet.has(key) ? 'sold' : isCouple ? 'couple' : 'available',
selected: false,
})
}
layout.push(row)
}
this.seatLayout = layout
this.$nextTick(() => {
this.autoFitSeatMap()
})
},
// 自动缩放座位图
autoFitSeatMap() {
uni.createSelectorQuery()
.in(this)
.select('.seat-container')
.boundingClientRect(rect => {
if (!rect) return
const mapWidth = 620
const mapHeight = 900
const scaleX =
rect.width / mapWidth
const scaleY =
rect.height / mapHeight
const fitScale =
Math.min(scaleX, scaleY)
this.scale = fitScale
this.minScale = fitScale
this.translateX = 0
this.translateY = 0
})
.exec()
},
getSeatClass(seat) {
if (seat === 'aisle') return ''
if (seat.selected) return 'seat--selected'
if (seat.status === 'sold') return 'seat--sold'
if (seat.status === 'couple') return 'seat--couple'
return 'seat--available'
},
onSeatTap(rowIndex, colIndex, seat) {
if (!seat || seat === 'aisle') return
if (seat.status === 'sold') {
uni.showToast({
title: '该座位已售出',
icon: 'none'
})
return
}
if (seat.selected) {
// 取消选择
seat.selected = false
this.selectedSeats = this.selectedSeats.filter(
s => !(s.row === rowIndex && s.col === colIndex)
)
} else {
if (this.selectedSeats.length >= this.maxSelect) {
uni.showToast({
title: `最多选${this.maxSelect}个座位`,
icon: 'none'
})
return
}
seat.selected = true
this.selectedSeats.push({
row: rowIndex,
col: colIndex,
type: seat.status
})
}
// 触发响应式更新
this.$forceUpdate()
},
// 触摸事件 - 双指缩放 + 单指平移
onTouchStart(e) {
if (e.touches.length === 2) {
this.lastDistance = this.getDistance(e.touches[0], e.touches[1])
this.lastScale = this.scale
const mid = this.getMidPoint(e.touches[0], e.touches[1])
this.lastMidX = mid.x
this.lastMidY = mid.y
this.lastTranslateX = this.translateX
this.lastTranslateY = this.translateY
} else if (e.touches.length === 1) {
this.lastMidX = e.touches[0].clientX
this.lastMidY = e.touches[0].clientY
this.lastTranslateX = this.translateX
this.lastTranslateY = this.translateY
}
},
onTouchMove(e) {
e.preventDefault && e.preventDefault()
if (e.touches.length === 2) {
// 双指缩放
const distance = this.getDistance(e.touches[0], e.touches[1])
let newScale = this.lastScale * (distance / this.lastDistance)
newScale = Math.min(Math.max(newScale, this.minScale), this.maxScale)
this.scale = newScale
// 双指平移
const mid = this.getMidPoint(e.touches[0], e.touches[1])
this.translateX = this.lastTranslateX + (mid.x - this.lastMidX)
this.translateY = this.lastTranslateY + (mid.y - this.lastMidY)
} else if (e.touches.length === 1) {
// 单指平移(仅放大时允许)
const dx = e.touches[0].clientX - this.lastMidX
const dy = e.touches[0].clientY - this.lastMidY
this.translateX = this.lastTranslateX + dx
this.translateY = this.lastTranslateY + dy
}
this.clampTranslate()
},
onTouchEnd(e) {
// 缩放回原点时重置平移
if (this.scale < this.minScale) {
this.scale = this.minScale
}
},
getDistance(t1, t2) {
const dx = t1.clientX - t2.clientX
const dy = t1.clientY - t2.clientY
return Math.sqrt(dx * dx + dy * dy)
},
getMidPoint(t1, t2) {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
}
},
onConfirm() {
if (this.selectedSeats.length === 0) {
uni.showToast({
title: '请先选择座位',
icon: 'none'
})
return
}
const seatNames = this.selectedSeats.map(s => `${s.row + 1}排${s.col + 1}座`).join('、')
uni.showModal({
title: '确认订单',
content: `已选:${seatNames}\n合计:¥${this.totalPrice}`,
confirmText: '去支付',
success: (res) => {
if (res.confirm) {
// 跳转支付页,传递选座信息
uni.navigateTo({
url: `/pages/pay/pay?seats=${encodeURIComponent(JSON.stringify(this.selectedSeats))}&price=${this.totalPrice}`,
})
}
},
})
},
//限制拖动边界
clampTranslate() {
const maxX = 300
const maxY = 500
this.translateX = Math.max(
-maxX,
Math.min(maxX, this.translateX)
)
this.translateY = Math.max(
-maxY,
Math.min(maxY, this.translateY)
)
}
},
}
</script>
<style lang="scss" scoped>
page {
background-color: #0d0d1a;
}
.page {
min-height: 100vh;
background-color: #0d0d1a;
display: flex;
flex-direction: column;
padding-bottom: 180rpx;
}
/* ── 顶部 ── */
.header {
padding: 24rpx 32rpx 16rpx;
background: linear-gradient(180deg, #1a1a2e 0%, #0d0d1a 100%);
border-bottom: 1rpx solid #2a2a4a;
}
.movie-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #f0e6ff;
letter-spacing: 2rpx;
}
.movie-meta {
display: block;
font-size: 24rpx;
color: #8888bb;
margin-top: 8rpx;
}
/* ── 银幕 ── */
.screen-wrap {
padding: 32rpx 40rpx 8rpx;
display: flex;
justify-content: center;
}
.screen-bar {
width: 80%;
height: 16rpx;
background: linear-gradient(90deg, transparent, #a78bfa, #7c3aed, #a78bfa, transparent);
border-radius: 50%;
box-shadow: 0 4rpx 32rpx rgba(167, 139, 250, 0.5);
display: flex;
align-items: flex-end;
justify-content: center;
}
.screen-text {
font-size: 22rpx;
color: #a78bfa;
margin-top: 12rpx;
display: block;
text-align: center;
}
/* ── 座位区 ── */
.seat-container {
flex: 1;
overflow: hidden;
position: relative;
// height: calc(100vh - 120rpx -
// /* header */
// 80rpx -
// /* screen */
// 80rpx -
// /* legend */
// 60rpx -
// /* zoom */
// 180rpx
// /* bottom */
// );
touch-action: none;
}
.seat-map {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
will-change: transform;
}
.seat-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.row-label {
width: 36rpx;
font-size: 20rpx;
color: #555588;
text-align: center;
flex-shrink: 0;
}
.seat-wrap {
display: flex;
align-items: center;
}
.aisle {
width: 24rpx;
}
.seat {
width: 52rpx;
height: 52rpx;
border-radius: 10rpx 10rpx 4rpx 4rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: transform 0.1s;
&:active {
transform: scale(0.9);
}
}
.seat-num {
font-size: 18rpx;
color: rgba(255, 255, 255, 0.6);
}
.seat--available {
background: linear-gradient(160deg, #2d2d5e, #1e1e3f);
border: 1rpx solid #4444aa;
}
.seat--selected {
background: linear-gradient(160deg, #7c3aed, #a78bfa);
border: 1rpx solid #c4b5fd;
box-shadow: 0 0 12rpx rgba(167, 139, 250, 0.6);
.seat-num {
color: #fff;
font-weight: 700;
}
}
.seat--sold {
background: #1a1a2e;
border: 1rpx solid #2a2a3a;
opacity: 0.4;
}
.seat--couple {
background: linear-gradient(160deg, #7f1d5e, #ec4899);
border: 1rpx solid #f472b6;
width: 60rpx;
/* 情侣座稍宽 */
.seat-num {
color: #fff;
}
}
.seat-icon {
width: 32rpx;
height: 32rpx;
}
/* ── 图例 ── */
.legend {
display: flex;
justify-content: center;
gap: 32rpx;
padding: 20rpx 0 8rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend-dot {
width: 24rpx;
height: 24rpx;
border-radius: 6rpx;
}
.legend-dot.available {
background: linear-gradient(160deg, #2d2d5e, #1e1e3f);
border: 1rpx solid #4444aa;
}
.legend-dot.selected {
background: linear-gradient(160deg, #7c3aed, #a78bfa);
}
.legend-dot.sold {
background: #1a1a2e;
border: 1rpx solid #2a2a3a;
opacity: 0.4;
}
.legend-dot.couple {
background: linear-gradient(160deg, #7f1d5e, #ec4899);
}
.legend-text {
font-size: 22rpx;
color: #8888bb;
}
/* ── 缩放提示 ── */
.zoom-tip {
text-align: center;
padding: 8rpx 0 16rpx;
}
.zoom-tip-text {
font-size: 22rpx;
color: #555588;
}
/* ── 底部结算 ── */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: #12122a;
border-top: 1rpx solid #2a2a4a;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 16rpx;
box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, .4);
}
.selected-info {
flex: 1;
overflow: hidden;
}
.no-select-text {
font-size: 26rpx;
color: #555588;
}
.selected-seats {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.seat-tag {
background: rgba(124, 58, 237, 0.25);
border: 1rpx solid #7c3aed;
border-radius: 8rpx;
padding: 4rpx 12rpx;
}
.seat-tag-text {
font-size: 22rpx;
color: #c4b5fd;
}
.price-block {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.price-label {
font-size: 22rpx;
color: #8888bb;
}
.price-value {
font-size: 36rpx;
font-weight: 700;
color: #a78bfa;
}
.confirm-btn {
background: #2d2d5e;
border-radius: 48rpx;
padding: 20rpx 36rpx;
flex-shrink: 0;
&.active {
background: linear-gradient(135deg, #7c3aed, #a855f7);
box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.5);
}
}
.confirm-text {
font-size: 28rpx;
font-weight: 700;
color: #fff;
}
</style>