Vue基础教程(213)电影购票APP开发实战之设计项目组件中的设计电影页面组件:Vue.js电影购票组件设计实录:从代码小白到影院“编导”的奇幻之旅

一、前言:为什么你的购票页面总像临时工搭的?

每次打开购票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实现交互反馈
  • 🚀 构建完整的用户操作流程

最重要的是,你现在不仅会写代码,更懂得如何设计用户体验。下次去电影院,你可以自豪地说:“这个选座系统,我也能写!”

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

值引力

持续创作,多谢支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值