会场大俯视图里桌子被压到几十 px,座位圈挤在一起点不清;而只看 座位环又不知道自己在会场哪个位置。拆成两个视图各自放大, 各看清一件事:座位位置画圆桌 + 座位环绕 + 图例;桌子位置画 舞台 + 会场边界 + 我的桌子高亮落点、不画座位圈。无坐标时 隐藏桌子位置 Tab,不给假视图。 顺带修掉两个小程序端不显示的根因: - canvas id 是页面级唯一的,原先固定 id 会让多实例取到同一个 节点,改为实例 uid 前缀; - boundingClientRect(cb) 回调式写法在微信小程序里参数恒为空, 导致测得宽度 0、高度永远卡在默认值,改用 fields().exec()。
420 lines
12 KiB
Vue
420 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 年会查座 **二级页**(分包 pkg-event):座位结果详情。
|
||
*
|
||
* 迭代-2026-10-09 新增。此前查询成功直接在 `query.vue` 同页渲染结果,
|
||
* 现改为 `query.vue` 查询成功后 `navigateTo('/pkg-event/seat?event_id=N')` 到本页。
|
||
*
|
||
* 数据交接:走 `utils/event-seat-handoff` 的**内存单例**(不落盘、不进 URL)——
|
||
* 理由与隐私红线见该文件注释。取不到时展示兜底空态,**绝不空屏**。
|
||
*
|
||
* 🔴 隐私红线:本页只展示**本人**座位 + 本人所在桌示意(各座位是否有人,不含任何人身份)。
|
||
*/
|
||
import type { EventInfo, SeatQueryResult } from '@/api/event'
|
||
import { apiEventInfo } from '@/api/event'
|
||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||
import { computed, ref } from 'vue'
|
||
import SeatMap from '@/components/event/SeatMap.vue'
|
||
import { heroPadTop } from '@/utils/safearea'
|
||
import { takeSeatHandoff } from '@/utils/event-seat-handoff'
|
||
import BackFab from '@/components/BackFab.vue'
|
||
import { usePageScrolled } from '@/utils/page-scrolled'
|
||
|
||
const heroPad = heroPadTop()
|
||
const scrolled = usePageScrolled()
|
||
|
||
function goBack() {
|
||
uni.navigateBack()
|
||
}
|
||
|
||
const eventId = ref(0)
|
||
const info = ref<EventInfo | null>(null)
|
||
/** 座位结果(null = 未拿到) */
|
||
const result = ref<SeatQueryResult | null>(null)
|
||
/** 已尝试取交接数据但仍为空 → 展示兜底空态 */
|
||
const missed = ref(false)
|
||
|
||
/**
|
||
* 当前 tab:`seat` = 座位位置(圆桌 + 座位圈);`hall` = 桌子位置(会场俯视)。
|
||
* 默认先看「座位位置」—— 细节更实用;「我在会场哪儿」按需切过去。
|
||
*/
|
||
const mapTab = ref<'seat' | 'hall'>('seat')
|
||
|
||
/**
|
||
* 🔴 「桌子位置」tab 是否可用:后端没给真实坐标(table_x/table_y 为 0,含老数据)时,
|
||
* 画不出「在会场哪儿」—— 硬画就是骗人。故此时**直接隐藏该 tab**,不给用户一个
|
||
* 看起来对但其实瞎编的俯视图。
|
||
*/
|
||
const hasHallPos = computed(() => {
|
||
const r = result.value
|
||
return !!r && r.table_x > 0 && r.table_y > 0
|
||
})
|
||
|
||
const seatMapRef = ref<{ render: () => void, resume: () => void, pause: () => void } | null>(null)
|
||
|
||
/** 切 tab:两个视图各自数据不同,必须重新渲染而不是复用上一张画布 */
|
||
function switchTab(tab: 'seat' | 'hall') {
|
||
if (mapTab.value === tab)
|
||
return
|
||
mapTab.value = tab
|
||
// 等 DOM 切换完成后再重绘;动画只在座位 tab 有意义
|
||
setTimeout(() => {
|
||
seatMapRef.value?.render()
|
||
seatMapRef.value?.resume()
|
||
}, 30)
|
||
}
|
||
|
||
const title = computed(() => info.value?.title || '我的座位')
|
||
|
||
/**
|
||
* 桌描述文案:`table_no` 与 `table_name` 归一化后的**唯一**展示串。
|
||
*
|
||
* 🔴 为什么要归一化:多数活动里 `table_name` 本身就是「第 N 号桌」
|
||
* (实测数据:`table_no="1"` + `table_name="1 号桌"`)。若无条件拼成
|
||
* `${table_no} ${table_name}`,主标题会渲染成「**1 1 号桌** · 第 1 号座位」,
|
||
* 下方再来一行「桌名:1 号桌」—— 同一信息出现三次。
|
||
*
|
||
* 规则:
|
||
* - 桌名已含桌号(「1 号桌」/「第1桌」)→ 只用桌名;
|
||
* - 桌名是独立雅称(「主桌」)→ 用「桌号 + 雅称」。
|
||
*/
|
||
const tableLabel = computed(() => {
|
||
const r = result.value
|
||
if (!r)
|
||
return ''
|
||
const no = `${r.table_no || ''}`.trim()
|
||
const name = `${r.table_name || ''}`.trim()
|
||
if (!name)
|
||
return no
|
||
// 去掉「第/号/桌/空格」等装饰字符后比对前缀,避免「1号桌」「第 1 桌」被误判成不含桌号
|
||
const stripped = name.replace(/[\s第号桌]/g, '')
|
||
if (no && stripped.startsWith(no))
|
||
return name
|
||
return no ? `${no} ${name}` : name
|
||
})
|
||
|
||
/** 结果文案:`${桌描述} · 第 N 号座位` */
|
||
const seatText = computed(() => {
|
||
const r = result.value
|
||
if (!r)
|
||
return ''
|
||
return `${tableLabel.value} · 第 ${r.seat_no} 号座位`
|
||
})
|
||
|
||
/**
|
||
* 是否需要额外展示一行「桌名:xxx」。
|
||
*
|
||
* 🔴 判据是「桌名是否已被主标题吸收」——桌名已进 `${tableLabel}` 时再单列一行即为冗余。
|
||
* 只有活动方给桌子起了独立雅称(如 `1` / `主桌`)时才补这一行。
|
||
*/
|
||
const showTableName = computed(() => {
|
||
const r = result.value
|
||
const name = `${r?.table_name || ''}`.trim()
|
||
if (!name)
|
||
return false
|
||
return name !== tableLabel.value
|
||
})
|
||
|
||
onLoad((options) => {
|
||
const raw = options?.event_id ?? options?.eventId
|
||
if (raw != null && `${raw}`.trim() !== '') {
|
||
const n = Number(`${raw}`.trim())
|
||
if (Number.isFinite(n) && n > 0)
|
||
eventId.value = Math.floor(n)
|
||
}
|
||
|
||
// 从内存单例取结果(读后即清);拿不到 → 兜底空态
|
||
const r = takeSeatHandoff()
|
||
if (r && r.found) {
|
||
result.value = r
|
||
missed.value = false
|
||
}
|
||
else {
|
||
missed.value = true
|
||
}
|
||
})
|
||
|
||
onShow(() => {
|
||
if (eventId.value)
|
||
loadInfo()
|
||
seatMapRef.value?.resume()
|
||
})
|
||
|
||
onUnload(() => {
|
||
seatMapRef.value?.pause()
|
||
})
|
||
|
||
async function loadInfo() {
|
||
try {
|
||
info.value = await apiEventInfo(eventId.value)
|
||
}
|
||
catch {
|
||
// 顶栏活动信息失败不阻断结果展示(座位信息才是本页主体)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<view class="page">
|
||
<!-- 渐变头区 -->
|
||
<view class="tb-hero" :style="{ paddingTop: heroPad }">
|
||
<view class="nav">
|
||
<view class="nav__back press" hover-class="press-active" @tap="goBack">
|
||
<wd-icon name="arrow-left" size="36rpx" color="#ffffff" />
|
||
</view>
|
||
<view class="nav__titles">
|
||
<text class="nav__title">{{ title }}</text>
|
||
<text class="nav__sub">座位信息</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="tb-body">
|
||
<!-- 兜底空态:进程被回收后重进本页,交接数据已丢失 -->
|
||
<view v-if="missed && !result" class="empty tb-card tb-elev-1">
|
||
<wd-icon name="info-circle" size="44rpx" color="#E8A87C" />
|
||
<text class="empty__title">座位信息已失效</text>
|
||
<text class="empty__sub">请返回重新查询一次,即可看到您的座位。</text>
|
||
<view class="empty__btn press" hover-class="press-active" @tap="goBack">
|
||
返回重新查询
|
||
</view>
|
||
</view>
|
||
|
||
<template v-else-if="result">
|
||
<!-- 桌号主卡:仪式感居中 -->
|
||
<view class="hero tb-card tb-elev-3">
|
||
<view class="hero__badge">
|
||
<text class="hero__badge-text">{{ result.table_no || '—' }}</text>
|
||
</view>
|
||
<text class="hero__seat">{{ seatText }}</text>
|
||
<!-- 🔴 桌名已并入主标题时不再重复展示(否则出现「1 1 号桌 · 第1号座位」+「桌名:1 号桌」双重冗余) -->
|
||
<text v-if="showTableName" class="hero__extra">桌名:{{ result.table_name }}</text>
|
||
</view>
|
||
|
||
<!-- 座位图:双 Tab(桌子位置 / 座位位置) -->
|
||
<view class="map tb-card tb-elev-2">
|
||
<view class="map__head">
|
||
<view class="tabs">
|
||
<view
|
||
class="tabs__item press"
|
||
:class="{ 'tabs__item--on': mapTab === 'seat' }"
|
||
hover-class="press-active"
|
||
@tap="switchTab('seat')"
|
||
>
|
||
<text class="tabs__text">座位位置</text>
|
||
</view>
|
||
<!-- 🔴 无坐标时隐藏「桌子位置」:画不出真实位置就别给假视图 -->
|
||
<view
|
||
v-if="hasHallPos"
|
||
class="tabs__item press"
|
||
:class="{ 'tabs__item--on': mapTab === 'hall' }"
|
||
hover-class="press-active"
|
||
@tap="switchTab('hall')"
|
||
>
|
||
<text class="tabs__text">桌子位置</text>
|
||
</view>
|
||
</view>
|
||
<text class="map__hint">{{ mapTab === 'seat' ? '★ 高亮处即您所在的座位' : '★ 高亮处即您所在的桌子' }}</text>
|
||
</view>
|
||
<SeatMap
|
||
ref="seatMapRef"
|
||
:mode="mapTab"
|
||
:capacity="result.capacity"
|
||
:seats="result.seats"
|
||
:self-seat-no="result.seat_no"
|
||
:table-no="result.table_no"
|
||
:table-name="result.table_name"
|
||
:stage="result.stage"
|
||
:table-x="result.table_x"
|
||
:table-y="result.table_y"
|
||
:hall-w="result.hall_w"
|
||
:hall-h="result.hall_h"
|
||
/>
|
||
</view>
|
||
|
||
<view class="privacy">
|
||
<wd-icon name="lock-on" size="22rpx" color="#B3907C" />
|
||
<text class="privacy__text">仅展示您本人的座位,不含其他宾客信息</text>
|
||
</view>
|
||
</template>
|
||
|
||
<view class="foot" />
|
||
</view>
|
||
|
||
<BackFab :show="scrolled" />
|
||
</view>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom, 0px));
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.nav {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
}
|
||
.nav__back {
|
||
flex-shrink: 0;
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.22);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.nav__titles {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
.nav__title {
|
||
font-size: 36rpx;
|
||
font-weight: 800;
|
||
color: #ffffff;
|
||
letter-spacing: 0.01em;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.nav__sub {
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.86);
|
||
}
|
||
|
||
/* 桌号主卡:仪式感居中,弱化表单感 */
|
||
.hero {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 44rpx 32rpx 40rpx;
|
||
}
|
||
.hero__badge {
|
||
min-width: 132rpx;
|
||
height: 132rpx;
|
||
padding: 0 24rpx;
|
||
border-radius: 40rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
|
||
box-shadow: 0 14rpx 30rpx rgba(251, 78, 20, 0.32);
|
||
}
|
||
.hero__badge-text {
|
||
font-size: 56rpx;
|
||
font-weight: 800;
|
||
color: #ffffff;
|
||
}
|
||
.hero__seat {
|
||
margin-top: 22rpx;
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: #3d2a1e;
|
||
text-align: center;
|
||
}
|
||
.hero__extra {
|
||
margin-top: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #9a7b65;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 座位图 */
|
||
.map {
|
||
padding: 26rpx 22rpx;
|
||
margin-top: 24rpx;
|
||
}
|
||
.map__head {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.map__hint {
|
||
font-size: 22rpx;
|
||
color: #b3907c;
|
||
}
|
||
|
||
/* 双 Tab(座位位置 / 桌子位置) */
|
||
.tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
.tabs__item {
|
||
padding: 12rpx 30rpx;
|
||
border-radius: 999rpx;
|
||
background: #fff4ec;
|
||
border: 1rpx solid #ffe0d0;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
.tabs__item--on {
|
||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||
border-color: transparent;
|
||
box-shadow: 0 8rpx 20rpx rgba(251, 78, 20, 0.24);
|
||
}
|
||
.tabs__text {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
color: #9a7b65;
|
||
}
|
||
.tabs__item--on .tabs__text {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.privacy {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
margin-top: 24rpx;
|
||
}
|
||
.privacy__text {
|
||
font-size: 22rpx;
|
||
color: #9a7b65;
|
||
}
|
||
|
||
/* 兜底空态 */
|
||
.empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
padding: 60rpx 40rpx;
|
||
}
|
||
.empty__title {
|
||
margin-top: 8rpx;
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #3d2a1e;
|
||
}
|
||
.empty__sub {
|
||
font-size: 24rpx;
|
||
color: #9a7b65;
|
||
text-align: center;
|
||
}
|
||
.empty__btn {
|
||
margin-top: 24rpx;
|
||
padding: 16rpx 44rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
|
||
color: #ffffff;
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.28);
|
||
}
|
||
|
||
.foot {
|
||
height: 20rpx;
|
||
}
|
||
</style>
|