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