功能、UI更新

This commit is contained in:
李琦
2026-10-09 16:49:09 +08:00
parent 4e87103099
commit a0c5d3e412
10 changed files with 887 additions and 217 deletions

View File

@@ -25,6 +25,14 @@ export const CODE_SEAT_RATE_LIMITED = 4006
/** 查座连续失败冷却中(与后端 `internal/consts.CodeSeatCooling` 一致)。与 4006 合并为同一句文案。 */
export const CODE_SEAT_COOLING = 4007
/**
* 命中重名,需补手机号二次查询(与后端 `internal/consts.CodeSeatAmbiguous` 一致,迭代-2026-10-09)。
*
* 🔴 触发时后端 `found=false` + `ambiguous=true` + **零座位信息**(不是 404),
* 前端据此把手机号输入框升为必填做二次查询。
*/
export const CODE_SEAT_AMBIGUOUS = 4010
/** 4006/4007 合并后的用户可见文案(与后端 `seatBusyMessage()` 一致,PRD-02 §4.2.3)。 */
export const SEAT_BUSY_MESSAGE = '操作过于频繁,请稍后再试'
@@ -97,6 +105,11 @@ export interface SeatCell {
*/
export interface SeatQueryResult {
found: boolean
/**
* 🔴 命中重名(后端 `EventSeatQueryRes.ambiguous`):有多个同名宾客,需补手机号二次查询。
* 为 true 时 `found` 必为 false,且**所有座位字段为空**(后端不下发,避免泄出他人桌号)。
*/
ambiguous?: boolean
/** found=false 时为统一模糊文案;found=true 时为 'ok'(不展示) */
message: string
table_no: string
@@ -143,9 +156,20 @@ export function apiEventInfo(eventId: number) {
return get<EventInfo>('/event/info', { event_id: eventId })
}
/** 扫码查座(免登录,姓名 + 手机号双因子精确匹配)。 */
/**
* 扫码查座(免登录)。
*
* 🔴 `name` / `phone` **二选一即可**发起查询(迭代-2026-10-09,原为双因子都必填):
* - 只给 name → 命中多张票时后端返回 `ambiguous=true` + 零座位信息,需补 phone 二次查询;
* - 只给 phone → 手机号唯一,直接命中;
* - 都给 → 双因子精确匹配。
*
* 🔴 `silent: true`:查座页用页内错误条展示完整文案,若不静默,请求层会**先弹一个 toast**,
* 紧接着页内又显示一条错误条 → 同一失败提示重复两遍(迭代-2026-10-09 实测截图确认)。
* 4005/4006/4007 在请求层已有静默白名单,但 422 等其余业务码没有,故在此显式静默。
*/
export function apiEventSeatQuery(eventId: number, name: string, phone: string) {
return post<SeatQueryResult>('/event/seat/query', { event_id: eventId, name, phone })
return post<SeatQueryResult>('/event/seat/query', { event_id: eventId, name, phone }, { silent: true })
}
/**

View File

@@ -429,9 +429,23 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
margin-left: 6rpx;
}
/* ============================== 版式 1 · iconTop 图标墙 ============================== */
/* ============================== 版式 1 · iconTop 图标墙 ==============================
🔴 R8(2026-10-09 用户报「宫格内容没有水平居中」实测修复):原为 `align-items: flex-start`,
导致图标 + 标题整体贴左。实测 sm 窄卡(158.8px 宽)里图标中心偏移 **-56.4px**、
标题 **-49.1px**,右侧留大片空白 —— 正是用户截图里「全挤左上角」的现象。
改为 center:图标块(`.ct__icon` / `.ct__art`)本身是 flex 容器且已自带
`justify-content:center`,只需父级水平居中即可;文案加 `text-align:center` 让多行文字也居中。
⚠️ 只改 iconTop / tile 这类「图标在上、文字在下」的版式;
`.ct` 基础与 iconLeft(行卡左对齐)、duo、lead 都不动,避免波及已有版式。 */
.ct--iconTop {
align-items: flex-start;
align-items: center;
text-align: center;
}
/* 文案居中:需同时覆盖 title/desc(CTA 胶囊是 inline-flex,天然跟随文本对齐) */
.ct--iconTop .ct__title,
.ct--iconTop .ct__desc {
text-align: center;
max-width: 100%;
}
/* ============================== 版式 2 · iconLeft 行卡 ============================== */
@@ -754,14 +768,14 @@ const effArtPos = computed<'top' | 'rightBottom'>(() =>
/* ============================== 版式 6 · tile 瓷片子卡 ==============================
规格硬约束(§2 :99):**不新增渲染分支**,tile 走「else 分支的 iconTop 骨架 + 本修饰 class」。
角标左上 pill / 插画右下,均由模板按 effBadgePos / effArtPos 挂 `.ct__badge--tl` / `.ct__art--br`,
本类只负责「文案左对齐」等骨架差异。 */
角标左上 pill / 插画右下,均由模板按 effBadgePos / effArtPos 挂 `.ct__badge--tl` / `.ct__art--br`。
🔴 R8:原为左对齐(与 iconTop 同样的贴左问题),现统一改为居中,与 iconTop 观感一致。 */
.ct--tile {
align-items: flex-start;
align-items: center;
}
.ct--tile .ct__title,
.ct--tile .ct__desc {
text-align: left;
text-align: center;
max-width: 100%;
}

View File

@@ -73,6 +73,21 @@ const props = withDefaults(defineProps<Props>(), {
hallH: 0,
})
/**
* 🔴 MP 取节点用的组件实例,**必须在 setup 顶层同步捕获**。
*
* 根因(迭代-2026-10-09 定位):`uni.createSelectorQuery().in(inst)` 靠 `inst` 把查询
* 限定在**本组件**的节点范围内;若 `inst` 为 null,查询会落空 → `acquireMP()` 拿不到
* canvas 节点 → `render()` 永不执行 → `ready` 恒 false → 遮罩「座位图加载中…」永久不消失。
*
* 为什么原来会 null:旧代码在 `onMounted(() => boot())` 里现取
* `getCurrentInstance()?.proxy`。**`onMounted` 是回调,此时已脱离 setup 的同步上下文**,
* `getCurrentInstance()` 在 MP 端会返回 null(页面 `onReady` 里能拿到是因为 uni 把页面
* 生命周期回调包进了实例上下文 —— 本组件是子组件,走的是 Vue 的 onMounted,不享受该特权)。
* 这正好解释了为什么「页面上直接放 canvas」的图片工具链一直正常,唯独本组件卡住。
*/
const instance = getCurrentInstance()?.proxy
// ============================================================================
// 画布逻辑坐标系(固定,与显示尺寸解耦;CSS 决定显示大小,bitmap 按其缩放)
// ============================================================================
@@ -821,7 +836,10 @@ function initCanvasSize(node: any, dpr: number) {
async function acquireMP(): Promise<boolean> {
// #ifdef MP-WEIXIN
const inst = getCurrentInstance()?.proxy
// 用 setup 顶层捕获的 instance,不用 getCurrentInstance() 现取(见 instance 处的注释)
const inst = instance
if (!inst)
return false
const queryNode = (selector: string): Promise<any> =>
new Promise((resolve) => {
uni
@@ -892,7 +910,9 @@ function acquireH5(): boolean {
// ============================================================================
function measureBox() {
const inst = getCurrentInstance()?.proxy
const inst = instance
if (!inst)
return
uni
.createSelectorQuery()
.in(inst)
@@ -905,7 +925,13 @@ function measureBox() {
.exec()
}
/** 取节点最终失败 → 展示可重试的失败态(**不再永久停在「加载中」**) */
const failed = ref(false)
/** 失败后允许用户手动重试(节点通常因时序未就绪,稍后重试即可成功) */
const retrying = ref(false)
async function boot() {
failed.value = false
measureBox()
await nextTick()
let ok = false
@@ -933,6 +959,26 @@ async function boot() {
// 这里补一次,保证「打开即动」;onHide/onShow 由父组件 page 生命周期驱动)
resume()
}
else {
// 🔴 关键:取不到节点时必须给用户一个**可退出**的状态。
// 旧实现里 ready 恒 false,遮罩「座位图加载中…」会一直挂着,用户既看不到座位、
// 也没有任何重试入口(迭代-2026-10-09 用户报障即为此)。
// 桌号/座位号等核心信息在二级页是纯文本展示,不依赖本组件,故降级不影响查座结果本身。
failed.value = true
}
}
/** 用户点「重新加载」 */
async function onRetry() {
if (retrying.value)
return
retrying.value = true
try {
await boot()
}
finally {
retrying.value = false
}
}
onMounted(() => {
@@ -964,11 +1010,20 @@ defineExpose({ render, resume, pause })
<view class="seatmap" :style="{ height: boxH }">
<canvas :id="STATIC_ID" type="2d" class="seatmap__cv" />
<canvas :id="FX_ID" type="2d" class="seatmap__cv" />
<view v-if="!ready" class="seatmap__mask">
<!-- 加载中:仅在首帧未完成且未失败时显示 -->
<view v-if="!ready && !failed" class="seatmap__mask">
<text class="seatmap__mask-text">
座位图加载中…
</text>
</view>
<!-- 🔴 取节点失败:给可退出的降级态 + 手动重试,绝不停在「加载中」 -->
<view v-if="failed" class="seatmap__mask seatmap__mask--fail">
<text class="seatmap__fail-text">座位图暂时无法显示</text>
<text class="seatmap__fail-sub">桌号与座位号见上方,不影响查看</text>
<view class="seatmap__retry press" hover-class="press-active" @tap="onRetry">
{{ retrying ? '加载中…' : '重新加载' }}
</view>
</view>
</view>
</template>
@@ -1001,4 +1056,28 @@ defineExpose({ render, resume, pause })
font-size: 24rpx;
color: #b3907c;
}
/* 取节点失败的降级态 */
.seatmap__mask--fail {
flex-direction: column;
gap: 10rpx;
}
.seatmap__fail-text {
font-size: 26rpx;
font-weight: 700;
color: #b3683a;
}
.seatmap__fail-sub {
font-size: 22rpx;
color: #b3907c;
}
.seatmap__retry {
margin-top: 12rpx;
padding: 12rpx 34rpx;
border-radius: 999rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
}
</style>

View File

@@ -414,13 +414,26 @@ function openTool(path: string, toolKey: string, locked: boolean) {
}
/* P5 修复竖排:楼层子卡改用调用方自管 scoped 网格(CardTile :grid="false" 后渲染裸 slot)。
写法与工作台 .wb-grid 一致(repeat(4,1fr) + gap),scoped class 在 MP 已被真机验证可靠;
组件内联 style 的 repeat() 网格在用户 MP 环境不生效导致逐字竖排,故弃用。 */
组件内联 style 的 repeat() 网格在用户 MP 环境不生效导致逐字竖排,故弃用。
🔴 R8(2026-10-09 用户报「上下左右间距没做好」实测修复):原为 `display:grid; repeat(4,1fr)`,
实测末行(11 个工具 = 4+4+3)右侧留 **85.19px 空洞** —— grid 按位摆放,末行不满 4 张时
剩余列位空着,与上方两行右边缘不齐。
修法:与工作台 `.wb-grid` 同一套 flex 换行 + flex-grow(该写法已真机验证):
· 满行 4 张时每张 1/4,与原 grid 完全一致;
· 末行 3 张时按剩余位置等分铺满,2 张时各占半行 ⇒ 右边缘始终对齐。
`max-width` 封顶 2 个位置宽:只有 1 张时别拉成通栏长条(同 `.wb-grid` 的 R8 处理)。
basis 里 `- 1rpx` 防 rpx 取整把满行挤成 3 张;3 个 gap = 48rpx。
⚠️ 保持 scoped class(不用内联 style),P5 已证内联 repeat() 在 MP 不生效。 */
.floor-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.floor-grid .cttap {
flex: 1 1 calc((100% - 48rpx) / 4 - 1rpx);
min-width: 0;
max-width: calc((100% - 16rpx) / 2 - 1rpx);
}
.foot {
height: calc(220rpx + env(safe-area-inset-bottom, 0px));

View File

@@ -757,7 +757,14 @@ function leadCard(key: string) {
· 满行(4 张)时每张恰好 1/4,与原来一致;
· 末行不足 4 张时,剩余卡片按**剩余位置等分**铺满(占 2 个位置就得到 2 个位置的宽度),
不再出现「一行只有两张、右侧大片空位」。
basis 里 `- 1rpx` 是防 rpx 取整把本该 4 张的一行挤成 3 张;多出的空间由 flex-grow 吸收。 */
basis 里 `- 1rpx` 是防 rpx 取整把本该 4 张的一行挤成 3 张;多出的空间由 flex-grow 吸收。
🔴 R8(2026-10-09 用户报「宫格排版不对、空太大」实测修复):`.wb-grid` 只有 1 张卡时,
flex-grow 会把它拉成**整行通栏 328px**,而卡片是 `size="sm"` 窄卡(内边距 8rpx、图标 68rpx),
实测内容只占 50px、右侧空 270px —— 一张 sm 窄卡拉成通栏长条,视觉上就是「大空盒子」。
修法:`max-width` 封顶 = 2 个位置宽。语义上「一张卡最多占半行」,与 R6「占两个位置就有两个位置的宽度」
完全兼容(2 张时 flex-grow 给的就是半行宽,正好等于 max-width,不受影响);
且**只封宽度、不改 flex 机制** ⇒ 4 张满行、末行 2 张等分的既有行为零回归。
(工具库 `.floor-grid` 是 grid repeat(4,1fr),天然按位不拉伸,无需同样处理——已实测确认正常。) */
.wb-grid {
display: flex;
flex-wrap: wrap;
@@ -767,6 +774,7 @@ function leadCard(key: string) {
.wb-grid .cttap {
flex: 1 1 calc((100% - 54rpx) / 4 - 1rpx);
min-width: 0;
max-width: calc((100% - 18rpx) / 2 - 1rpx);
}
/* 便捷功能:两列(≤2 张时的原排版) */

View File

@@ -143,6 +143,12 @@
"style": {
"navigationBarTitleText": "年会查座"
}
},
{
"path": "seat",
"style": {
"navigationBarTitleText": "我的座位"
}
}
]
},

View File

@@ -7,20 +7,24 @@
* - H5 / 开发调试:`?event_id=12` 普通参数。
* 两者都支持,优先取 `event_id`,回退解析 `scene`(含 URL 解码)。
*
* 交互(PRD-02 §5.4、R7-12~R7-24):
* 交互(PRD-02 §5.4、R7-12~R7-24;迭代-2026-10-09 调整):
* - 顶部展示活动主题 / 时间 / 地点;
* - `can_query=false` → 直接显示「活动未开放查询」并**禁用表单**(判据⑤);
* - 姓名 + 手机号**两个都填**才允许提交(只填一个给更轻的前端提示,不发请求);
* - 成功:显示「桌号 / 桌名 — 第 N 号座位」+ 座位示意(`SeatMap`)。
* 后端 E2 给出 `table_x/table_y/hall_w/hall_h` 时,按**真实相对位置**在会场图上高亮本人桌;
* 坐标缺失(0,含老数据/后端未部署)时组件自动回退到「本人桌居中 + 舞台」的老示意。
* - **姓名 / 手机号二选一即可发起查询**(C 端体验;原为双因子都必填):
* · 只填其一时照常请求;
* · 命中**重名**时后端回 `ambiguous=true` + **零座位信息**,本页把两项都升为必填,
* 引导补手机号做**二次查询**——绝不因为"只输了名字"就漏出别人的桌号;
* - 查询成功 → `navigateTo` 到**二级页** `pkg-event/seat`(结果经内存单例交接,不落盘不进 URL);
* 二级页展示「桌号 / 桌名 — 第 N 号座位」+ 座位示意(`SeatMap`),
* 后端 E2 给出 `table_x/table_y/hall_w/hall_h` 时按**真实相对位置**高亮本人桌,
* 坐标缺失(0,含老数据/后端未部署)时组件自动回退「本人桌居中 + 舞台」示意。
* - 失败:`found=false` → 直接展示后端 `message`(统一模糊文案,防枚举);4005 → 「活动未开放查询」;
* 4006/4007 → **合并为同一句**「操作过于频繁,请稍后再试」;网络错误 → 可重试。
*
* 🔴 本页**免登录**:不触发任何登录跳转(公开接口 EventPub,预期不会 401)。
* 🔴 隐私红线:不推断 / 不展示任何他人身份;不请求别桌数据(后端根本不提供)。
*/
import type { EventInfo, SeatQueryResult } from '@/api/event'
import type { EventInfo } from '@/api/event'
import {
apiEventInfo,
apiEventSeatQuery,
@@ -30,10 +34,10 @@ import {
EVENT_UNAVAILABLE_MESSAGE,
SEAT_BUSY_MESSAGE,
} from '@/api/event'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { computed, nextTick, ref, watch } from 'vue'
import SeatMap from '@/components/event/SeatMap.vue'
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { heroPadTop } from '@/utils/safearea'
import { setSeatHandoff } from '@/utils/event-seat-handoff'
import BackFab from '@/components/BackFab.vue'
import { usePageScrolled } from '@/utils/page-scrolled'
@@ -59,13 +63,15 @@ const name = ref('')
const phone = ref('')
const submitting = ref(false)
const result = ref<SeatQueryResult | null>(null)
/** 查询失败 / 异常提示 */
const queryError = ref('')
/** 失败是否可重试(网络类错误为 true) */
const queryRetryable = ref(false)
const seatMapRef = ref<{ render: () => void, resume: () => void, pause: () => void } | null>(null)
/**
* 🔴 命中重名:后端 `ambiguous=true` + 零座位信息。
* 此时把手机号升为必填,引导用户二次查询(迭代-2026-10-09)。
*/
const needPhone = ref(false)
/** 是否可查询(活动已发布) */
const canQuery = computed(() => !!info.value?.can_query)
@@ -75,18 +81,19 @@ const title = computed(() => info.value?.title || '年会查座')
const venue = computed(() => info.value?.venue || '')
const eventTime = computed(() => info.value?.event_time || '')
/** 两个都填才允许提交(只填一个不发请求,仅前端轻提示) */
const canSubmit = computed(
() => canQuery.value && !!name.value.trim() && !!phone.value.trim() && !submitting.value,
)
/** 查询上下文:命中重名后把「姓名 + 手机号」锁成必填,否则二选一即可 */
const ctxLabel = computed(() => (needPhone.value ? '重名,请补充手机号' : '输入姓名或手机号即可查询'))
const ctxHint = computed(() => (needPhone.value
? '检测到多个同名宾客,补充您的手机号即可确认座位'
: '记不清可只填其中一项'))
/** 结果文案:桌号 / 桌名 — 第 N 号座位 */
const seatText = computed(() => {
const r = result.value
if (!r)
return ''
const table = r.table_name ? `${r.table_no} ${r.table_name}` : `${r.table_no}`
return `${table} · 第 ${r.seat_no} 号座位`
/** 姓名 / 手机号**二选一非空**即可提交;命中重名后要求两者都填 */
const canSubmit = computed(() => {
if (!canQuery.value || submitting.value)
return false
const n = !!name.value.trim()
const p = !!phone.value.trim()
return needPhone.value ? (n && p) : (n || p)
})
// ============================================================================
@@ -155,8 +162,14 @@ function onSubmit() {
uni.showToast({ title: EVENT_UNAVAILABLE_MESSAGE, icon: 'none' })
return
}
if (!name.value.trim() || !phone.value.trim()) {
uni.showToast({ title: '请填写姓名和手机号', icon: 'none' })
const n = name.value.trim()
const p = phone.value.trim()
if (!n && !p) {
uni.showToast({ title: '请填写姓名或手机号', icon: 'none' })
return
}
if (needPhone.value && (!n || !p)) {
uni.showToast({ title: '请补充手机号以确认座位', icon: 'none' })
return
}
void doQuery()
@@ -168,18 +181,23 @@ async function doQuery() {
queryRetryable.value = false
try {
const res = await apiEventSeatQuery(eventId.value, name.value.trim(), phone.value.trim())
if (res.found) {
result.value = res
// 🔴 重名优先判定:后端 found=false + ambiguous=true + 零座位信息。
// 必须排在 found 之前判,否则会被当成「未查询到」而丢掉消歧入口。
if (res.ambiguous) {
needPhone.value = true
queryError.value = ''
return
}
else {
// 后端 message 已是统一模糊文案(防枚举,R7-21),直接展示,不自行拼文案
result.value = null
queryError.value = res.message || '未查询到您的座位信息,请联系活动组织者确认是否已录入'
if (res.found) {
// 命中 → 交结果给二级页并跳转(结果不留在本页,避免同页塞表单+结果)
setSeatHandoff(res)
uni.navigateTo({ url: `/pkg-event/seat?event_id=${eventId.value}` })
return
}
// 未命中:后端 message 已是统一模糊文案(防枚举,R7-21),直接展示,不自行拼文案
queryError.value = res.message || '未查询到您的座位信息,请联系活动组织者确认是否已录入'
}
catch (e) {
result.value = null
const code = (e as { code?: number }).code
if (code === CODE_EVENT_UNAVAILABLE) {
queryError.value = EVENT_UNAVAILABLE_MESSAGE
@@ -188,6 +206,18 @@ async function doQuery() {
// 4006 / 4007 合并为同一句文案
queryError.value = SEAT_BUSY_MESSAGE
}
else if (typeof code === 'number' && code > 0) {
// 🔴 带业务码的失败 = 后端**已经明确拒绝**(如 422 参数校验)。
// 直接透传后端 message(如「请填写正确的姓名或手机号」),**绝不能**兜底成
// 「网络异常,请检查网络后重试」—— 那是把「你填错了」误报成「网断了」,
// 会让用户白等重试。此处不给「重试」按钮:重发同样的入参必然同样被拒。
//
// 判据是 `code > 0` 而非「code !== undefined」:request.ts 成功分支
// `(err as ...).code = body && body.code` 在 body 为空时会写入 0/undefined,
// 而真正的网络失败(fail 分支)根本不带 code 字段。
queryError.value = (e as Error).message || '查询失败,请稍后重试'
queryRetryable.value = false
}
else {
queryError.value = '网络异常,请检查网络后重试'
queryRetryable.value = true
@@ -203,16 +233,9 @@ function onRetryQuery() {
}
// ============================================================================
// 动画与生命周期
// 生命周期
// ============================================================================
watch(result, async (v) => {
if (v && v.found) {
await nextTick()
seatMapRef.value?.resume()
}
})
onLoad((options) => {
eventId.value = pickEventId(options as Record<string, any> | undefined)
if (!eventId.value) {
@@ -223,18 +246,6 @@ onLoad((options) => {
}
loadInfo()
})
onShow(() => {
seatMapRef.value?.resume()
})
onHide(() => {
seatMapRef.value?.pause()
})
onUnload(() => {
seatMapRef.value?.pause()
})
</script>
<template>
@@ -278,40 +289,60 @@ onUnload(() => {
</view>
<template v-else>
<!-- 查询表单(紧凑 · 横向) -->
<!-- 查询卡片(C 端:大留白 + 竖排输入 + 圆角大按钮) -->
<view class="form tb-card tb-elev-2" :class="{ 'form--off': !canQuery }">
<view class="fields">
<input
v-model="name"
class="field"
placeholder="姓名"
placeholder-class="field__ph"
:disabled="!canQuery"
:maxlength="32"
confirm-type="next"
>
<view class="fields__sep" />
<input
v-model="phone"
class="field"
type="number"
placeholder="手机号"
placeholder-class="field__ph"
:disabled="!canQuery"
:maxlength="11"
confirm-type="search"
@confirm="onSubmit"
>
<text class="form__title">{{ ctxLabel }}</text>
<text class="form__hint">{{ ctxHint }}</text>
<!-- 重名消歧提示(后端 ambiguous=true,不返回任何座位信息) -->
<view v-if="needPhone" class="dup">
<wd-icon name="warning" size="28rpx" color="#E8A87C" />
<text class="dup__text">检测到多个同名宾客,补充手机号后才能确认座位</text>
</view>
<wd-button
type="primary"
:loading="submitting"
:disabled="!canQuery"
custom-style="border-radius: 999rpx; height: 84rpx; font-size: 30rpx; margin-top: 20rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="onSubmit"
<view class="inputs">
<view class="inputbox" :class="{ 'inputbox--req': needPhone }">
<wd-icon name="user" size="30rpx" color="#C39A80" />
<input
v-model="name"
class="inputbox__in"
placeholder="宾客姓名"
placeholder-class="inputbox__ph"
:disabled="!canQuery"
:maxlength="32"
confirm-type="next"
>
</view>
<view class="inputs__or">
<view class="inputs__line" />
<text class="inputs__or-text">{{ needPhone ? '与' : '或' }}</text>
<view class="inputs__line" />
</view>
<view class="inputbox" :class="{ 'inputbox--req': needPhone }">
<wd-icon name="phone" size="30rpx" color="#C39A80" />
<input
v-model="phone"
class="inputbox__in"
type="number"
placeholder="手机号"
placeholder-class="inputbox__ph"
:disabled="!canQuery"
:maxlength="11"
confirm-type="search"
@confirm="onSubmit"
>
</view>
</view>
<view
class="submit press"
:class="{ 'submit--off': !canSubmit, 'submit--busy': submitting }"
hover-class="press-active"
@tap="onSubmit"
>
查询我的座位
</wd-button>
<text class="submit__text">{{ submitting ? '查询中…' : '查询我的座位' }}</text>
</view>
<view class="privacy">
<wd-icon name="lock-on" size="22rpx" color="#B3907C" />
<text class="privacy__text">仅用于查询您本人的座位,信息不会被公开</text>
@@ -331,7 +362,7 @@ onUnload(() => {
</view>
</view>
<!-- 查询失败 -->
<!-- 查询失败(重名走 needPhone 分支,不落这里) -->
<view v-else-if="queryError" class="query-fail tb-card tb-elev-1">
<wd-icon name="warning" size="40rpx" color="#E5484D" />
<text class="query-fail__text">{{ queryError }}</text>
@@ -339,37 +370,6 @@ onUnload(() => {
重试
</view>
</view>
<!-- 查询成功:本人桌座 + 示意 -->
<view v-else-if="result && result.found" class="result">
<view class="result__head tb-card tb-elev-3">
<view class="result__badge">
<text class="result__badge-text">{{ result.table_no || '—' }}</text>
</view>
<view class="result__info">
<text class="result__table">{{ seatText }}</text>
<text v-if="result.table_name" class="result__extra">桌名:{{ result.table_name }}</text>
</view>
</view>
<view class="result__map tb-card tb-elev-2">
<text class="result__map-title">本人所在桌示意</text>
<SeatMap
ref="seatMapRef"
: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"
/>
<text class="result__map-tip">★ 高亮处即您所在的桌子与座位</text>
</view>
</view>
</template>
<view class="foot" />
@@ -443,45 +443,124 @@ onUnload(() => {
color: rgba(255, 255, 255, 0.92);
}
/* 表单(紧凑横向) */
/* 查询卡片(C 端:大留白 + 竖排输入 + 独立大按钮) */
.form {
padding: 26rpx;
padding: 40rpx 32rpx 32rpx;
}
.form--off {
opacity: 0.6;
}
.fields {
display: flex;
align-items: center;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 0 8rpx;
height: 84rpx;
.form__title {
display: block;
font-size: 36rpx;
font-weight: 800;
color: #3d2a1e;
letter-spacing: 0.01em;
}
.field {
.form__hint {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #9a7b65;
}
/* 重名消歧提示 */
.dup {
display: flex;
align-items: flex-start;
gap: 10rpx;
margin-top: 24rpx;
padding: 18rpx 20rpx;
border-radius: 20rpx;
background: #fff4ec;
border: 2rpx solid #ffe3d3;
}
.dup__text {
flex: 1;
min-width: 0;
height: 84rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #3d2a1e;
font-size: 24rpx;
line-height: 1.5;
color: #b3683a;
}
.inputs {
display: flex;
flex-direction: column;
margin-top: 32rpx;
}
.inputbox {
display: flex;
align-items: center;
gap: 16rpx;
height: 100rpx;
padding: 0 26rpx;
border-radius: 28rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
box-sizing: border-box;
}
.fields__sep {
width: 2rpx;
height: 40rpx;
background: #ffe3d3;
flex-shrink: 0;
/* 消歧阶段:手机号/姓名都变必填,用主色描边做视觉升级 */
.inputbox--req {
border-color: #ffc79e;
background: #fff6ee;
}
.field__ph {
.inputbox__in {
flex: 1;
min-width: 0;
height: 100rpx;
font-size: 30rpx;
color: #3d2a1e;
}
.inputbox__ph {
color: #c39a80;
font-size: 28rpx;
}
.inputs__or {
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx 0;
}
.inputs__line {
flex: 1;
height: 2rpx;
background: #ffe3d3;
}
.inputs__or-text {
font-size: 22rpx;
color: #c39a80;
}
/* 主按钮:自绘而非 wd-button,便于精确控制 C 端观感与禁用态 */
.submit {
margin-top: 40rpx;
height: 96rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ff9f4d, #fb4e14);
box-shadow: 0 14rpx 30rpx rgba(251, 78, 20, 0.32);
}
.submit--off {
background: #ffd9c2;
box-shadow: none;
}
.submit--busy {
opacity: 0.75;
}
.submit__text {
font-size: 32rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.02em;
}
.privacy {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 18rpx;
margin-top: 24rpx;
}
.privacy__text {
font-size: 22rpx;
@@ -550,69 +629,6 @@ onUnload(() => {
font-weight: 700;
}
/* 结果 */
.result {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.result__head {
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
}
.result__badge {
flex-shrink: 0;
min-width: 104rpx;
height: 104rpx;
padding: 0 18rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, #ff9f4d, #fb4e14);
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.32);
}
.result__badge-text {
font-size: 40rpx;
font-weight: 800;
color: #ffffff;
}
.result__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.result__table {
font-size: 32rpx;
font-weight: 800;
color: #3d2a1e;
}
.result__extra {
font-size: 24rpx;
color: #9a7b65;
}
.result__map {
padding: 26rpx 22rpx;
}
.result__map-title {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
}
.result__map-tip {
display: block;
margin-top: 16rpx;
text-align: center;
font-size: 22rpx;
color: #b3907c;
}
.foot {
height: 20rpx;
}

349
src/pkg-event/seat.vue Normal file
View File

@@ -0,0 +1,349 @@
<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)
const seatMapRef = ref<{ render: () => void, resume: () => void, pause: () => void } | null>(null)
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>
<!-- 本人所在桌示意 -->
<view class="map tb-card tb-elev-2">
<view class="map__head">
<text class="map__title">本人所在桌示意</text>
<text class="map__hint">★ 高亮处即您所在的桌子与座位</text>
</view>
<SeatMap
ref="seatMapRef"
: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__title {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.map__hint {
font-size: 22rpx;
color: #b3907c;
}
.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>

View File

@@ -9,7 +9,7 @@
* hero 头图横幅 / flow 服务流程 / checklist 交付清单 / price 价格卡 / rich 富文本 /
* image_text 图文 / image_list 图片列表 / developer 开发者简介 / works 作品入口 / goods 服务入口。
*/
import { apiServicePage, apiServiceWorks, type ServiceBlock, type WorkItem } from '@/api/service'
import { apiQuoteMine, apiServicePage, apiServiceWorks, type QuoteItem, type ServiceBlock, type WorkItem } from '@/api/service'
import { BASE_URL } from '@/config/env'
import { parseMarkdown, type MdNode } from '@/utils/md-parser'
import { onShow } from '@dcloudio/uni-app'
@@ -25,11 +25,30 @@ const loading = ref(true)
const blocks = ref<ServiceBlock[]>([])
/** works 卡的作品精选(取前 6 个,3 列两行) */
const workPeeks = ref<WorkItem[]>([])
/** 我的报价(需求申请)记录:非空时才显示悬浮入口,>0 时角标显示条数 */
const quoteList = ref<QuoteItem[]>([])
/** 未处理(待处理 / 已报价)条数:角标只提示「有进展」的,避免已成交的仍被角标打扰 */
const quotePending = computed(() => quoteList.value.filter(q => q.status === 0 || q.status === 1).length)
onShow(() => {
load()
loadQuotes()
})
/**
* 拉我的报价记录(决定悬浮入口显隐 + 角标条数)。
* 静默失败:未登录 / 网络异常时保持空数组(入口不出现),绝不打断本页加载。
*/
async function loadQuotes() {
try {
const res = await apiQuoteMine(20)
quoteList.value = res.list || []
}
catch {
quoteList.value = []
}
}
async function load() {
loading.value = true
try {
@@ -120,6 +139,18 @@ function goQuote() {
uni.navigateTo({ url: '/pkg-service/quote' })
}
/** 进「我的报价」(需求申请记录列表) */
function goQuotes() {
uni.navigateTo({ url: '/pkg-service/quotes' })
}
/**
* 悬浮入口的 bottom:需让开底部固定 CTA 条。
* CTA 实测构成 = padding 上下 18rpx + safe-area + 按钮高(20rpx*2 + 31rpx*1.25≈79rpx) ≈ 115rpx,
* 取 132rpx 留出安全余量,再叠加 Home Indicator。
*/
const quoteFabBottom = `calc(132rpx + env(safe-area-inset-bottom, 0px))`
function previewImage(urls: string[], current: string) {
if (!urls.length)
return
@@ -413,6 +444,28 @@ function previewImage(urls: string[], current: string) {
</text>
</view>
<!-- 我的报价悬浮入口:仅当已有需求申请记录时出现(onShow 拉 apiQuoteMine 判定)。
固定在底部 CTA 上方,避免与「申请报价」按钮争位;角标只计未完结(待处理/已报价)。 -->
<view
v-if="quoteList.length > 0"
class="qfab press"
hover-class="press-active"
:style="{ bottom: quoteFabBottom }"
@tap="goQuotes"
>
<!-- 图标名用 goods:实测 `orders` 在 wd-icons 字体里**不存在**(渲染尺寸 0×0,字形静默丢失,
同页 arrow-right / arrow-left 均正常)。goods 是项目内已在用且渲染正常的图标。 -->
<view class="qfab__icon">
<wd-icon name="goods" size="34rpx" color="#ffffff" />
<text v-if="quotePending > 0" class="qfab__badge">
{{ quotePending > 99 ? '99+' : quotePending }}
</text>
</view>
<text class="qfab__text">
我的需求
</text>
</view>
<BackFab :show="scrolled" />
</view>
</template>
@@ -1058,4 +1111,71 @@ function previewImage(urls: string[], current: string) {
align-items: center;
justify-content: center;
}
/* ===== 我的需求悬浮入口(仅有报价记录时出现)=====
页面根级 fixed(不被任何玻璃祖先劫持包含块);层级取 20,与底部 CTA 同层但位置错开,
避开 CTA 条(bottom 由 quoteFabBottom 让开)。玻璃胶囊 + 橙色图标座,与 CTA 同一视觉语言。 */
.qfab {
position: fixed;
right: 24rpx;
z-index: 20;
display: flex;
align-items: center;
gap: 12rpx;
padding: 10rpx 28rpx 10rpx 10rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(20rpx) saturate(1.6);
backdrop-filter: blur(20rpx) saturate(1.6);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95), 0 10rpx 26rpx rgba(151, 60, 8, 0.18);
animation: qfabIn 0.32s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
@keyframes qfabIn {
from {
opacity: 0;
transform: translateY(20rpx) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.qfab__icon {
position: relative;
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(140deg, #ff8a4d 0%, #fb4e14 62%, #f0430e 100%);
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.32);
display: flex;
align-items: center;
justify-content: center;
}
/* 角标:未完结条数(>99 折成 99+,避免撑破圆角) */
.qfab__badge {
position: absolute;
top: -8rpx;
right: -10rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
border-radius: 999rpx;
background: #e8531a;
color: #ffffff;
font-size: 19rpx;
font-weight: 700;
line-height: 30rpx;
text-align: center;
border: 2rpx solid #ffffff;
box-sizing: border-box;
}
.qfab__text {
font-size: 26rpx;
font-weight: 800;
color: #3d2a1e;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,41 @@
/**
* 查座结果在「query → seat」两个页面之间的**内存交接**。
*
* 🔴 为什么不用 URL query 传结果:
* 查座结果含本人座位号,若拼进 `navigateTo` 的 url 会进入页面栈,
* 可能被日志、`getCurrentPages`、页面分享/截图读出 → 违反结果最小化红线。
*
* 🔴 为什么不用 `uni.setStorageSync`:
* 同样是敏感信息,且落盘后会残留(用户清缓存前一直可读)。内存单例进程结束即消失。
*
* 🔴 为什么不用 `EventChannel`:
* 可行但需要 query/seed 两边各维护一套 on/emit 回调,且「页面被系统回收后重进」时通道失效、
* 还要再兜一层缓存。内存单例 + 消费即清空,一处逻辑覆盖全部场景。
*
* 用法:query 页 `setSeatHandoff(res)` 后 navigateTo;
* seat 页 onLoad 里 `takeSeatHandoff()`(**读后即清**,保证结果只被消费一次)。
*/
import type { SeatQueryResult } from '@/api/event'
/** 模块级变量(非响应式):只做跨页面传递,不参与渲染,故不进 ref */
let handoff: SeatQueryResult | null = null
/** 写入待交接的查座结果(query 页查询成功后调用) */
export function setSeatHandoff(r: SeatQueryResult): void {
handoff = r
}
/**
* 取并清空交接结果(seat 页 onLoad 调用)。
* 返回 null 表示「没有交接数据」——调用方须展示兜底空态,绝不空屏。
*/
export function takeSeatHandoff(): SeatQueryResult | null {
const r = handoff
handoff = null
return r
}
/** 是否存在待交接结果(用于 page 页做存在性判断,避免误清) */
export function hasSeatHandoff(): boolean {
return handoff !== null
}