Files
qitongxue-wx/src/pkg-event/seat.vue
李琦 3fbe7ac8c1 座位图拆「座位位置 / 桌子位置」两个 Tab
会场大俯视图里桌子被压到几十 px,座位圈挤在一起点不清;而只看
座位环又不知道自己在会场哪个位置。拆成两个视图各自放大,
各看清一件事:座位位置画圆桌 + 座位环绕 + 图例;桌子位置画
舞台 + 会场边界 + 我的桌子高亮落点、不画座位圈。无坐标时
隐藏桌子位置 Tab,不给假视图。

顺带修掉两个小程序端不显示的根因:
- canvas id 是页面级唯一的,原先固定 id 会让多实例取到同一个
  节点,改为实例 uid 前缀;
- boundingClientRect(cb) 回调式写法在微信小程序里参数恒为空,
  导致测得宽度 0、高度永远卡在默认值,改用 fields().exec()。
2026-10-13 16:23:37 +08:00

420 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>