feat(borrow): 全局提醒支持双向 —— 发起方也能收到「转交被拒绝」

提醒组件从单向(待我接收)扩展为双向,一次轮询同时取回两类:

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

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

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

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

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

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

验证(node 复刻判定链)
  既有拒绝、又有 2 件待接收 → 弹[转交被拒绝];确认后 ack;
  下一轮 → 弹[待办通知] count=2;再轮询 → 不弹(未变)。
  拒绝优先、不叠加、ack 后待接收提醒正常补上。
This commit is contained in:
yueli
2026-09-17 10:44:07 +08:00
parent f24797ff1f
commit f4f887c2b4
3 changed files with 105 additions and 30 deletions

View File

@ -309,9 +309,14 @@ export function rejectBorrowTransfer(transferId: number, reason?: string) {
}
/**
* 待我接收的转交数量 —— 全局待办强提醒(初始化 + 轮询)。
* 极轻量,只做一次 count。
* @returns { count }
* 全局待办强提醒的轮询数据源(初始化 + 轮询)。
*
* ★ 一次请求返回两类信息,避免前端多打一个请求:
* count —— 待**我接收**的转交数量
* rejects —— **我发起、被对方拒绝、尚未告知我**的转交
* (被拒时物品责任仍在我手上,不告知就会误以为已交接出去)
*
* @returns { count, rejects: [{ id, borrow_no, sku, material_name, to_user_name, remark, ... }] }
*/
export function getPendingTransferCount() {
return request({
@ -320,6 +325,18 @@ export function getPendingTransferCount() {
})
}
/**
* 确认已知悉「转交被拒绝」—— 写 reject_seen_at之后不再提醒。
* @param ids 留空表示该用户全部待告知的拒绝
*/
export function ackTransferRejects(ids?: number[]) {
return request({
url: '/v1/transactions/borrow/transfer/reject-ack',
method: 'post',
data: { ids }
})
}
/**
* 整单流转时间线(**borrow_no** 维度):借出 → 转交(可多次) → 归还 → 报废
*

View File

@ -4,10 +4,14 @@
<script setup lang="ts">
/**
* 借库转交 · 全局待办强提醒
* 借库转交 · 全局强提醒(双向)
*
* 解决的问题:双向握手引入后,发起方提交了转交,接收人若不去借还记录页主动
* 查看,就完全处于盲区 —— 物品在系统里挂着「待接收」,责任悬空。
* 两类提醒一次轮询同时取回GET .../transfer/pending-count
*
* ① 转交被拒绝 —— **我发起**、对方拒收。物品责任仍在我手上,
* 不告知就会误以为已经交接出去,责任链出现静默断点。
* 用 reject_seen_at 做持久标记,确认后不再提醒。
* ② 待我接收 —— **别人转给我**、等我确认。用 sessionStorage 按数量去重。
*
* ★ 为什么用 ElMessageBox 而不是 ElNotification
* Notification 缩在右上角,在仓库作业现场极易被忽略。改为**中央 Modal +
@ -18,32 +22,23 @@
* 且它是路由切换时**常驻**的组件AppMain 换页不会卸载它),
* 轮询定时器不会被反复创建销毁。
*
* ★ 防骚扰 + 防叠加(三道):
* 1) 会话级去重sessionStorage 记「上次已提醒过的数量」,只有数量**变化**
* 才再弹。用 sessionStorage 而非 Pinia —— 后者刷新即重置,会反复轰炸。
* 2) 模块级标志 reminderOpen同一时刻只允许一个提醒弹窗。
* 3) DOM 探测:按标题找屏幕上是否已有同名弹窗(覆盖标志失效的极端情况,
* 例如 HMR 后旧实例残留的弹窗)。两道都过才弹。
*
* ★ 记录时机很关键:**只有真正弹出后才写 sessionStorage**。
* 若先记录再因并发防护跳过,这条提醒会被永久吞掉(数量没变,下次不再弹)。
*
* ★ 错误一律静默:待办提醒是锦上添花,绝不能因为接口抖动就弹错误框刷屏。
* ★ 防叠加(模块级标志 + DOM 探测):见 isReminderOnScreen。
*/
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { getPendingTransferCount } from '@/api/transaction'
import { getPendingTransferCount, ackTransferRejects } from '@/api/transaction'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const SS_KEY = 'pendingTransferNotifiedCount'
const REMINDER_TITLE = '待办通知:借库转交'
const REMINDER_TITLES = ['待办通知:借库转交', '转交被拒绝']
// 轮询间隔:需求只要求「初始化时查一次」,但那样已打开的页面永远收不到提醒
// SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」;
// 有上面的去重逻辑兜底,轮询不会造成重复打扰。不需要的话删掉定时器即可。
// 有去重逻辑兜底,轮询不会造成重复打扰。不需要的话删掉定时器即可。
const POLL_MS = 2 * 60 * 1000
// ★ 模块级而非组件级即便组件被重复挂载HMR、多 Layout 实例),
@ -52,29 +47,76 @@ 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 => (el.textContent || '').includes(REMINDER_TITLE))
.some(el => REMINDER_TITLES.some(t => (el.textContent || '').includes(t)))
} catch {
return false
}
}
/**
* 弹出提醒。
* @returns 是否**真的弹出了** —— 调用方据此决定要不要记 sessionStorage
* 弹出「转交被拒绝」提醒。
* @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 showReminder = (count: number): boolean => {
if (isReminderOnScreen()) return false // 已有提醒在屏 → 绝不叠加
reminderOpen = true // 同步置位,挡住同一轮内的竞态
const showPendingReminder = (count: number): boolean => {
if (isReminderOnScreen()) return false
reminderOpen = true
ElMessageBox.confirm(
`您有 ${count} 件物品等待接收确认,请及时处理。`,
REMINDER_TITLE,
'待办通知:借库转交',
{
confirmButtonText: '去处理',
cancelButtonText: '稍后处理',
@ -84,7 +126,6 @@ const showReminder = (count: number): boolean => {
closeOnClickModal: false,
closeOnPressEscape: true,
showClose: true,
// 取消与关闭行为一致(都只是收起弹窗),无需区分
distinguishCancelAndClose: false,
}
)
@ -104,13 +145,22 @@ const showReminder = (count: number): boolean => {
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)
@ -121,7 +171,7 @@ const check = async () => {
if (count === notified) return // 数量没变 → 本会话已提醒过,不再轰炸
// ★ 先弹,弹成功了才记账。反过来会把提醒永久吞掉。
if (showReminder(count)) {
if (showPendingReminder(count)) {
sessionStorage.setItem(SS_KEY, String(count))
}
// 没弹出(已有弹窗在屏)时不记账:留给下一轮轮询重试

View File

@ -478,6 +478,14 @@
</template>
<template v-else-if="e.type === 'transfer'">
<b>{{ e.from_name || '—' }}</b> <b>{{ e.actor_name || '—' }}</b>
<!-- 状态标签被拒绝的转交若不标出来与成功的长得一模一样
发起方翻记录时会误以为已经交接出去 -->
<el-tag
v-if="e.status && e.status !== 'ACCEPTED'"
:type="e.status === 'REJECTED' ? 'danger' : 'warning'"
size="small"
style="margin-left:6px"
>{{ e.status_text || e.status }}</el-tag>
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管{{ e.operator_name }}</span>
</template>
<template v-else-if="e.type === 'return'">