一、前言:为什么你的购票页面总像临时工搭的?
每次打开购票APP,你是不是常遇到这种尴尬:点选座位时突然卡顿,好不容易选好座却被别人抢先支付,或者更绝的是——页面直接显示一团乱码?这些车祸现场,多半是组件设计没做好。
今天,咱们就用Vue.js这个“魔法工具箱”,亲手打造一个丝滑的电影购票页面。别担心,就算你刚学Vue,只要会写“Hello World”,就能跟着我变身影院“数字编导”,指挥座位、场次、票价乖乖听话!
二、组件设计思维:把电影院“搬进”代码里
1. 像导演一样分镜头
设计组件前,先闭上眼睛想象用户购票流程:选电影 → 看场次 → 挑座位 → 付钱。对应到代码里,我们需要这三个核心组件:
- MoviePoster(海报组件):负责颜值担当,展示电影海报和基本信息
- ScheduleList(场次组件):像节目单一样列出所有可选的场次
- SeatMap(座位图组件):重头戏!让用户像皇帝选妃一样点选座位
2. 数据流就是“传纸条”
Vue组件之间怎么通信?想象学生时代上课传纸条:
- 爸爸组件(父组件)通过props把数据写在纸条上传给儿子
- 儿子想反馈就通过$emit发射事件,好比举手说“老师,我要上厕所”
三、实战开始:搭建我们的“数字影院”
第一步:先搞定电影海报组件
这个组件最简单,就是个颜值担当。我们来创建一个MoviePoster.vue:
<template>
<div class="movie-poster">
<img :src="movieInfo.posterUrl" :alt="movieInfo.title" class="poster-img">
<div class="movie-info">
<h2 class="title">{{ movieInfo.title }}</h2>
<p class="rating">评分:<span class="score">{{ movieInfo.rating }}</span></p>
<p class="duration">{{ movieInfo.duration }}分钟</p>
<p class="genre">{{ movieInfo.genre.join(' / ') }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
movieInfo: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
.movie-poster {
display: flex;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
margin-bottom: 20px;
}
.poster-img {
width: 120px;
height: 160px;
border-radius: 8px;
margin-right: 20px;
}
.movie-info {
flex: 1;
}
.title {
color: #1a1a1a;
margin-bottom: 8px;
font-size: 1.4em;
}
.score {
color: #ff6b35;
font-weight: bold;
}
</style>
用法超简单,在父组件里这样调用:
<MoviePoster :movie-info="currentMovie" />
第二步:场次列表组件——电影院的“节目单”
场次组件要聪明一点,需要处理用户点击,还要显示价格和时间:
<template>
<div class="schedule-list">
<h3>选择场次</h3>
<div
v-for="schedule in schedules"
:key="schedule.id"
class="schedule-item"
:class="{ active: selectedScheduleId === schedule.id }"
@click="selectSchedule(schedule)"
>
<div class="time">{{ formatTime(schedule.startTime) }}</div>
<div class="hall">{{ schedule.hall }}厅</div>
<div class="price">¥{{ schedule.price }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
schedules: {
type: Array,
default: () => []
}
},
data() {
return {
selectedScheduleId: null
}
},
methods: {
selectSchedule(schedule) {
this.selectedScheduleId = schedule.id
this.$emit('schedule-selected', schedule)
},
formatTime(timeString) {
return new Date(timeString).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
}
}
</script>
<style scoped>
.schedule-list {
margin-bottom: 30px;
}
.schedule-item {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s;
}
.schedule-item:hover {
border-color: #ff6b35;
}
.schedule-item.active {
border-color: #ff6b35;
background: #fff5f2;
}
.time {
font-weight: bold;
color: #1a1a1a;
}
.price {
color: #ff6b35;
font-weight: bold;
}
</style>
第三步:重头戏——座位图组件
这是最复杂的部分,我们要模拟真实的影院选座:
<template>
<div class="seat-map">
<h3>选择座位</h3>
<div class="screen">银幕</div>
<div class="seats-container">
<div
v-for="row in seatMap"
:key="row.row"
class="seat-row"
>
<div class="row-label">{{ row.row }}排</div>
<div
v-for="seat in row.seats"
:key="seat.number"
class="seat"
:class="getSeatClass(seat)"
@click="toggleSeat(seat)"
>
{{ seat.number }}
</div>
</div>
</div>
<div class="selected-summary">
<p>已选座位:{{ selectedSeatsText }}</p>
<p class="total-price">总价:<span>¥{{ totalPrice }}</span></p>
</div>
</div>
</template>
<script>
export default {
props: {
seatMap: {
type: Array,
required: true
},
ticketPrice: {
type: Number,
required: true
}
},
data() {
return {
selectedSeats: []
}
},
computed: {
selectedSeatsText() {
if (this.selectedSeats.length === 0) return '暂无选座'
return this.selectedSeats.map(seat => `${seat.row}排${seat.number}座`).join('、')
},
totalPrice() {
return this.selectedSeats.length * this.ticketPrice
}
},
methods: {
getSeatClass(seat) {
const classes = []
if (seat.type === 'vip') classes.push('vip')
if (seat.status === 'occupied') classes.push('occupied')
if (this.isSelected(seat)) classes.push('selected')
return classes
},
isSelected(seat) {
return this.selectedSeats.some(s =>
s.row === seat.row && s.number === seat.number
)
},
toggleSeat(seat) {
if (seat.status === 'occupied') {
alert('这个座位已经被占了,别跟人抢啦!')
return
}
const seatIndex = this.selectedSeats.findIndex(s =>
s.row === seat.row && s.number === seat.number
)
if (seatIndex > -1) {
this.selectedSeats.splice(seatIndex, 1)
} else {
if (this.selectedSeats.length >= 6) {
alert('一次最多选6个座位,你是要包场吗?')
return
}
this.selectedSeats.push({ ...seat })
}
this.$emit('seats-changed', this.selectedSeats)
}
}
}
</script>
<style scoped>
.seat-map {
margin-bottom: 30px;
}
.screen {
text-align: center;
padding: 10px;
background: linear-gradient(180deg, #e0e0e0, #f5f5f5);
margin: 20px 0;
border-radius: 4px;
font-weight: bold;
}
.seats-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.seat-row {
display: flex;
align-items: center;
gap: 6px;
}
.row-label {
width: 40px;
text-align: right;
font-size: 12px;
color: #666;
}
.seat {
width: 30px;
height: 30px;
border: 1px solid #ccc;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
}
.seat:hover:not(.occupied) {
border-color: #ff6b35;
background: #fff5f2;
}
.seat.selected {
background: #ff6b35;
color: white;
border-color: #ff6b35;
}
.seat.occupied {
background: #e0e0e0;
color: #999;
cursor: not-allowed;
}
.seat.vip {
border-color: #ffd700;
}
.total-price span {
color: #ff6b35;
font-weight: bold;
font-size: 1.2em;
}
</style>
四、组装完整页面:让组件们团队协作
现在我们把三个组件组装起来,创建一个完整的购票页面:
<template>
<div class="movie-booking-page">
<h1>在线选座购票</h1>
<MoviePoster :movie-info="currentMovie" />
<ScheduleList
:schedules="schedules"
@schedule-selected="handleScheduleSelected"
/>
<SeatMap
v-if="selectedSchedule"
:seat-map="seatMap"
:ticket-price="selectedSchedule.price"
@seats-changed="handleSeatsChanged"
/>
<div class="action-bar">
<button
class="book-btn"
:disabled="!canBook"
@click="handleBook"
>
{{ bookButtonText }}
</button>
</div>
</div>
</template>
<script>
import MoviePoster from './components/MoviePoster.vue'
import ScheduleList from './components/ScheduleList.vue'
import SeatMap from './components/SeatMap.vue'
export default {
components: {
MoviePoster,
ScheduleList,
SeatMap
},
data() {
return {
currentMovie: {
title: '流浪地球3:星际穿越',
posterUrl: '/images/liulangdiqiu3.jpg',
rating: 9.2,
duration: 148,
genre: ['科幻', '冒险', '灾难']
},
schedules: [
{
id: 1,
startTime: '2024-03-20T14:30:00',
hall: 'IMAX巨幕',
price: 45
},
{
id: 2,
startTime: '2024-03-20T17:15:00',
hall: '4K激光',
price: 38
}
],
selectedSchedule: null,
selectedSeats: [],
seatMap: [
{
row: 'A',
seats: [
{ number: 1, status: 'available', type: 'normal' },
{ number: 2, status: 'available', type: 'normal' },
{ number: 3, status: 'occupied', type: 'normal' }
]
},
{
row: 'B',
seats: [
{ number: 1, status: 'available', type: 'vip' },
{ number: 2, status: 'available', type: 'vip' },
{ number: 3, status: 'available', type: 'vip' }
]
}
]
}
},
computed: {
canBook() {
return this.selectedSchedule && this.selectedSeats.length > 0
},
bookButtonText() {
if (!this.selectedSchedule) return '请选择场次'
if (this.selectedSeats.length === 0) return '请选择座位'
return `立即支付 ¥${this.selectedSeats.length * this.selectedSchedule.price}`
}
},
methods: {
handleScheduleSelected(schedule) {
this.selectedSchedule = schedule
this.selectedSeats = [] // 切换场次清空已选座位
},
handleSeatsChanged(seats) {
this.selectedSeats = seats
},
handleBook() {
const bookingInfo = {
movie: this.currentMovie.title,
schedule: this.selectedSchedule,
seats: this.selectedSeats,
totalPrice: this.selectedSeats.length * this.selectedSchedule.price
}
alert(`下单成功!\n电影:${bookingInfo.movie}\n座位:${this.selectedSeats.map(s => `${s.row}排${s.number}座`).join('、')}\n总价:¥${bookingInfo.totalPrice}`)
}
}
}
</script>
<style scoped>
.movie-booking-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.action-bar {
position: sticky;
bottom: 0;
background: white;
padding: 16px 0;
border-top: 1px solid #e0e0e0;
text-align: center;
}
.book-btn {
background: #ff6b35;
color: white;
border: none;
padding: 12px 40px;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
.book-btn:hover:not(:disabled) {
background: #ff8555;
transform: translateY(-2px);
}
.book-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
五、进阶技巧:让你的组件更智能
1. 加入加载状态
真实场景中数据需要从API获取,可以添加loading状态:
<template>
<div class="loading" v-if="loading">
<div class="spinner"></div>
<p>正在努力加载场次信息...</p>
</div>
</template>
2. 错误处理
网络请求可能会失败,需要优雅的错误处理:
async fetchSchedules() {
this.loading = true
try {
const response = await fetch('/api/schedules')
this.schedules = await response.json()
} catch (error) {
this.$emit('error', '场次加载失败,请稍后重试')
} finally {
this.loading = false
}
}
六、总结:你已经是合格的“影院编导”了!
通过这个实战项目,我们学会了:
- 🎯 用组件化思维拆解复杂页面
- 🔄 掌握父子组件通信(props down, events up)
- 💰 用计算属性自动衍生数据
- 🎨 用动态class实现交互反馈
- 🚀 构建完整的用户操作流程
最重要的是,你现在不仅会写代码,更懂得如何设计用户体验。下次去电影院,你可以自豪地说:“这个选座系统,我也能写!”

被折叠的 条评论
为什么被折叠?



