电影院选座功能操作介绍-兼容移动端和小程序端

项目合作开发

一、页面基础信息展示

  1. 影片与场次信息 页面顶部清晰展示当前观影影片《蜘蛛侠:纵横宇宙》,同步标注放映时间「今天 19:30」、放映厅规格「IMAX 激光」、厅号「3 号厅」,让用户第一时间确认观影场次无误。

  2. 座位状态图例说明 页面上方设置 4 类座位状态标识,直观区分不同座位属性:

  • 深蓝色方块:可选座位,未被购买,可直接点击选中;

  • 浅紫色方块:已选座位,用户点击后标记,计入订单;

  • 纯黑色方块:已售座位,其他用户已购买,无法选择;

  • 粉红色方块:情侣座,双人连体专属座位,适合结伴情侣选购。

  1. 银幕方位指引 座位区域最上方标注「银幕」字样,明确观影朝向,方便用户判断座位前后、左右观影视角。

二、座位布局规则

  1. 排号划分 纵向左侧数字 1-20 代表排数,1 排为最靠近银幕的前排,数字越大越靠后;

  2. 座位编号 横向数字为单排内座位号,中间存在过道分隔,左右两大区域,10 排设有专属情侣连体座位区;

  3. 特殊禁选座位 黑色遮挡座位为已售出 / 设备占位,全程无法点击选择,避免用户误选。

三、核心选座操作流程

  1. 挑选座位 点击任意深蓝色可选座位,座位会变为浅紫色「已选」状态;10 排粉色情侣座可直接点击选购双人位。

  2. 选座数量限制 页面左下角提示规则:单次最多选择 4 个座位,超出数量无法新增选中,适配单人、双人、三四人结伴观影需求。

  3. 订单金额实时计算 右下角「合计」区域会根据选中座位数量自动计算总票价,未选座时显示 ¥0。

  4. 确认下单 选好满意座位后,点击右下角深色「确认选座」按钮,即可进入购票支付环节,锁定选中座位。

四、功能实用优势

  1. 可视化选座:完整还原影厅真实座位排布,直观看清空位、已售、情侣座分布,不用到现场就能挑选心仪视角;

  2. 状态区分清晰:四种颜色标识无认知门槛,快速分辨能否选购;

  3. 限制防误操作:最高 4 座选购限制,避免单次多选造成票务资源浪费;

  4. 信息一体化:影片、时间、影厅、座位、总价同页展示,操作链路简短,购票流程简单易懂。

五、使用小提示

  1. 前排(1-6 排)距离银幕近,视觉冲击强但易视觉疲劳;中后段 8-15 排为观影黄金区域,画面比例观感最佳;

  2. 情侣优先选择 10 排粉色连体情侣座,私密性更好;

  3. 若心仪座位显示黑色,代表已被他人购买,可选择同排相邻空位或更换前后排。

  4. 当前版本情侣座功能暂无自动连坐匹配机制,仅作页面展示效果,开发者可根据实际业务需求,对该情侣座展示模块进行删除、替换或功能优化升级。

六、功能适用场景

  1. 个人独自观影场景:用户单人观影时,可通过该功能快速挑选单人最优座位,优先选择影厅中间黄金席位,操作简单、选座高效,无需人工协助,自助完成购票选座,适配日常休闲、独自看片、周末放松等个人观影需求。

  2. 情侣双人观影场景:情侣约会观影时,可精准选择专属粉色情侣连体座位,座位紧邻、私密性更强,贴合双人约会的观影需求,同时可直观避开人流密集区域,提升观影体验。

  3. 亲友结伴观影场景:朋友、家人、亲子等多人结伴观影(2-4人)时,依托单次最多选4座的功能规则,可一键挑选相邻连座,避免座位分散,保障结伴观影的互动性,适配家庭观影、朋友聚会、同学团建等场景。

  4. 观影选位对比参考场景:用户提前规划观影场次时,可通过可视化座位布局,实时查看座位售卖情况,对比不同排数、不同区域的观影视角,根据自身需求避开过近、过偏的座位,提前锁定优质席位,避免到店无好座的情况。

  5. 日常快速购票场景:日常刚需观影、临时追剧、院线新片打卡等场景下,依托页面一体化信息展示,快速确认影片、场次、影厅信息,一键选座下单,简化购票流程,节省排队、选座时间,适配快节奏的观影需求。

  6. 观影避坑选座场景:针对热门影片、黄金场次座位紧张的情况,用户可通过座位颜色标识,快速甄别已售、可售座位,及时更换备选座位,高效完成购票,避免心仪座位售罄导致观影计划受阻。

  7. 演示图片如下

复制代码
<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>