功能、UI更新
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 张时的原排版) */
|
||||
|
||||
@@ -143,6 +143,12 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "年会查座"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "seat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的座位"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
349
src/pkg-event/seat.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
41
src/utils/event-seat-handoff.ts
Normal file
41
src/utils/event-seat-handoff.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user