吴树波 пре 1 дан
родитељ
комит
a4e00d50f6

+ 121 - 4
src/api/aiSipCall/softPhone.js

@@ -2,9 +2,14 @@
 // 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
 
 import * as JsSIP from 'jssip';
+import incomingRingAudio from '@/assets/来电铃声.mp3';
 
-// ========== 回铃音 URL(使用本地音频文件) ==========
+// ========== 回铃音 / 来电铃声 URL ==========
 export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
+export const INCOMING_RING_AUDIO_URL = incomingRingAudio;
+
+/** 当前活跃的 WebPhone 实例,供控制台测试使用 */
+let activeWebPhoneInstance = null;
 
 // ========== 默认配置常量(IPCC 与 JsSIP 严格分离) ==========
 
@@ -301,9 +306,13 @@ export class WebPhone {
     this.reconnectTimerId = null;
     this._isHandlingDisconnect = false;
 
-    // 使用 Base64 回铃音
+    // 外呼回铃音
     this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL);
     this.ringbackMedia.loop = true;
+    // 来电铃声
+    this.incomingRingMedia = new Audio(INCOMING_RING_AUDIO_URL);
+    this.incomingRingMedia.loop = true;
+    this._simulatedIncoming = false;
     this.remoteMedia = new Audio();
     this.localMedia = new Audio();
     this.peerConnection = null;
@@ -462,6 +471,7 @@ export class WebPhone {
   }
 
   Start(reconnect, isReconnect = false) {
+    activeWebPhoneInstance = this;
     console.log(`[SIP] 启动 ${reconnect ? '启用重连' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
     this.reconnectEnabled = reconnect;
     if (this.ua) {
@@ -692,6 +702,9 @@ export class WebPhone {
   SetSpeaker(paused, volume) {
     this.remoteMedia.volume = paused ? 0 : volume;
     this.ringbackMedia.volume = paused ? 0 : volume;
+    if (this.incomingRingMedia) {
+      this.incomingRingMedia.volume = paused ? 0 : volume;
+    }
   }
 
   SetMicPhone(paused, volume) {
@@ -788,6 +801,54 @@ export class WebPhone {
     }
   }
 
+  pauseIncomingRing() {
+    if (this.incomingRingMedia && !this.incomingRingMedia.paused) {
+      this.incomingRingMedia.pause();
+      console.log('[音频] 暂停来电铃声');
+    }
+  }
+
+  playIncomingRing() {
+    if (this.incomingRingMedia && this.incomingRingMedia.paused) {
+      this.incomingRingMedia.currentTime = 0;
+      this.incomingRingMedia.play().catch(e => console.warn('[音频] 来电铃声播放失败:', e));
+      console.log('[音频] 播放来电铃声');
+    }
+  }
+
+  /**
+   * 控制台模拟来电(触发 UI 事件 + 播放铃声)
+   * @param {string} caller 模拟来电号码
+   */
+  simulateIncomingCall(caller = '13800138000') {
+    this._simulatedIncoming = true;
+    activeWebPhoneInstance = this;
+    this.emit('OnSessionCreated', {
+      outgoing: false,
+      callee: caller,
+      province: '测试',
+      city: ''
+    });
+    this.emit('OnRing', {
+      outgoing: false,
+      caller,
+      province: '测试',
+      city: ''
+    });
+    this.playIncomingRing();
+    console.log(`[测试] 模拟来电: ${caller},执行 __testSoftPhoneStopIncomingCall() 停止`);
+  }
+
+  /** 停止控制台模拟来电 */
+  stopSimulatedIncomingCall() {
+    this.pauseIncomingRing();
+    if (this._simulatedIncoming) {
+      this._simulatedIncoming = false;
+      this.emit('OnSessionClosed', { succeeded: false, reason: 'test_rejected' });
+      console.log('[测试] 已停止模拟来电');
+    }
+  }
+
   newRTCSession(event) {
     console.log('[通话] 新会话创建');
     if (this.session) { event.session.terminate({ status_code: 486 }); return; }
@@ -806,6 +867,8 @@ export class WebPhone {
     });
     this.session.on('confirmed', () => {
       this.pauseRingback();
+      this.pauseIncomingRing();
+      this._simulatedIncoming = false;
       this.emit('OnAnswered', sessionRef.direction === 'outgoing');
       this.startCallTimer();
     });
@@ -832,7 +895,11 @@ export class WebPhone {
       province: event.request?.getHeader('X-Province') || '',
       city: event.request?.getHeader('X-City') || ''
     });
-    if (outgoing) this.playRingback();
+    if (outgoing) {
+      this.playRingback();
+    } else {
+      this.playIncomingRing();
+    }
   }
 
   newMessage(event) {
@@ -909,6 +976,8 @@ export class WebPhone {
     this.session = null;
     if (this.callTimerId) clearInterval(this.callTimerId);
     this.pauseRingback();
+    this.pauseIncomingRing();
+    this._simulatedIncoming = false;
 
     // 清理远程媒体
     if (this.remoteMedia.srcObject) {
@@ -972,11 +1041,16 @@ export class WebPhone {
       } catch (e) {}
     };
     cleanupAudio(this.ringbackMedia);
+    cleanupAudio(this.incomingRingMedia);
     cleanupAudio(this.remoteMedia);
     cleanupAudio(this.localMedia);
     this.ringbackMedia = null;
+    this.incomingRingMedia = null;
     this.remoteMedia = null;
     this.localMedia = null;
+    if (activeWebPhoneInstance === this) {
+      activeWebPhoneInstance = null;
+    }
     if (this.audioCtx) {
       try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch (e) {}
       this.audioCtx = null;
@@ -994,4 +1068,47 @@ export class WebPhone {
   }
 }
 
-export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS };
+/** 获取当前活跃的 WebPhone 实例 */
+export function getActiveWebPhone() {
+  return activeWebPhoneInstance;
+}
+
+// 控制台测试方法(在浏览器 DevTools Console 中执行)
+if (typeof window !== 'undefined') {
+  window.__testSoftPhoneIncomingCall = function(caller = '13800138000') {
+    const phone = getActiveWebPhone();
+    if (!phone) {
+      console.warn('[测试] 软电话未初始化,请先打开页面并等待软电话连接成功');
+      return;
+    }
+    phone.simulateIncomingCall(caller);
+  };
+  window.__testSoftPhoneStopIncomingCall = function() {
+    const phone = getActiveWebPhone();
+    if (!phone) {
+      console.warn('[测试] 软电话未初始化');
+      return;
+    }
+    phone.stopSimulatedIncomingCall();
+  };
+  window.__testSoftPhonePlayRing = function() {
+    const phone = getActiveWebPhone();
+    if (!phone) {
+      console.warn('[测试] 软电话未初始化');
+      return;
+    }
+    phone.playIncomingRing();
+    console.log('[测试] 仅播放铃声,执行 __testSoftPhoneStopIncomingCall() 停止');
+  };
+}
+
+export default {
+  WebPhone,
+  ProfileManager,
+  checkMicrophonePermission,
+  RINGBACK_AUDIO_URL,
+  INCOMING_RING_AUDIO_URL,
+  getActiveWebPhone,
+  IPCC_DEFAULTS,
+  JS_SIP_DEFAULTS
+};

+ 228 - 57
src/components/FloatingSoftPhone/index.vue

@@ -1,16 +1,28 @@
 <template>
   <div class="floating-softphone">
-    <!-- 来电提示气泡 -->
+    <!-- 来电醒目提示(右下角固定,高于面板层级) -->
     <transition name="call-bubble-fade">
-      <div class="incoming-call-bubble"
-           v-if="callStatus === 'ringing' && incomingCaller"
-           :class="{ 'bubble-left': fabOnLeftEdge }"
-           :style="bubbleStyle"
-           @click="onFabClick">
-        <div class="bubble-content">
-          <i class="material-icons bubble-icon">phone_in_talk</i>
-          <span class="bubble-number">{{ maskNumber(incomingCaller) }}</span>
-          <span class="bubble-label">来电</span>
+      <div class="incoming-call-alert"
+           v-if="isIncomingRinging"
+           @click="onIncomingAlertClick">
+        <div class="incoming-call-alert__ripple"></div>
+        <div class="incoming-call-alert__content">
+          <div class="incoming-call-alert__icon-wrap">
+            <i class="material-icons">phone_in_talk</i>
+          </div>
+          <div class="incoming-call-alert__info">
+            <span class="incoming-call-alert__title">来电响铃中</span>
+            <span class="incoming-call-alert__number">{{ incomingCallDisplayNumber }}</span>
+            <span class="incoming-call-alert__hint">点击卡片打开软电话</span>
+          </div>
+          <div class="incoming-call-alert__actions">
+            <button class="incoming-call-alert__btn answer" title="接听" @click.stop="answerIncomingCall">
+              <i class="material-icons">call</i>
+            </button>
+            <button class="incoming-call-alert__btn reject" title="拒接" @click.stop="rejectIncomingCall">
+              <i class="material-icons">call_end</i>
+            </button>
+          </div>
         </div>
       </div>
     </transition>
@@ -27,7 +39,7 @@
          :style="fabStyle"
          @mousedown="onFabDragStart"
          @click="onFabClick">
-      <i class="material-icons">{{ panelVisible ? 'close' : 'phone' }}</i>
+      <i class="material-icons">{{ fabIconName }}</i>
       <span class="fab-badge" v-if="callStatus !== 'idle'"></span>
     </div>
 
@@ -285,6 +297,11 @@ import {
   getConnectedSharedCCPhoneBar,
   incrementSharedCCPhoneBarRef
 } from '@/utils/ccPhoneBarShared';
+import {
+  startIncomingCallAttention,
+  stopIncomingCallAttention,
+  requestIncomingCallNotificationPermission
+} from '@/utils/incomingCallAttention';
 
 // IPCC 和 JsSIP 默认配置已从 softPhone.js 统一导入,此处仅作别名引用
 const IPCC_CONFIG = IPCC_DEFAULTS;
@@ -302,8 +319,10 @@ const UI_STATE = {
 };
 
 const FAB_SIZE = 56;
+const FAB_RING_SIZE = 64;
 const FAB_EDGE_OFFSET = 8;
 const FAB_MIN_VISIBLE = 28;
+const FAB_SAFE_MARGIN = 16;
 
 export default {
   name: 'FloatingSoftPhone',
@@ -491,21 +510,26 @@ export default {
         zIndex: 9998
       };
     },
-    bubbleStyle() {
-      if (this.fabX === null) {
-        return { position: 'fixed', bottom: '88px', right: '24px', zIndex: 9997 };
+    /** 来电提示显示的号码 */
+    incomingCallDisplayNumber() {
+      if (this.incomingCaller) {
+        return this.maskNumber(this.incomingCaller);
       }
-      // 气泡在FAB上方
-      return {
-        position: 'fixed',
-        left: this.fabX + 'px',
-        top: (this.fabY - 48) + 'px',
-        zIndex: 9997
-      };
+      if (this.displayText) {
+        return this.displayText;
+      }
+      return '未知号码';
     },
     hangupButtonIcon() {
       return this.callStatus !== 'idle' ? 'call_end' : 'phone';
     },
+    /** 来电振铃时不用 close 图标,避免与面板关闭态混淆且吸边时易被裁切 */
+    fabIconName() {
+      if (this.isIncomingRinging) {
+        return 'phone_in_talk';
+      }
+      return this.panelVisible ? 'close' : 'phone';
+    },
     fabOnLeftEdge() {
       if (this.fabEdge === 'left') return true;
       if (this.fabEdge === 'right') return false;
@@ -556,6 +580,19 @@ export default {
       if (val === 'ringing' || val === 'talking') {
         this.panelVisible = true;
         this.fabIsCollapsed = false;  // 来电/通话时自动展开FAB
+        this.$nextTick(() => this.ensureFabFullyVisible());
+      }
+    },
+    isIncomingRinging(val) {
+      if (val) {
+        this.fabIsCollapsed = false;
+        this.$nextTick(() => this.ensureFabFullyVisible());
+        startIncomingCallAttention({
+          caller: this.incomingCallDisplayNumber,
+          body: `来电号码:${this.incomingCallDisplayNumber},请尽快接听`
+        });
+      } else {
+        stopIncomingCallAttention();
       }
     },
     '$route.path'(newPath, oldPath) {
@@ -617,6 +654,7 @@ export default {
     this.$root.$on('cc-phonebar-reconnect-requested', this.onCCPhoneBarReconnectRequested);
   },
   beforeDestroy() {
+    stopIncomingCallAttention();
     this.destroyAllConnections();
     window.removeEventListener('beforeunload', this.handleBeforeUnload);
     if (this._boundViewportResize) {
@@ -648,6 +686,24 @@ export default {
         height: vv ? vv.height : window.innerHeight
       };
     },
+    getFabSize() {
+      return this.callStatus === UI_STATE.RINGING ? FAB_RING_SIZE : FAB_SIZE;
+    },
+    ensureFabFullyVisible() {
+      const fabSize = this.getFabSize();
+      const { width, height } = this.getViewportSize();
+      if (this.fabX === null) {
+        this.fabX = width - fabSize - FAB_SAFE_MARGIN;
+        this.fabY = Math.max(0, Math.min(
+          this.fabYRatio != null ? this.fabYRatio * height : height * 0.85,
+          height - fabSize
+        ));
+        return;
+      }
+      this.fabX = Math.max(FAB_SAFE_MARGIN, Math.min(this.fabX, width - fabSize - FAB_SAFE_MARGIN));
+      this.fabY = Math.max(0, Math.min(this.fabY, height - fabSize));
+      this.syncFabRelativeFromAbsolute();
+    },
     applyFabFromRelative(edge, yRatio) {
       const { width, height } = this.getViewportSize();
       this.fabEdge = edge === 'left' ? 'left' : 'right';
@@ -671,10 +727,11 @@ export default {
         this.applyFabFromRelative(edge, yRatio);
         return;
       }
-      const minX = -(FAB_SIZE - FAB_MIN_VISIBLE);
-      const maxX = width - FAB_MIN_VISIBLE;
+      const fabSize = this.getFabSize();
+      const minX = FAB_SAFE_MARGIN;
+      const maxX = width - fabSize - FAB_SAFE_MARGIN;
       this.fabX = Math.max(minX, Math.min(this.fabX, maxX));
-      this.fabY = Math.max(0, Math.min(this.fabY, height - FAB_SIZE));
+      this.fabY = Math.max(0, Math.min(this.fabY, height - fabSize));
       this.syncFabRelativeFromAbsolute();
     },
 
@@ -730,7 +787,23 @@ export default {
       // 面板打开时不折叠,避免影响操作
       this.fabIsCollapsed = !this.panelVisible;
     },
+    onIncomingAlertClick() {
+      this.panelVisible = true;
+      this.fabIsCollapsed = false;
+    },
+    answerIncomingCall() {
+      if (this.phone) {
+        this.phone.Answer();
+      }
+      this.panelVisible = true;
+      this.fabIsCollapsed = false;
+    },
+    rejectIncomingCall() {
+      this.endCall();
+    },
     onFabClick(e) {
+      // 用户点击软电话时尝试申请桌面通知权限
+      requestIncomingCallNotificationPermission();
       // 如果拖拽过程中产生了位移,不触发点击
       if (this.fabDragMoved) {
         this.fabDragMoved = false;
@@ -1514,9 +1587,10 @@ export default {
       this.incomingCaller = '';
       // 注意:不在_resetCallState中重置pendingManualNavigation,它由onSessionClosed消费
     },
-    onSessionClosed() {
+    onSessionClosed(event) {
       // 转人工来电(incomingJsipCall)或主动设置的导航标记,挂断后都跳转
-      const shouldNavigate = this.incomingJsipCall || this.pendingManualNavigation;
+      const isTestRejected = event && event.reason === 'test_rejected';
+      const shouldNavigate = !isTestRejected && (this.incomingJsipCall || this.pendingManualNavigation);
       this.showStatus('已挂机', 'info');
       this._resetCallState();
       // 转人工来电挂断后,跳转到转人工通话列表页面
@@ -1538,8 +1612,14 @@ export default {
       this.rightButtonHangup = incoming;
       this.delegatedCallActive = true;
       this.showStatus(incoming ? '来电振铃中...' : '振铃中...', 'info');
+      if (incoming && this.phone) {
+        this.phone.playIncomingRing();
+      }
     },
     onDialogCallTalking() {
+      if (this.phone) {
+        this.phone.pauseIncomingRing();
+      }
       this.isIncomingCall = false;
       this.callStatus = UI_STATE.TALKING;
       this.showLeftButton = true;
@@ -1550,6 +1630,9 @@ export default {
       this.showStatus('通话中', 'success');
     },
     onDialogCallEnded() {
+      if (this.phone) {
+        this.phone.pauseIncomingRing();
+      }
       this._resetCallState();
       this.showStatus('已挂机', 'info');
     },
@@ -1991,58 +2074,146 @@ export default {
 /* 振铃时FAB脉冲动画 */
 .softphone-fab.fab-ringing {
   animation: fab-ringing-pulse 1s infinite;
+  opacity: 1 !important;
+  width: 64px !important;
+  height: 64px !important;
+  background: #f44336 !important;
+  box-shadow: 0 6px 20px rgba(244, 67, 54, 0.55) !important;
 }
 @keyframes fab-ringing-pulse {
   0% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.7); }
-  70% { box-shadow: 0 0 0 16px rgba(244, 67, 54, 0); }
+  70% { box-shadow: 0 0 0 20px rgba(244, 67, 54, 0); }
   100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); }
 }
-.softphone-fab.fab-ringing:not(.fab-collapsed) {
-  background: #f44336;
+.softphone-fab.fab-ringing .material-icons {
+  font-size: 32px;
+}
+.softphone-fab.fab-ringing .fab-badge {
+  width: 14px;
+  height: 14px;
+  top: 6px;
+  right: 6px;
 }
 
-/* ===== 来电提示气泡 ===== */
-.incoming-call-bubble {
+/* ===== 来电醒目提示卡片 ===== */
+.incoming-call-alert {
   position: fixed;
-  background: linear-gradient(135deg, #f44336, #e53935);
+  right: 24px;
+  bottom: 100px;
+  z-index: 10002;
+  min-width: 300px;
+  max-width: calc(100vw - 48px);
+  box-sizing: border-box;
+  background: linear-gradient(135deg, #ff5252 0%, #d32f2f 100%);
   color: #fff;
-  border-radius: 24px;
-  padding: 6px 16px;
-  box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
+  border-radius: 16px;
+  padding: 14px 20px 14px 16px;
+  box-shadow: 0 8px 32px rgba(211, 47, 47, 0.45), 0 0 0 2px rgba(255, 255, 255, 0.25);
   cursor: pointer;
-  white-space: nowrap;
-  animation: bubble-bounce 0.5s ease;
+  overflow: visible;
 }
-.incoming-call-bubble.bubble-left {
-  /* 左侧FAB时气泡在右边 */
+.incoming-call-alert__ripple {
+  position: absolute;
+  inset: 0;
+  border-radius: inherit;
+  box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.35);
+  animation: incoming-alert-ripple 1.5s ease-out infinite;
+  pointer-events: none;
+  overflow: hidden;
 }
-.bubble-content {
+.incoming-call-alert__content {
+  position: relative;
   display: flex;
   align-items: center;
-  gap: 8px;
+  gap: 12px;
+  animation: incoming-alert-shake 2s ease-in-out infinite;
+}
+.incoming-call-alert__icon-wrap {
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.2);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.incoming-call-alert__icon-wrap .material-icons {
+  font-size: 28px;
+  animation: incoming-alert-icon-ring 0.6s ease-in-out infinite alternate;
 }
-.bubble-icon {
-  font-size: 18px;
-  animation: bubble-icon-ring 0.8s infinite;
+@keyframes incoming-alert-icon-ring {
+  from { transform: rotate(-12deg); }
+  to { transform: rotate(12deg); }
 }
-@keyframes bubble-icon-ring {
-  0%, 100% { transform: rotate(0deg); }
-  50% { transform: rotate(15deg); }
+.incoming-call-alert__info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
 }
-.bubble-number {
-  font-size: 15px;
-  font-weight: 600;
+.incoming-call-alert__title {
+  font-size: 16px;
+  font-weight: 700;
   letter-spacing: 0.5px;
 }
-.bubble-label {
-  font-size: 12px;
+.incoming-call-alert__number {
+  font-size: 20px;
+  font-weight: 700;
+  letter-spacing: 1px;
+  line-height: 1.2;
+}
+.incoming-call-alert__hint {
+  font-size: 11px;
   opacity: 0.85;
-  margin-left: 2px;
+  margin-top: 2px;
+}
+.incoming-call-alert__actions {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex-shrink: 0;
+  padding-right: 2px;
+}
+.incoming-call-alert__btn {
+  width: 44px;
+  height: 44px;
+  border: none;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+.incoming-call-alert__btn .material-icons {
+  font-size: 22px;
+  color: #fff;
+}
+.incoming-call-alert__btn.answer {
+  background: #4caf50;
+  box-shadow: 0 4px 12px rgba(76, 175, 80, 0.5);
+}
+.incoming-call-alert__btn.reject {
+  background: rgba(0, 0, 0, 0.25);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+.incoming-call-alert__btn:hover {
+  transform: scale(1.08);
+}
+.incoming-call-alert__btn:active {
+  transform: scale(0.95);
+}
+@keyframes incoming-alert-ripple {
+  0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.45); }
+  70% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); }
+  100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
 }
-@keyframes bubble-bounce {
-  0% { opacity: 0; transform: translateY(10px) scale(0.9); }
-  60% { transform: translateY(-2px) scale(1.02); }
-  100% { opacity: 1; transform: translateY(0) scale(1); }
+@keyframes incoming-alert-shake {
+  0%, 100% { transform: translateY(0); }
+  50% { transform: translateY(-2px); }
 }
 /* 气泡过渡 */
 .call-bubble-fade-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }

+ 229 - 0
src/utils/incomingCallAttention.js

@@ -0,0 +1,229 @@
+/**
+ * 来电注意力提示:标签页标题闪烁、Favicon 闪烁、桌面通知
+ * 用于浏览器切到后台或其他标签页时提醒坐席有来电
+ */
+
+const FLASH_INTERVAL_MS = 700;
+const ALERT_TITLE_PREFIX = '\u3010\u6765\u7535\u3011';
+
+let originalTitle = '';
+let originalFaviconHref = '';
+let alertFaviconHref = '';
+let faviconLinkEl = null;
+let titleFlashTimer = null;
+let showAlertState = true;
+let desktopNotification = null;
+let active = false;
+let visibilityHandler = null;
+
+function getFaviconLink() {
+  if (faviconLinkEl) {
+    return faviconLinkEl;
+  }
+  faviconLinkEl = document.querySelector("link[rel~='icon']")
+    || document.querySelector("link[rel='shortcut icon']");
+  if (!faviconLinkEl) {
+    faviconLinkEl = document.createElement('link');
+    faviconLinkEl.rel = 'icon';
+    document.head.appendChild(faviconLinkEl);
+  }
+  return faviconLinkEl;
+}
+
+/** 生成红色告警 Favicon(Canvas 绘制,无需额外资源) */
+function buildAlertFavicon() {
+  const size = 32;
+  const canvas = document.createElement('canvas');
+  canvas.width = size;
+  canvas.height = size;
+  const ctx = canvas.getContext('2d');
+
+  ctx.fillStyle = '#f44336';
+  ctx.beginPath();
+  ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
+  ctx.fill();
+
+  ctx.fillStyle = '#ffffff';
+  ctx.font = 'bold 20px sans-serif';
+  ctx.textAlign = 'center';
+  ctx.textBaseline = 'middle';
+  ctx.fillText('!', size / 2, size / 2 + 1);
+
+  return canvas.toDataURL('image/png');
+}
+
+function ensureAlertFavicon() {
+  if (!alertFaviconHref) {
+    alertFaviconHref = buildAlertFavicon();
+  }
+  return alertFaviconHref;
+}
+
+function applyFlashFrame(caller) {
+  const link = getFaviconLink();
+  if (!originalFaviconHref && link.href) {
+    originalFaviconHref = link.href;
+  }
+
+  if (showAlertState) {
+    document.title = `${ALERT_TITLE_PREFIX}${caller || '\u672a\u77e5\u53f7\u7801'}`;
+    link.href = ensureAlertFavicon();
+    link.type = 'image/png';
+  } else if (originalTitle) {
+    document.title = originalTitle;
+    if (originalFaviconHref) {
+      link.href = originalFaviconHref;
+    }
+  }
+  showAlertState = !showAlertState;
+}
+
+function closeDesktopNotification() {
+  if (desktopNotification) {
+    try {
+      desktopNotification.close();
+    } catch (e) {
+      // ignore
+    }
+    desktopNotification = null;
+  }
+}
+
+function showDesktopNotification({ caller, body }) {
+  if (typeof window === 'undefined' || !('Notification' in window)) {
+    return;
+  }
+  if (Notification.permission !== 'granted') {
+    return;
+  }
+
+  closeDesktopNotification();
+
+  const text = caller || '\u672a\u77e5\u53f7\u7801';
+  desktopNotification = new Notification('\u6765\u7535\u54cd\u94c3\u4e2d', {
+    body: body || `\u6765\u7535\u53f7\u7801\uff1a${text}`,
+    icon: ensureAlertFavicon(),
+    tag: 'softphone-incoming-call',
+    requireInteraction: true,
+    silent: false
+  });
+
+  desktopNotification.onclick = () => {
+    window.focus();
+    closeDesktopNotification();
+  };
+}
+
+function onVisibilityChange(caller, body) {
+  if (!active) {
+    return;
+  }
+  if (document.hidden) {
+    showDesktopNotification({ caller, body });
+  }
+}
+
+function bindVisibilityListener(caller, body) {
+  unbindVisibilityListener();
+  visibilityHandler = () => onVisibilityChange(caller, body);
+  document.addEventListener('visibilitychange', visibilityHandler);
+}
+
+function unbindVisibilityListener() {
+  if (visibilityHandler) {
+    document.removeEventListener('visibilitychange', visibilityHandler);
+    visibilityHandler = null;
+  }
+}
+
+/**
+ * 预请求桌面通知权限(建议在用户交互后调用)
+ */
+export async function requestIncomingCallNotificationPermission() {
+  if (typeof window === 'undefined' || !('Notification' in window)) {
+    return 'unsupported';
+  }
+  if (Notification.permission === 'granted' || Notification.permission === 'denied') {
+    return Notification.permission;
+  }
+  try {
+    return await Notification.requestPermission();
+  } catch (e) {
+    return 'denied';
+  }
+}
+
+/**
+ * 开始来电注意力提示
+ * @param {{ caller?: string, body?: string }} options
+ */
+export function startIncomingCallAttention(options = {}) {
+  if (typeof document === 'undefined') {
+    return;
+  }
+
+  const caller = options.caller || '\u672a\u77e5\u53f7\u7801';
+  const body = options.body || `\u6765\u7535\u53f7\u7801\uff1a${caller}`;
+
+  stopIncomingCallAttention();
+
+  active = true;
+  originalTitle = document.title;
+  showAlertState = true;
+
+  const link = getFaviconLink();
+  if (link.href) {
+    originalFaviconHref = link.href;
+  }
+
+  applyFlashFrame(caller);
+  titleFlashTimer = setInterval(() => {
+    if (active) {
+      applyFlashFrame(caller);
+    }
+  }, FLASH_INTERVAL_MS);
+
+  bindVisibilityListener(caller, body);
+  if (document.hidden) {
+    showDesktopNotification({ caller, body });
+  }
+}
+
+/**
+ * 停止来电注意力提示并恢复标签页标题 / Favicon
+ */
+export function stopIncomingCallAttention() {
+  active = false;
+
+  if (titleFlashTimer) {
+    clearInterval(titleFlashTimer);
+    titleFlashTimer = null;
+  }
+
+  unbindVisibilityListener();
+  closeDesktopNotification();
+
+  if (originalTitle) {
+    document.title = originalTitle;
+  }
+
+  const link = getFaviconLink();
+  if (originalFaviconHref) {
+    link.href = originalFaviconHref;
+  }
+
+  originalTitle = '';
+  originalFaviconHref = '';
+  showAlertState = true;
+}
+
+if (typeof window !== 'undefined') {
+  window.__testIncomingCallAttention = function(caller = '13800138000') {
+    startIncomingCallAttention({ caller, body: `\u6d4b\u8bd5\u6765\u7535\uff1a${caller}` });
+    console.log('[\u6d4b\u8bd5] \u6807\u7b7e\u9875\u95ea\u70c1\u5df2\u542f\u52a8\uff0c\u6267\u884c __testStopIncomingCallAttention() \u505c\u6b62');
+  };
+  window.__testStopIncomingCallAttention = function() {
+    stopIncomingCallAttention();
+    console.log('[\u6d4b\u8bd5] \u6807\u7b7e\u9875\u95ea\u70c1\u5df2\u505c\u6b62');
+  };
+}

+ 7 - 1
src/views/aiSipCall/softPhone.vue

@@ -1100,7 +1100,13 @@ export default {
         // 注意:不清空 currentCallUuid,因为后续还需要用它来保存通话记录
       }
     },
-    onSessionClosed() {
+    onSessionClosed(event) {
+      // 控制台测试拒接不触发额外逻辑
+      if (event && event.reason === 'test_rejected') {
+        this._resetCallState();
+        this.showStatus('测试来电已结束', 'info');
+        return;
+      }
       // 显示已挂机状态
       this.showStatus('已挂机', 'info');
       this._resetCallState();