Files
KCGL/inventory-web/src/components/PendingTransferNotifier/index.vue
yueli f4f887c2b4 feat(borrow): 全局提醒支持双向 —— 发起方也能收到「转交被拒绝」
提醒组件从单向(待我接收)扩展为双向,一次轮询同时取回两类:

  ① 转交被拒绝 —— 我发起、对方拒收,物品责任仍在我手上
  ② 待我接收   —— 别人转给我、等我确认

弹窗(沿用中央 Modal + 遮罩,不进则已、进则打断):
  标题「转交被拒绝」,列出被拒物品(物料名 + 单号 + 接收人,超过 5 笔折叠计数),
  按钮【去处理】(跳借还记录) /【知道了】。

★ 优先级:拒绝提醒优先于待接收提醒,且**一次只弹一个弹窗**。
  前者是「责任已回到你手上」的状态变更,后者是「等你确认」的待办;
  本次弹了拒绝就直接 return,待接收那条留给下一轮(此时拒绝已 ack),
  避免两个 Modal 叠加打扰。

★ 两条退出路径都算「已知悉」并 ack —— 否则每次登录都会再弹同一条,
  从提醒退化成骚扰。ack 失败不阻断,下一轮还会再提醒(宁可多提醒一次,
  也不能漏)。

防叠加沿用上一轮的模块标志 + DOM 探测,标题白名单扩为两个。

顺带:流转明细时间线为转交节点补状态标签(已拒绝/待接收),被拒的转交
不再与成功的长得一模一样。

验证(node 复刻判定链)
  既有拒绝、又有 2 件待接收 → 弹[转交被拒绝];确认后 ack;
  下一轮 → 弹[待办通知] count=2;再轮询 → 不弹(未变)。
  拒绝优先、不叠加、ack 后待接收提醒正常补上。
2026-09-17 10:44:07 +08:00

195 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 无渲染组件只负责全局待办强提醒不产生任何 DOM -->
</template>
<script setup lang="ts">
/**
* 借库转交 · 全局强提醒(双向)
*
* 两类提醒一次轮询同时取回GET .../transfer/pending-count
*
* ① 转交被拒绝 —— **我发起**、对方拒收。物品责任仍在我手上,
* 不告知就会误以为已经交接出去,责任链出现静默断点。
* 用 reject_seen_at 做持久标记,确认后不再提醒。
* ② 待我接收 —— **别人转给我**、等我确认。用 sessionStorage 按数量去重。
*
* ★ 为什么用 ElMessageBox 而不是 ElNotification
* Notification 缩在右上角,在仓库作业现场极易被忽略。改为**中央 Modal +
* 遮罩**,强制打断注意力,符合蓝领作业现场的触达要求。
*
* ★ 为什么挂在 Layout 而非登录页:
* Layout 只在已登录区域渲染,天然保证「有 token 才查」;
* 且它是路由切换时**常驻**的组件AppMain 换页不会卸载它),
* 轮询定时器不会被反复创建销毁。
*
* ★ 防叠加(模块级标志 + DOM 探测):见 isReminderOnScreen。
*/
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { getPendingTransferCount, ackTransferRejects } from '@/api/transaction'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const SS_KEY = 'pendingTransferNotifiedCount'
const REMINDER_TITLES = ['待办通知:借库转交', '转交被拒绝']
// 轮询间隔:需求只要求「初始化时查一次」,但那样已打开的页面永远收不到提醒
// SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」;
// 有去重逻辑兜底,轮询不会造成重复打扰。不需要的话删掉定时器即可。
const POLL_MS = 2 * 60 * 1000
// ★ 模块级而非组件级即便组件被重复挂载HMR、多 Layout 实例),
// 也能保证同一时刻只有一个提醒弹窗。
let reminderOpen = false
let timer: ReturnType<typeof setInterval> | null = null
// 按标题探测屏幕上是否已有本组件的弹窗(覆盖模块标志失效的极端情况,
// 例如 HMR 后旧实例残留的弹窗)
const isReminderOnScreen = (): boolean => {
if (reminderOpen) return true
try {
return Array.from(document.querySelectorAll('.el-message-box__title'))
.some(el => REMINDER_TITLES.some(t => (el.textContent || '').includes(t)))
} catch {
return false
}
}
/**
* 弹出「转交被拒绝」提醒。
* @returns 是否真的弹出了
*/
const showRejectNotice = (rejects: any[]): boolean => {
if (isReminderOnScreen()) return false
reminderOpen = true
const shown = rejects.slice(0, 5)
const lines = shown.map(r =>
`· ${r.material_name || r.sku || '物品'}${r.borrow_no})—— 接收人:${r.to_user_name || '—'}`
)
const more = rejects.length > shown.length ? `\n…另有 ${rejects.length - shown.length}` : ''
ElMessageBox.confirm(
`以下转交已被对方拒绝,物品仍在您名下,请另行安排:\n\n${lines.join('\n')}${more}`,
'转交被拒绝',
{
confirmButtonText: '去处理',
cancelButtonText: '知道了',
type: 'warning',
closeOnClickModal: false,
closeOnPressEscape: true,
showClose: true,
}
)
.then(() => {
router.push('/operation/records')
})
.catch(() => {
// 【知道了】/ 关闭:仅收起弹窗
})
.finally(async () => {
reminderOpen = false
// ★ 两条路径都算「已知悉」:否则每次登录都会再弹同一条。
// 若 ack 失败,下一轮轮询还会再提醒 —— 宁可多提醒一次,也不能漏。
try {
await ackTransferRejects(rejects.map((r: any) => r.id))
} catch {
// 静默
}
})
return true
}
/**
* 弹出「待我接收」提醒。
* @returns 是否真的弹出了 —— 调用方据此决定要不要记 sessionStorage
* 避免「记了却没弹」把提醒永久吞掉。
*/
const showPendingReminder = (count: number): boolean => {
if (isReminderOnScreen()) return false
reminderOpen = true
ElMessageBox.confirm(
`您有 ${count} 件物品等待接收确认,请及时处理。`,
'待办通知:借库转交',
{
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 () => {
if (!userStore.token) return
let count = 0
let rejects: any[] = []
try {
const res: any = await getPendingTransferCount()
count = Number(res?.count ?? res?.data?.count ?? 0)
rejects = Array.isArray(res?.rejects)
? res.rejects
: (Array.isArray(res?.data?.rejects) ? res.data.rejects : [])
} catch {
return // 静默:不打扰用户,也不刷屏报错
}
// ★ 拒绝提醒优先于待接收提醒:前者是「责任已回到你手上」的状态变更,
// 后者是「等你确认」的待办;而且一次只弹一个弹窗,避免叠加打扰。
// 本次弹了拒绝提醒就直接返回,待接收的那条留给下一轮(此时拒绝已 ack
if (rejects.length && showRejectNotice(rejects)) return
if (!count) {
// 已处理完:清掉记录,下次再来新转交能重新提醒
sessionStorage.removeItem(SS_KEY)
return
}
const notified = Number(sessionStorage.getItem(SS_KEY) || 0)
if (count === notified) return // 数量没变 → 本会话已提醒过,不再轰炸
// ★ 先弹,弹成功了才记账。反过来会把提醒永久吞掉。
if (showPendingReminder(count)) {
sessionStorage.setItem(SS_KEY, String(count))
}
// 没弹出(已有弹窗在屏)时不记账:留给下一轮轮询重试
}
onMounted(() => {
check()
timer = setInterval(check, POLL_MS)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
timer = null
// 组件卸载时若弹窗还开着,一并收掉,避免遮罩残留挡住界面
if (reminderOpen) {
try { ElMessageBox.close() } catch { /* 忽略 */ }
reminderOpen = false
}
})
</script>