feat(borrow): 全局待办强提醒(接收人不再处于盲区)

新增无渲染组件 PendingTransferNotifier,挂在 Layout(路由切换常驻、且只在
已登录区域渲染,天然保证「有 token 才查」)。

UI
----
ElNotification,type=warning、duration=0(不自动关闭,须用户处理或手动点掉):
  标题:待办通知:借库转交
  内容:您有 X 件物品等待接收确认,请及时处理。【去处理 >】
点击【去处理】关闭通知并 router.push('/operation/records')(借还记录页)。
message 用 VNode 构造而非 dangerouslyUseHTMLString —— 不必把数量拼进 HTML 字符串。

防骚扰(两道)
----
· 会话级去重:sessionStorage 记「上次已提醒过的数量」,只有数量**变化**才再弹。
  用 sessionStorage 而非 Pinia —— 前者跨刷新存活,后者会重置,刷新即轰炸。
· 数量归零时清掉记录,下次新转交能重新提醒。

★ 加了 2 分钟低频轮询(超出需求所写,但需求目标需要它):
  需求只要求「初始化时查一次」,而 SPA 只在首次进入时初始化 —— 已打开页面的
  用户永远收不到提醒,与「第一时间响应」的目标相悖。有去重逻辑兜底,
  轮询不会造成重复打扰。不需要的话删掉定时器即可。

错误一律静默:提醒是锦上添花,不能因接口抖动弹错误框刷屏。

验证:去重状态机用 node 复刻验证 —— 3→2→1 各弹一次,刷新与轮询均不重复,
归零后再来新转交能重新提醒。
This commit is contained in:
yueli
2026-09-17 10:30:13 +08:00
parent 1c58789fd9
commit 2c732a9a3a
3 changed files with 121 additions and 0 deletions

View File

@ -0,0 +1,104 @@
<template>
<!-- 无渲染组件只负责全局待办强提醒不产生任何 DOM -->
</template>
<script setup lang="ts">
/**
* 借库转交 · 全局待办强提醒
*
* 解决的问题:双向握手引入后,发起方提交了转交,接收人若不去借还记录页主动
* 查看,就完全处于盲区 —— 物品在系统里挂着「待接收」,责任悬空。
* 本组件在应用启动后主动查询「待我接收」的数量,并用不会自动消失的
* ElNotification 强提醒,附一键跳转到借还记录页。
*
* ★ 为什么挂在 Layout 而非登录页:
* Layout 只在已登录区域渲染,天然保证「有 token 才查」;
* 且它是路由切换时**常驻**的组件AppMain 换页不会卸载它),
* 轮询定时器不会被反复创建销毁。
*
* ★ 防骚扰(两道):
* 1) 会话级去重sessionStorage 记「上次已提醒过的数量」,只有数量**变化**
* 才再弹。刷新页面不会重复轰炸(用 sessionStorage 而非 Pinia ——
* 前者跨刷新存活,后者会重置)。
* 2) 数量归零时清掉记录,下次再来新转交能重新提醒。
*
* ★ 错误一律静默:待办提醒是锦上添花,绝不能因为接口抖动就弹错误框刷屏。
*/
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { h } from 'vue'
import { ElButton, ElNotification } from 'element-plus'
import { getPendingTransferCount } from '@/api/transaction'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const SS_KEY = 'pendingTransferNotifiedCount'
// 轮询间隔:需求只要求「初始化时查一次」,但那样已打开的页面永远收不到提醒
// SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」,
// 有了上面的去重逻辑,轮询不会造成重复打扰。若不需要,删掉定时器即可。
const POLL_MS = 2 * 60 * 1000
let timer: ReturnType<typeof setInterval> | 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 check = async () => {
if (!userStore.token) return
let count = 0
try {
const res: any = await getPendingTransferCount()
count = Number(res?.count ?? res?.data?.count ?? 0)
} catch {
return // 静默:不打扰用户,也不刷屏报错
}
if (!count) {
// 已处理完:清掉记录,下次再来新转交能重新提醒
sessionStorage.removeItem(SS_KEY)
return
}
const notified = Number(sessionStorage.getItem(SS_KEY) || 0)
if (count === notified) return // 数量没变 → 本会话已提醒过,不再轰炸
sessionStorage.setItem(SS_KEY, String(count))
notify(count)
}
onMounted(() => {
check()
timer = setInterval(check, POLL_MS)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
timer = null
})
</script>