diff --git a/src/api/event.ts b/src/api/event.ts index f0c291e..afb0630 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -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('/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('/event/seat/query', { event_id: eventId, name, phone }) + return post('/event/seat/query', { event_id: eventId, name, phone }, { silent: true }) } /** diff --git a/src/components/card/CardTile.vue b/src/components/card/CardTile.vue index a3d112f..ce5d0ee 100644 --- a/src/components/card/CardTile.vue +++ b/src/components/card/CardTile.vue @@ -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%; } diff --git a/src/components/event/SeatMap.vue b/src/components/event/SeatMap.vue index 6a71417..e23657f 100644 --- a/src/components/event/SeatMap.vue +++ b/src/components/event/SeatMap.vue @@ -73,6 +73,21 @@ const props = withDefaults(defineProps(), { 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 { // #ifdef MP-WEIXIN - const inst = getCurrentInstance()?.proxy + // 用 setup 顶层捕获的 instance,不用 getCurrentInstance() 现取(见 instance 处的注释) + const inst = instance + if (!inst) + return false const queryNode = (selector: string): Promise => 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 }) - + + 座位图加载中… + + + 座位图暂时无法显示 + 桌号与座位号见上方,不影响查看 + + {{ retrying ? '加载中…' : '重新加载' }} + + @@ -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; +} diff --git a/src/components/home/PaneLibrary.vue b/src/components/home/PaneLibrary.vue index 9bde218..b9466fc 100644 --- a/src/components/home/PaneLibrary.vue +++ b/src/components/home/PaneLibrary.vue @@ -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)); diff --git a/src/components/home/PaneWorkbench.vue b/src/components/home/PaneWorkbench.vue index 2713489..8eb633b 100644 --- a/src/components/home/PaneWorkbench.vue +++ b/src/components/home/PaneWorkbench.vue @@ -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 张时的原排版) */ diff --git a/src/pages.json b/src/pages.json index dfb607e..3b20be6 100644 --- a/src/pages.json +++ b/src/pages.json @@ -143,6 +143,12 @@ "style": { "navigationBarTitleText": "年会查座" } + }, + { + "path": "seat", + "style": { + "navigationBarTitleText": "我的座位" + } } ] }, diff --git a/src/pkg-event/query.vue b/src/pkg-event/query.vue index d8dd2ff..30b834f 100644 --- a/src/pkg-event/query.vue +++ b/src/pkg-event/query.vue @@ -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(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 | undefined) if (!eventId.value) { @@ -223,18 +246,6 @@ onLoad((options) => { } loadInfo() }) - -onShow(() => { - seatMapRef.value?.resume() -}) - -onHide(() => { - seatMapRef.value?.pause() -}) - -onUnload(() => { - seatMapRef.value?.pause() -}) @@ -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; +} diff --git a/src/utils/event-seat-handoff.ts b/src/utils/event-seat-handoff.ts new file mode 100644 index 0000000..b891542 --- /dev/null +++ b/src/utils/event-seat-handoff.ts @@ -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 +}