From b53e22f53685b1fadf419170d3165bf166c10664 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:40:00 +0800 Subject: [PATCH] =?UTF-8?q?style(borrow):=20=E5=BE=85=E5=8A=9E=E6=8F=90?= =?UTF-8?q?=E9=86=92=E6=94=B9=E4=B8=BA=E4=B8=AD=E5=A4=AE=E5=BC=BA=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=88Modal=20+=20=E9=81=AE=E7=BD=A9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 右上角 ElNotification 在仓库作业现场视觉提示太弱,极易被操作员忽略, 待接收的物品就一直在系统里悬着。 改动 ---- ElNotification -> ElMessageBox.confirm: · 屏幕正中 + 灰色半透明遮罩,强制打断注意力; · 标题「待办通知:借库转交」,内容「您有 X 件物品等待接收确认,请及时处理。」; · 按钮【去处理】/【稍后处理】;前者 router.push('/operation/records'), 后者仅收起弹窗,不阻断当前工作; · closeOnClickModal=false(点遮罩不关,避免误触即消失), 但保留 showClose 与 Esc —— 强提醒不等于关不掉,用户始终有明确退出路径。 ★ 防叠加(两道,模块级标志 + DOM 探测) reminderOpen 用**模块级**变量而非组件级:即便组件被重复挂载 (HMR、多 Layout 实例)也能保证同一时刻只有一个提醒弹窗。 另按标题探测屏幕上是否已有同名弹窗,覆盖标志失效的极端情况。 ★ 一处必须修正的时序隐患:记录时机 原逻辑是「先写 sessionStorage 已提醒数量,再弹窗」。加了并发防护后, 弹窗可能被跳过(已有弹窗在屏),而数量却已记下 —— 数量不变 → 下次不再弹, **这条提醒被永久吞掉**。 现改为「先弹,弹成功了才记账」:showReminder() 返回是否真的弹出, 没弹出就不记账,留给下一轮轮询重试。 验证(node 复刻判定链,全场景通过) 3 → 弹;仍 3 → 不弹;弹窗开着时变 4 → 不弹且不记账 → 收起后轮询 4 → 弹;归零 → 清记录;新来 2 → 弹。 弹窗序列 [3,4,2],无重复、无叠加;同一轮内并发两次 check 只弹一次。 --- .../PendingTransferNotifier/index.vue | 114 ++++++++++++------ 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/inventory-web/src/components/PendingTransferNotifier/index.vue b/inventory-web/src/components/PendingTransferNotifier/index.vue index 24037fb..373fbaa 100644 --- a/inventory-web/src/components/PendingTransferNotifier/index.vue +++ b/inventory-web/src/components/PendingTransferNotifier/index.vue @@ -8,26 +8,31 @@ * * 解决的问题:双向握手引入后,发起方提交了转交,接收人若不去借还记录页主动 * 查看,就完全处于盲区 —— 物品在系统里挂着「待接收」,责任悬空。 - * 本组件在应用启动后主动查询「待我接收」的数量,并用不会自动消失的 - * ElNotification 强提醒,附一键跳转到借还记录页。 + * + * ★ 为什么用 ElMessageBox 而不是 ElNotification: + * Notification 缩在右上角,在仓库作业现场极易被忽略。改为**中央 Modal + + * 遮罩**,强制打断注意力,符合蓝领作业现场的触达要求。 * * ★ 为什么挂在 Layout 而非登录页: * Layout 只在已登录区域渲染,天然保证「有 token 才查」; * 且它是路由切换时**常驻**的组件(AppMain 换页不会卸载它), * 轮询定时器不会被反复创建销毁。 * - * ★ 防骚扰(两道): + * ★ 防骚扰 + 防叠加(三道): * 1) 会话级去重:sessionStorage 记「上次已提醒过的数量」,只有数量**变化** - * 才再弹。刷新页面不会重复轰炸(用 sessionStorage 而非 Pinia —— - * 前者跨刷新存活,后者会重置)。 - * 2) 数量归零时清掉记录,下次再来新转交能重新提醒。 + * 才再弹。用 sessionStorage 而非 Pinia —— 后者刷新即重置,会反复轰炸。 + * 2) 模块级标志 reminderOpen:同一时刻只允许一个提醒弹窗。 + * 3) DOM 探测:按标题找屏幕上是否已有同名弹窗(覆盖标志失效的极端情况, + * 例如 HMR 后旧实例残留的弹窗)。两道都过才弹。 + * + * ★ 记录时机很关键:**只有真正弹出后才写 sessionStorage**。 + * 若先记录再因并发防护跳过,这条提醒会被永久吞掉(数量没变,下次不再弹)。 * * ★ 错误一律静默:待办提醒是锦上添花,绝不能因为接口抖动就弹错误框刷屏。 */ import { onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' -import { h } from 'vue' -import { ElButton, ElNotification } from 'element-plus' +import { ElMessageBox } from 'element-plus' import { getPendingTransferCount } from '@/api/transaction' import { useUserStore } from '@/stores/user' @@ -35,38 +40,65 @@ const router = useRouter() const userStore = useUserStore() const SS_KEY = 'pendingTransferNotifiedCount' +const REMINDER_TITLE = '待办通知:借库转交' // 轮询间隔:需求只要求「初始化时查一次」,但那样已打开的页面永远收不到提醒 -// (SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」, -// 有了上面的去重逻辑,轮询不会造成重复打扰。若不需要,删掉定时器即可。 +// (SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」; +// 有上面的去重逻辑兜底,轮询不会造成重复打扰。不需要的话删掉定时器即可。 const POLL_MS = 2 * 60 * 1000 +// ★ 模块级而非组件级:即便组件被重复挂载(HMR、多 Layout 实例), +// 也能保证同一时刻只有一个提醒弹窗。 +let reminderOpen = false + let timer: ReturnType | null = null -const notify = (count: number) => { - const notification = ElNotification({ - title: '待办通知:借库转交', - type: 'warning', - duration: 0, // 不自动关闭,必须用户手动处理或点掉 - showClose: true, - // 用 VNode 而非 dangerouslyUseHTMLString:避免把数量拼进 HTML 字符串 - message: h('div', [ - h('div', `您有 ${count} 件物品等待接收确认,请及时处理。`), - h( - ElButton, - { - type: 'primary', - link: true, - size: 'small', - style: 'margin-top:6px; padding:0;', - onClick: () => { - notification.close() - router.push('/operation/records') - }, - }, - () => '去处理 >' - ), - ]), - }) +// 按标题探测屏幕上是否已有同一个提醒弹窗 +const isReminderOnScreen = (): boolean => { + if (reminderOpen) return true + try { + return Array.from(document.querySelectorAll('.el-message-box__title')) + .some(el => (el.textContent || '').includes(REMINDER_TITLE)) + } catch { + return false + } +} + +/** + * 弹出强提醒。 + * @returns 是否**真的弹出了** —— 调用方据此决定要不要记 sessionStorage, + * 避免「记了却没弹」把提醒永久吞掉。 + */ +const showReminder = (count: number): boolean => { + if (isReminderOnScreen()) return false // 已有提醒在屏 → 绝不叠加 + reminderOpen = true // 同步置位,挡住同一轮内的竞态 + + ElMessageBox.confirm( + `您有 ${count} 件物品等待接收确认,请及时处理。`, + REMINDER_TITLE, + { + confirmButtonText: '去处理', + cancelButtonText: '稍后处理', + type: 'warning', + // 强提醒:点遮罩不关(避免误触即消失),但保留右上角关闭按钮与 Esc, + // 用户始终有明确的退出路径。 + closeOnClickModal: false, + closeOnPressEscape: true, + showClose: true, + // 取消与关闭行为一致(都只是收起弹窗),无需区分 + distinguishCancelAndClose: false, + } + ) + .then(() => { + router.push('/operation/records') // 【去处理】 + }) + .catch(() => { + // 【稍后处理】/ 关闭:仅收起弹窗,不阻断用户当前工作 + }) + .finally(() => { + reminderOpen = false + }) + + return true } const check = async () => { @@ -88,8 +120,11 @@ const check = async () => { const notified = Number(sessionStorage.getItem(SS_KEY) || 0) if (count === notified) return // 数量没变 → 本会话已提醒过,不再轰炸 - sessionStorage.setItem(SS_KEY, String(count)) - notify(count) + // ★ 先弹,弹成功了才记账。反过来会把提醒永久吞掉。 + if (showReminder(count)) { + sessionStorage.setItem(SS_KEY, String(count)) + } + // 没弹出(已有弹窗在屏)时不记账:留给下一轮轮询重试 } onMounted(() => { @@ -100,5 +135,10 @@ onMounted(() => { onUnmounted(() => { if (timer) clearInterval(timer) timer = null + // 组件卸载时若弹窗还开着,一并收掉,避免遮罩残留挡住界面 + if (reminderOpen) { + try { ElMessageBox.close() } catch { /* 忽略 */ } + reminderOpen = false + } })