座位图拆「座位位置 / 桌子位置」两个 Tab

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

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

View File

@@ -5,15 +5,21 @@
* 绘制内容(**只画本人所在桌 + 舞台**,架构 §5.4 / PRD-02 R7-14 / R7-16):
* - 圆桌:桌号 + 桌名居中文字;
* - 沙发座位:`capacity` 个,沿圆周均匀分布,编号 1..capacity,**顺时针自 12 点起**(架构 §8.2);
* - 舞台:按 `stage` 的 x/y/w/h/shape/rotation/color 绘制(未知 shape 一律 fallback 矩形)。
* - 舞台:按 `stage` 的 x/y/w/h/shape/rotation/color 绘制(未知 shape 一律按矩形画)。
*
* 两种布局模式(由后端 E2 是否给出坐标决定):
* - **hall(会场定位)**:`table_x/table_y > 0` 时启用。把 `hall_w`×`hall_h` 作为逻辑坐标系,
* 等比(contain)映射进「扣除舞台横幅后的绘制区」,本人桌按**真实相对位置**落点,
* 并加光晕 + 橙色虚环 + 「你的桌子」标注,一眼可见「我的桌子在会场哪儿」;
* - **fallback(现有示意)**:坐标缺失(`table_x/table_y` 为 0,含老数据 / 后端未部署)时**原样回退**到
* 「本人桌居中 + 舞台横幅」的老观感。**绝不在 (0,0) 处画桌子,也不因缺字段而崩。**
* `hall_w/hall_h` 缺省(0)时分别回退 `HALL_LOGIC_W/HALL_LOGIC_H`。
* 两种布局模式(由 `mode` prop 决定,迭代-2026-10-09 双 Tab):
* - **`hall`「桌子位置」**:`table_x/table_y > 0` 时启用整幅会场俯视图。
* 把 `hall_w`×`hall_h` 作为逻辑坐标系,等比(contain)映射进「扣除舞台横幅后的绘制区」,
* 本人桌按**真实相对位置**落点,加光晕 + 橙色虚环 + 「你的桌子」标注,一眼可见「在会场哪儿」。
* **此模式不画座位圈**(桌子在全场俯视里被压得很小,座位挤成一团根本看不清),
* 桌面画到最大以便辨认。
* - **`seat`「座位位置」**(默认):圆桌居中 + `capacity` 个座位环绕 + 「你」呼吸高亮 + 图例。
* **不画会场边界**,让圆桌尽可能大、座位编号尽可能清晰。
*
* 🔴 无坐标时的降级:`table_x/table_y` 为 0(含老数据 / 后端未部署)时,即使 `mode='hall'`
* 也**自动退回 `seat` 模式** —— 没有落点就画不出位置,硬画等于骗人。
* `hall_w/hall_h` 缺省(0)时分别回退 `HALL_LOGIC_W/HALL_LOGIC_H`。
* 绝不把桌子画到 (0,0),也绝不因缺字段而崩。
*
* 🔴 结果最小化红线:hall 模式**也只画本人那一张桌**——不画其它桌、不画容量/已坐统计/他人信息。
*
@@ -50,7 +56,7 @@ interface Props {
stage?: StageInfo | null
/**
* 本人桌圆心 x(大厅逻辑坐标系,后端 E2 新增)。
* **0 = 无坐标**(老数据 / 后端未部署)→ 走 fallback 示意。
* **0 = 无坐标**(老数据 / 后端未部署)→ 自动退回 seat 模式。
*/
tableX?: number
/** 本人桌圆心 y(语义同 {@link tableX};0 = 无坐标) */
@@ -59,6 +65,16 @@ interface Props {
hallW?: number
/** 大厅逻辑高(0 = 缺省,回退 HALL_LOGIC_H) */
hallH?: number
/**
* 展示模式(迭代-2026-10-09 双 Tab):
* - `hall`「桌子位置」:整幅会场俯视图(舞台 + 会场边界 + 我的桌子高亮落点),**不画座位圈**;
* - `seat`「座位位置」:圆桌 + N 个座位环绕 + 「你」高亮,**不画会场边界**。
*
* 🔴 为什么拆两个而不是一张图画完:会场大俯视图里桌子被压得很小(几十 px),
* 座位圈挤在一起根本点不清;而只看座位环又不知道自己在会场哪个位置。
* 两个视图各自放大,才能各看清一件事。默认 `seat`(细节更实用)。
*/
mode?: 'hall' | 'seat'
}
const props = withDefaults(defineProps<Props>(), {
@@ -71,6 +87,7 @@ const props = withDefaults(defineProps<Props>(), {
tableY: 0,
hallW: 0,
hallH: 0,
mode: 'seat',
})
/**
@@ -95,8 +112,18 @@ const LW = 760
const LH = 900
const M = 26
const STATIC_ID = 'seatCanvas'
const FX_ID = 'seatCanvasFx'
/**
* 🔴 canvas id 必须**每个组件实例唯一**(迭代-2026-10-09)。
*
* 微信小程序的 canvas id 是**页面级唯一**的:`select('#seatCanvasFx')` 在同一页面里
* 只会返回**第一个**匹配节点。双 Tab 若同时挂两个 SeatMap(或同页出现两个实例),
* 第二个实例会拿到第一个实例的 canvas 节点 → 画到别人画布上 / 取到空节点。
* 用「实例 uid + 静态后缀」组合,两层 canvas 在同页内也不会撞。
*/
let uidSeq = 0
const uid = `sm${++uidSeq}_${Date.now().toString(36)}`
const STATIC_ID = `${uid}_seatCanvas`
const FX_ID = `${uid}_seatCanvasFx`
/** 容器高度(按测到的宽度等比换算,避免变形) */
const boxH = ref('440px')
@@ -120,8 +147,13 @@ interface SeatDraw {
}
interface Layout {
/** `hall` = 有真实坐标(画会场定位图);`fallback` = 无坐标(现有居中示意) */
mode: 'hall' | 'fallback'
/**
* 展示模式:
* - `hall` = 会场俯视(Tab「桌子位置」):画会场边界 + 舞台 + 我的桌子高亮落点,**不画座位圈**;
* - `seat` = 座位圈(Tab「座位位置」):画圆桌 + N 个座位 + 「你」高亮,**不画会场边界**;
* - `hybrid` = 无坐标时的降级(旧观感:居中圆桌 + 座位圈)。
*/
mode: 'hall' | 'seat' | 'hybrid'
cx: number
cy: number
ringR: number
@@ -131,7 +163,7 @@ interface Layout {
seats: SeatDraw[]
selfPos: { x: number; y: number } | null
fxBox: { x: number; y: number; w: number; h: number } | null
/** hall 模式下映射后的「会场矩形」(fallback 为 null) */
/** hall 模式下映射后的「会场矩形」(其余为 null) */
hallBox: { x: number; y: number; w: number; h: number } | null
}
@@ -562,7 +594,7 @@ function computeLayout(): Layout {
const ty = Number.isFinite(props.tableY) ? props.tableY : 0
const effHallW = props.hallW > 0 ? props.hallW : HALL_LOGIC_W
const effHallH = props.hallH > 0 ? props.hallH : HALL_LOGIC_H
// 🔴 降级判据:坐标缺失(0 / NaN)→ fallback。绝不把桌子画到 (0,0),也绝不因缺字段而崩。
// 🔴 降级判据:坐标缺失(0 / NaN)→ 无真实落点。绝不把桌子画到 (0,0),也绝不因缺字段而崩。
const hasRealPos = tx > 0 && ty > 0
let cx: number
@@ -570,8 +602,15 @@ function computeLayout(): Layout {
let ringR: number
let hallBox: Layout['hallBox'] = null
if (hasRealPos) {
// 会场定位:把 hall_w×hall_h 等比(contain)映射进「扣除舞台横幅后的绘制区」,中心对齐
// 🔴 展示模式解析(迭代-2026-10-09 双 Tab):
// props.mode='hall' 且**有真实坐标** → 会场俯视(桌子位置)
// 其余('seat',或 'hall' 但坐标缺失)→ 座位圈(座位位置 / 降级观感)
// 'hall' 但无坐标时不能画会场:没有落点就画不出位置,强行用 (0,0) 等于骗人,故退回座位圈。
const wantHall = props.mode === 'hall' && hasRealPos
const mode: Layout['mode'] = wantHall ? 'hall' : 'seat'
if (wantHall) {
// 会场俯视:把 hall_w×hall_h 等比(contain)映射进「扣除舞台横幅后的绘制区」,中心对齐
const pad = 16
const areaW = Math.max(LW - 2 * (M + pad), 80)
const areaH2 = Math.max(areaH - 2 * pad, 80)
@@ -582,17 +621,16 @@ function computeLayout(): Layout {
const oy = areaTop + pad + (areaH2 - mh) / 2
hallBox = { x: ox, y: oy, w: mw, h: mh }
// 桌子显示半径:随桌人数略增,并保证不超出会场矩形
ringR = clamp(48 + N * 0.85, 54, 92)
ringR = Math.min(ringR, Math.min(mw, mh) * 0.22)
// 桌子显示半径:hall 模式下**不画座位圈**,所以可以画得更大更醒目
ringR = clamp(Math.min(mw, mh) * 0.16, 54, 118)
// 桌子(含光晕/标注余量)整体夹在会场矩形内,避免越界或压到舞台横幅
const margin = ringR + 30
const margin = ringR + 34
cx = clampSafe(ox + tx * s, ox + margin, ox + mw - margin)
cy = clampSafe(oy + ty * s, oy + margin, oy + mh - margin)
}
else {
// 现有示意:本人桌居中
// 座位圈模式:本人桌居中,桌子尽可能大(座位数越多环越大)
cx = LW / 2
cy = areaTop + areaH / 2
const availR = Math.max(Math.min(LW / 2 - M, areaH / 2) - 6, 90)
@@ -602,7 +640,10 @@ function computeLayout(): Layout {
const sofaW = clamp(((2 * Math.PI * ringR) / N) * 0.8, 15, 56)
const sofaH = clamp(sofaW * 0.72, 12, 42)
const tableR = Math.max(ringR - sofaH - 12, ringR * 0.42)
// hall 模式下画的是「整张桌子」(无座位环),桌面要撑满 ringR;座位模式下要留出座位环宽度
const tableR = mode === 'hall'
? ringR
: Math.max(ringR - sofaH - 12, ringR * 0.42)
const cellMap = new Map<number, SeatCell>()
for (const c of props.seats || [])
@@ -637,7 +678,7 @@ function computeLayout(): Layout {
}
}
return { mode: hasRealPos ? 'hall' : 'fallback', cx, cy, ringR, tableR, sofaW, sofaH, seats, selfPos, fxBox, hallBox }
return { mode, cx, cy, ringR, tableR, sofaW, sofaH, seats, selfPos, fxBox, hallBox }
}
// ============================================================================
@@ -651,22 +692,26 @@ function renderStatic() {
prepareCanvas(staticCanvas, ctx)
ctx.clearRect(0, 0, LW, LH)
drawFloor(ctx)
// 会场边界:只在「桌子位置」tab 画(座位圈 tab 不画,免得误导成会场平面)
if (layout.mode === 'hall')
drawHall(ctx)
drawStage(ctx)
if (layout.mode === 'hall')
drawHalo(ctx)
drawTable(ctx)
for (const s of layout.seats)
drawSofa(ctx, s)
// 座位圈:只在「座位位置」tab 画
if (layout.mode === 'seat') {
for (const s of layout.seats)
drawSofa(ctx, s)
drawLegend(ctx)
}
if (layout.mode === 'hall')
drawTableCaption(ctx)
drawLegend(ctx)
}
/** 只重绘「本人座位」那一小块(fxBox)+ 呼吸光晕 + 弹跳徽标 */
function drawFx(ts: number) {
if (!fxCtx || !layout || !layout.selfPos || !layout.fxBox)
if (!fxCtx || !layout || layout.mode !== 'seat' || !layout.selfPos || !layout.fxBox)
return
const ctx = fxCtx
const { x, y } = layout.selfPos
@@ -798,7 +843,8 @@ function cancelFrame() {
/** 开始「本人座位」呼吸/弹跳动画(页面 onShow 时调用) */
function resume() {
if (running || !ready.value || !layout?.selfPos)
// 🔴 只有「座位位置」tab 需要动画(hall 模式不画座位圈,rAF 会空转白烧 CPU)
if (running || !ready.value || layout?.mode !== 'seat' || !layout?.selfPos)
return
running = true
scheduleFrame()
@@ -913,16 +959,21 @@ function measureBox() {
const inst = instance
if (!inst)
return
// 🔴 MP 端必须用 `.fields({size:true}).exec(cb)`,**不能**用
// `boundingClientRect(cb)` 回调式写法(迭代-2026-10-09 定位)。
// 实测产物里是 `createSelectorQuery().in(t).select(".seatmap").boundingClientRect(e=>{...})`,
// 该写法在微信小程序里**回调收到的参数恒为空** → `w=0` → boxH 永远停在默认 440px,
// 桌面端容器过宽时画布被压扁。`.fields().exec()` 拿数组,实测两端都稳。
uni
.createSelectorQuery()
.in(inst)
.select('.seatmap')
.boundingClientRect((rect: any) => {
const w = rect?.width || 0
.fields({ size: true }, () => {})
.exec((res: any[]) => {
const w = res?.[0]?.width || 0
if (w > 0)
boxH.value = `${Math.round((w * LH) / LW)}px`
})
.exec()
}
/** 取节点最终失败 → 展示可重试的失败态(**不再永久停在「加载中」**) */
@@ -995,7 +1046,7 @@ onUnmounted(() => {
})
watch(
() => [props.capacity, props.selfSeatNo, props.tableNo, props.tableName, props.seats, props.stage, props.tableX, props.tableY, props.hallW, props.hallH],
() => [props.capacity, props.selfSeatNo, props.tableNo, props.tableName, props.seats, props.stage, props.tableX, props.tableY, props.hallW, props.hallH, props.mode],
() => {
if (ready.value)
render()

View File

@@ -34,8 +34,36 @@ 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 || '我的座位')
/**
@@ -163,14 +191,34 @@ async function loadInfo() {
<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">
<text class="map__title">本人所在桌示意</text>
<text class="map__hint">★ 高亮处即您所在的桌子与座位</text>
<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"
@@ -291,16 +339,38 @@ async function loadInfo() {
gap: 6rpx;
margin-bottom: 16rpx;
}
.map__title {
font-size: 28rpx;
font-weight: 700;
color: #3d2a1e;
}
.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;