| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- <script setup lang="ts">
- // 1. 导入 Vue3 核心 API 和 uni-app 生命周期
- import { computed, nextTick, ref } from 'vue'
- import { onLoad } from '@dcloudio/uni-app'
- // 2. 导入原组件和模拟数据
- import { getArrayFieldMax, isWithin45Minutes } from '../utils'
- import canvasSeatmap from './components/pages/CanvasSeatmap.vue'
- import { StaticUrl } from '@/config'
- import router from '@/router'
- definePage({
- name: 'film-choose-seat',
- islogin: true,
- style: {
- navigationBarTitleText: '座次选择',
- backgroundColorBottom: '#fff',
- },
- })
- // ************************ 核心:定义所有缺失的接口(解决类型报错的关键) ************************
- // /**
- // * 影院信息接口
- // */
- // interface CinemaInfo {
- // cinema_name: string
- // [key: string]: any // 兼容其他未知属性,避免后续扩展报错
- // }
- /**
- * 区域列表项接口
- */
- interface AreaItem {
- area: string | number
- areaId: string | number
- market_price: number | string
- strokeStyle: string
- [key: string]: any // 兼容组件所需的其他未知属性
- }
- // /**
- // * 场次信息接口
- // */
- // interface ScheduleInfo {
- // schedule_id: string | number
- // hall_name: string
- // cinema: CinemaInfo | null
- // schedule_area: AreaItem[]
- // [key: string]: any // 兼容其他未知属性
- // }
- /**
- * 座位信息接口
- */
- interface SeatInfo {
- seatName: string
- marketPrice: number | string
- [key: string]: any // 兼容组件所需的其他未知属性
- }
- /**
- * 座位图配置项接口
- */
- interface SeatmapOptions {
- title: string
- hallName: string
- areaList: AreaItem[]
- footerHeight: number
- maxSelectNum?: number // 可选属性,对应注释的配置
- isolateSeats?: boolean // 可选属性,对应注释的配置
- [key: string]: any // 兼容组件所需的其他未知配置
- }
- /**
- * 座位点击回调事件接口
- */
- interface SeatTapEvent {
- chooseSeatList: SeatInfo[]
- }
- /**
- * 座位图组件实例接口(定义组件的公开方法,解决 ref 调用方法的类型报错)
- */
- interface CanvasSeatmapInstance {
- init: (params: { seatList: SeatInfo[], options: SeatmapOptions }) => void
- validateIsolates: () => boolean
- cancelSeat: (seat: SeatInfo) => void
- }
- const isLoging = ref(false)
- const loading = ref<boolean>(false)
- const seatInfo = ref<Api.filmMovieSeat>({})
- const areaList = ref<AreaItem[]>([])
- const seatList = ref<SeatInfo[]>([])
- const chooseSeatList = ref<SeatInfo[]>([])
- const options = ref<SeatmapOptions>({
- title: '',
- hallName: '',
- areaList: [],
- footerHeight: 0,
- })
- const areaArr = ref(['贵宾区', '普通区', '特价区1', '特价区2', '特价区3', '特价区4', '特价区5', '特价区6'])
- const { userInfo } = storeToRefs(useUserStore())
- const phone = ref('')
- const query = ref({
- memberId: unref(userInfo).id,
- channelId: unref(userInfo).channelId,
- shopId: '1',
- channelName: unref(userInfo).channelName,
- cinemaName: '',
- cinemaCode: '',
- movieCode: '',
- hallName: '',
- orderPayMode: '1',
- originPrice: 0,
- seatNames: '',
- sessionBeginTime: '',
- switchSeat: false,
- movieName: '',
- postImageUrl: '',
- planType: '',
- })
- const active = ref('')
- const showTime = ref(false)
- const screenList = ref()
- // ************************ 非响应式常量(类型自动推断,无需额外注解) ************************
- const colorList = [
- '#449DFE', // 浅黄色
- '#FFB639', // 浅蓝色
- '#FF4D3A', // 干枯玫瑰
- '#b6e67f', // 浅绿色
- '#ffb0bc', // 浅粉色
- ] as const // as const 锁定数组元素,禁止修改,类型更严格
- const totalPrice = computed(() => {
- let total = 0
- const scale = 10000
- if (!seatInfo.value.showTime)
- return 0
- const key = isWithin45Minutes(new Date(), seatInfo.value.showTime) ? 'fastPrice' : 'ticketPrice'
- const price = getArrayFieldMax(chooseSeatList.value, key) || 0
- total = (price * scale) * chooseSeatList.value.length / scale
- return total
- })
- // ************************ 组件 ref(添加明确实例类型,解决调用方法的类型报错) ************************
- const seatmapRef = ref<CanvasSeatmapInstance | null>(null)
- // ************************ 所有方法(添加参数/返回值类型注解,消除隐式 any) ************************
- // /**
- // * 金额转换(分转元,保留 2 位小数)
- // * @param total 分单位金额
- // * @returns 元单位金额(字符串,保留 2 位小数)
- // */
- // function priceConversion(total: number | string): string {
- // const numTotal = Number(total)
- // // 处理 NaN 情况,避免返回 NaN.toFixed(2) 报错
- // if (Number.isNaN(numTotal))
- // return '0.00'
- // return (numTotal / 100).toFixed(2)
- // }
- async function getScreenList(cinemaId: string, movieId: string) {
- const res = await Apis.film.getFilmMovieList({ data: { cinemaId, movieId } })
- res.data.data.movieShows?.forEach((i: any) => {
- if (i.shows.length) {
- i.shows.forEach((it: any, idx: number) => {
- if (active.value.includes(it.showDate)) {
- screenList.value = i.shows[idx].sessions
- }
- })
- }
- })
- console.log(2222222, screenList.value)
- }
- /**
- * 获取座位详情
- */
- async function getData(sessionId: string, movieId: string, cinemaId: string): Promise<void> {
- uni.showLoading({ title: '加载中...' })
- const res = await Apis.film.getFilmMovieSeat({ data: { sessionId, movieId, cinemaId } })
- uni.hideLoading()
- getScreenList(res.data.cinemaId, res.data.movieId)
- active.value = res.data.showTime
- seatInfo.value = res.data
- // seatInfo.value.seatSection = []
- query.value.hallName = res.data.hall
- query.value.cinemaCode = res.data.cinemaCode
- query.value.movieCode = res.data.movieCode
- query.value.sessionBeginTime = res.data.showTime
- query.value.cinemaName = res.data.cinemaName
- query.value.movieName = res.data.movieName
- query.value.postImageUrl = res.data.postImageUrl
- areaList.value = res.data.areaPriceList
- areaList.value.sort((a, b) => Number(b.originPrice) - Number(a.originPrice))
- areaList.value.forEach((item: AreaItem, index: number) => {
- item.strokeStyle = colorList[index % colorList.length] || '#ccc'
- item.areaName = areaArr.value[index]
- })
- for (let index = 0; index < res.data.seatSection.seatRows.length; index++) {
- const item = res.data.seatSection.seatRows[index]
- for (let i = 0; i < item.columns.length; i++) {
- const seat = item.columns[i]
- let price = 0
- let marketPrice = 0
- let fastPrice = 0
- for (let d = 0; d < res.data.areaPriceList.length; d++) {
- const el = res.data.areaPriceList[d]
- if (el.areaId === seat.areaId) {
- // 价格
- price = el.price
- marketPrice = el.originPrice
- fastPrice = el.fastPrice
- }
- }
- // 桌椅状态
- let status = 0
- if (seat.state === 0 || seat.state === 4) {
- status = -2
- }
- else if (seat.state === 1) {
- status = 1
- }
- else if (seat.state === 2) {
- status = 0
- }
- else if (seat.state === 3) {
- status = 10
- }
- else if (seat.state === 10) {
- status = 1
- }
- else if (seat.state === 11) {
- status = 1
- }
- // 座位类型
- let seatType = 0
- if (seat.seatType === 2) {
- seatType = 1
- }
- else if (seat.seatType === 3) {
- seatType = 3
- }
- seatList.value.push({
- status,
- seatType,
- seatName: seat.seatName,
- seatId: seat.originSeatID,
- columnId: seat.colNum,
- rowId: item.rowsNum,
- // columnId: i,
- // rowId: index,
- areaId: seat.areaId,
- marketPrice,
- ticketPrice: price,
- fastPrice,
- serviceFee: 0,
- })
- }
- }
- console.log(2222, areaList.value)
- // 赋值座位图配置项(严格符合 SeatmapOptions 接口)
- options.value = {
- title: '',
- hallName: seatInfo.value.hall as string,
- areaList: areaList.value,
- footerHeight: 470,
- // maxSelectNum: 4, // 可选属性,开启后无类型报错
- // isolateSeats: true, // 可选属性,开启后无类型报错
- }
- // 可选链访问,避免 cinema 为 null 时报错
- // const cinemaName = schedule.value.cinema?.cinema_name || '默认影院'
- // uni.setNavigationBarTitle({ title: cinemaName })
- }
- /**
- * 选定并购买座位
- */
- async function buySeat() {
- if (loading.value)
- return false
- if (chooseSeatList.value.length === 0) {
- return uni.showToast({
- title: '请选择座位',
- icon: 'none',
- duration: 2000,
- })
- }
- // 可选链调用,避免组件实例为 null 时报错
- if (seatmapRef.value?.validateIsolates()) {
- uni.showToast({
- title: '左右座位请不要留空!',
- icon: 'none',
- duration: 2000,
- })
- return false
- }
- query.value.seatNames = chooseSeatList.value.map(item => item.seatName).join(',')
- query.value.planType = seatInfo.value?.language as string + seatInfo.value?.planType
- uni.showToast({
- title: '开始锁座',
- icon: 'none',
- duration: 2000,
- })
- uni.setStorageSync('film-info', { showTime: active.value, endTime: seatInfo.value.endTime, cinemaName: seatInfo.value.cinemaName, chooseSeatList: chooseSeatList.value, language: seatInfo.value.language, planType: seatInfo.value.planType, totalPrice: totalPrice.value, phone: phone.value })
- router.push({ name: 'film-submit-order', params: { query: JSON.stringify(query.value) } })
- console.log(query.value)
- }
- /**
- * 取消选定座位
- * @param seat 要取消的座位信息
- */
- function cancelSeat(seat: SeatInfo): void {
- seatmapRef.value?.cancelSeat(seat)
- }
- /**
- * 座位图错误回调
- * @param e 错误事件对象
- */
- function onError(e: { message: string }): void {
- uni.showToast({
- title: e.message || '座位图加载失败',
- icon: 'none',
- duration: 2000,
- })
- }
- /**
- * 座位点击回调
- * @param event 座位点击事件对象
- */
- function onSeatTap(event: SeatTapEvent): void {
- chooseSeatList.value = event.chooseSeatList
- console.log('chooseSeatList', event)
- console.log('zuoshsjd', chooseSeatList.value)
- // 遍历已选座位列表,累加价格(严格类型访问)
- let price = 0
- for (const item of chooseSeatList.value) {
- if (item.marketPrice as number > price) {
- price = item.marketPrice as number
- }
- }
- query.value.originPrice = price
- }
- // ************************ 页面生命周期(严格类型,无报错) ************************
- onLoad(async (opt): Promise<void> => {
- phone.value = opt?.phone
- await getData(opt?.sessionId, opt?.movieId, opt?.cinemaId)
- // 等待 DOM 渲染完成后初始化座位图
- await nextTick()
- // 可选链 + 类型守卫,确保调用安全无报错
- if (seatmapRef.value) {
- seatmapRef.value.init({
- seatList: seatList.value,
- options: options.value,
- })
- }
- })
- function handleTime(time: string, sessionId: string) {
- if (time === active.value) {
- return
- }
- active.value = time
- router.push({ name: 'film-choose-seat', params: { sessionId, movieId: seatInfo.value.movieId as string, cinemaId: seatInfo.value.cinemaId } })
- }
- function handleSwitch() {
- console.log(11111)
- showTime.value = !showTime.value
- }
- // 获取时间
- function judgeNearDaysSimple(time: any) {
- const today = new Date(new Date().setHours(0, 0, 0, 0)).getTime()
- const target = new Date(new Date(time).setHours(0, 0, 0, 0)).getTime()
- const dayDiff = Math.floor((target - today) / 86400000)
- return { 0: '今天', 1: '明天', 2: '后天' }[dayDiff] || ''
- }
- </script>
- <template>
- <view class="view-page">
- <!-- 顶部价格区域 -->
- <view v-if="seatInfo.cinemaId" class="areaList">
- <view class="area-item">
- <view class="area" style="background-color:#AAAAAA" />
- <view class="area-price">
- 已售
- </view>
- </view>
- <view v-for="(item, index) in areaList" :key="index" class="area-item">
- <view class="area" :style="`border:1px solid ${item.strokeStyle}`" />
- <view class="area-price">
- {{ `${item.areaName} ${isWithin45Minutes(new Date(), seatInfo.showTime) ? item.fastPrice : item.price}` }}
- </view>
- </view>
- </view>
- <canvasSeatmap ref="seatmapRef" @error="onError" @on-seat-tap="onSeatTap" />
- <!-- 底部信息 -->
- <view v-if="seatInfo.cinemaId" class="footer">
- <view class="schedule bg-white">
- <view class="name-box">
- <view class="film-name">
- {{ seatInfo.movieName }}
- </view>
- <view class="change-box" @click="handleSwitch">
- <view>切换场次</view>
- <image class="arrow" :src="`${StaticUrl}/film-black-arrow.png`" mode="scaleToFill" />
- </view>
- </view>
- <view class="film-info">
- <text style="color:red">
- {{ judgeNearDaysSimple(seatInfo.showTime) || seatInfo.showTime?.split('T')[0] }}
- </text>
- {{ seatInfo.showTime?.split('T')[1] }} -
- {{ seatInfo.endTime?.split('T')[1] }}
- {{ seatInfo.planType }} {{ seatInfo.language }}
- </view>
- <!-- 切换场次 -->
- <view v-if="showTime" class="time-list">
- <view
- v-for="(item, index) in screenList" :key="index" class="time-item"
- :class="[active == item.showTime ? 'active' : '']" @click="handleTime(item.showTime, item.sessionId)"
- >
- <view class="time">
- {{ item.showTime.split('T')[1] }}
- </view>
- <view class="type">
- {{ item.language }} {{ item.planType }}
- </view>
- <view class="price">
- ¥{{ item.sellPrice }}起
- </view>
- </view>
- </view>
- <view v-if="chooseSeatList.length" class="seat-list">
- <view v-for="(seat, optindex) in chooseSeatList" :key="optindex" class="seat-item">
- <view class="seat">
- <view class="seat-name">
- {{ seat.seatName }}
- </view>
- <view class="seat-price">
- <!-- ¥{{ priceConversion(seat.marketPrice) }} -->
- ¥{{ isWithin45Minutes(new Date(), seatInfo.showTime) ? seat.fastPrice : seat.ticketPrice }}
- </view>
- </view>
- <view class="delete-seat" @click="cancelSeat(seat)">
- ×
- </view>
- </view>
- </view>
- </view>
- <view class="notice bg-white">
- 请尽快选座,座位可能被抢!
- </view>
- <wd-button
- custom-class="confirm" block :loading="isLoging" :class="{ unbtn: !chooseSeatList.length || loading }"
- @click="buySeat"
- >
- {{ chooseSeatList.length ? (`¥${totalPrice} 确认座位`) : '请选座位' }}
- </wd-button>
- </view>
- </view>
- </template>
- <style lang="scss" scoped>
- .view-page {
- background-color: #f7f9fc;
- }
- .bg-white {
- background-color: #fff;
- }
- /* 核心样式 */
- .areaList {
- display: flex;
- align-items: center;
- /* 上下居中 */
- /* 左右居中(可选) */
- height: 40px;
- white-space: nowrap;
- overflow-x: auto;
- scrollbar-width: none;
- /* Firefox */
- -ms-overflow-style: none;
- /* IE/Edge */
- }
- .areaList::-webkit-scrollbar {
- display: none;
- /* Chrome、Safari、微信小程序-web-view */
- }
- .area-item {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- white-space: nowrap;
- margin: 0 8px;
- }
- .area {
- width: 20px;
- height: 20px;
- border-radius: 4px;
- }
- .area-price {
- margin-left: 10rpx;
- font-size: 20rpx;
- line-height: 20px;
- }
- .footer {
- position: fixed;
- left: 0;
- bottom: var(--window-bottom);
- padding: 0 15px 15px 15px;
- width: 100vw;
- box-sizing: border-box;
- border-radius: 5px 5px 0% 0%;
- z-index: 999999999;
- background: #FFFFFF;
- border-radius: 16rpx 16rpx 0rpx 0rpx;
- padding: 28rpx 24rpx;
- box-sizing: border-box;
- .notice{
- font-size: 24rpx;
- color: #FF4A39;
- margin-top: 24rpx;
- margin-bottom: 24rpx;
- }
- .schedule {
- .name-box {
- display: flex;
- justify-content: space-between;
- .film-name {
- font-weight: bold;
- font-size: 32rpx;
- color: #222222;
- }
- .change-box {
- display: flex;
- align-items: center;
- font-size: 24rpx;
- color: #222222;
- .arrow{
- width: 32rpx;
- height: 32rpx;
- transform: rotate(90deg);
- }
- }
- }
- .film-info {
- font-size: 24rpx;
- color: #AAAAAA;
- margin-top: 20rpx;
- }
- }
- }
- .time-list{
- display: flex;
- flex-wrap: nowrap;
- scrollbar-width: none;
- margin-top: 24rpx;
- overflow-x: auto;
- white-space: nowrap;
- /* Firefox */
- -ms-overflow-style: none;
- /* IE/Edge */
- .time-item{
- flex-shrink: 0;
- width: 160rpx;
- height: 114rpx;
- background: #F9F9F9;
- border-radius: 16rpx 16rpx 16rpx 16rpx;
- box-sizing: border-box;
- padding: 10rpx 0;
- text-align: center;
- margin-right: 20rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: space-between;
- .time{
- font-size: 24rpx;
- color: #222222;
- }
- .type{
- font-size: 22rpx;
- color: #AAAAAA;
- }
- .price{
- font-size: 22rpx;
- color: #222222;
- }
- }
- .time-item.active {
- background: #FFE7E5;
- }
- }
- .seat-list {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- overflow-x: auto;
- white-space: nowrap;
- margin-top: 5px;
- scrollbar-width: none;
- margin-top: 24rpx;
- /* Firefox */
- -ms-overflow-style: none;
- /* IE/Edge */
- }
- .seat-list::-webkit-scrollbar {
- display: none;
- /* Chrome、Safari、微信小程序-web-view */
- }
- .seat-item {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 80rpx;
- background: #F9F9F9;
- border-radius: 16rpx 16rpx 16rpx 16rpx;
- box-sizing: border-box;
- padding: 10rpx 16rpx;
- margin-right: 20rpx;
- .seat-name {
- font-size: 24rpx;
- color: #222222;
- }
- .seat-price {
- font-size: 22rpx;
- color: #222222;
- margin-top: 10rpx;
- }
- .delete-seat {
- width: 20px;
- height: 20px;
- line-height: 20px;
- color: #727272;
- text-align: center;
- margin-left: 16rpx;
- }
- }
- .confirm {
- width: 100%;
- height: 80rpx;
- line-height: 80rpx;
- text-align: center;
- border-radius: 10px;
- font-size: 14px;
- color: #fff;
- background-color: #e54f4f;
- }
- .unbtn {
- background-color: #f69c9c;
- }
- </style>
|