吴树波 1 неделя назад
Родитель
Сommit
902a9c0ce7

+ 16 - 0
.codegraph/.gitignore

@@ -0,0 +1,16 @@
+# CodeGraph data files
+# These are local to each machine and should not be committed
+
+# Database
+*.db
+*.db-wal
+*.db-shm
+
+# Cache
+cache/
+
+# Logs
+*.log
+
+# Hook markers
+.dirty

+ 255 - 59
src/api/aiSipCall/softPhone.js

@@ -6,6 +6,48 @@ import * as JsSIP from 'jssip';
 // ========== 回铃音 URL(使用本地音频文件) ==========
 export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
 
+// ========== 默认配置常量(IPCC 与 JsSIP 严格分离) ==========
+
+/** IPCC(呼叫中心)服务器默认配置 */
+export const IPCC_DEFAULTS = {
+  SERVER_PROD: 'sip.ylrzcloud.com',
+  SERVER_LOCAL: '129.28.164.235',
+  PORT_LOCAL: 1081,
+  CONNECT_TIMEOUT: 15000,
+  HEARTBEAT_INTERVAL: 16
+};
+
+/** JsSIP(软电话)SIP 默认配置 */
+export const JS_SIP_DEFAULTS = {
+  SERVER: 'wss://sip.ylrzcloud.com:8443', // 线上环境
+  DOMAIN: 'sip.ylrzcloud.com',
+  TRANSPORT: 'wss',
+  USER_AGENT: 'JsSIP',
+  SESSION_EXPIRES: 180,
+  MIN_SESSION_EXPIRES: 90,
+  SPEAKER_VOLUME: 0.8,
+  MIC_VOLUME: 0.8,
+  RECONNECT_INTERVAL: 15,
+  RECONNECT_TOTAL_DURATION: 60000
+};
+
+/**
+ * 将字符串转换为 Base64(安全,使用 TextEncoder 替代已弃用的 unescape)
+ */
+const toBase64 = (str) => {
+  const bytes = new TextEncoder().encode(str);
+  return btoa(String.fromCharCode(...bytes));
+};
+
+/**
+ * 从 Base64 解码为字符串(安全,使用 TextDecoder 替代已弃用的 escape)
+ */
+const fromBase64 = (b64) => {
+  const binary = atob(b64);
+  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
+  return new TextDecoder().decode(bytes);
+};
+
 /**
  * 简单的密码混淆(非加密,仅避免明文暴露)
  * 注意:生产环境应使用服务端认证token或OAuth
@@ -15,14 +57,12 @@ const encodePassword = (pwd) => {
   try {
     const timestamp = Date.now().toString(36);
     const pwdStr = String(pwd);
-    const encoded = btoa(unescape(encodeURIComponent(pwdStr)));
+    const encoded = toBase64(pwdStr);
     return `${timestamp}:${encoded}`;
   } catch (e) {
-    console.error('[密码] 编码失败');
     try {
       return btoa(String(pwd));
     } catch (fallbackError) {
-      console.error('[密码] 备选编码失败');
       return '';
     }
   }
@@ -35,9 +75,8 @@ const decodePassword = (encoded) => {
     if (encoded.includes(':')) {
       base64Part = encoded.split(':')[1];
     }
-    return decodeURIComponent(escape(atob(base64Part)));
+    return fromBase64(base64Part);
   } catch (e) {
-    console.warn('[密码] 解码失败,可能是旧格式');
     return '';
   }
 };
@@ -106,12 +145,12 @@ export class ProfileManager {
       users: {},
       user: '',
       reconnect: true,
-      reconnect_interval: 15,
-      user_agent: 'JsSIP',
-      session_expires: 180,
-      min_session_expires: 120,
-      speaker_volume: 0.8,
-      mic_volume: 0.8,
+      reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
+      user_agent: JS_SIP_DEFAULTS.USER_AGENT,
+      session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
+      speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
+      mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME,
       speaker_paused: false,
       mic_paused: false,
       auto_answer: false,
@@ -132,13 +171,14 @@ export class ProfileManager {
       this.profile.reconnect_interval = reconnectInterval;
       this.save();
     }
+    const currentUser = this.getCurrentUserProfile() || {};
     const settings = {
       user_agent: this.profile.user_agent,
       session_expires: this.profile.session_expires,
       min_session_expires: this.profile.min_session_expires,
       stun: this.profile.stun,
       ice_server: this.profile.ice_server,
-      auto_answer: this.profile.auto_answer,
+      auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
       reconnect: this.profile.reconnect,
       reconnect_interval: reconnectInterval
     };
@@ -152,10 +192,10 @@ export class ProfileManager {
 
   resetSettings() {
     this.profile.reconnect = true;
-    this.profile.reconnect_interval = 15;
-    this.profile.user_agent = 'JsSIP';
-    this.profile.session_expires = 180;
-    this.profile.min_session_expires = 120;
+    this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
+    this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
+    this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
+    this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
     this.profile.auto_answer = false;
     this.profile.stun = false;
     this.profile.ice_server = '';
@@ -190,14 +230,31 @@ export class ProfileManager {
 
   updateUser(userId, updatedProfile) {
     if (this.profile.users[userId]) {
-      const existing = this.profile.users[userId];
-      const merged = {
-        ...existing,
-        ...updatedProfile,
-        user: existing.user,
-        domain: existing.domain
-      };
-      this.profile.users[userId] = merged;
+      const newUser = updatedProfile.user;
+      const newDomain = updatedProfile.domain;
+      const newUserId = `${newUser}@${newDomain}`;
+
+      if (newUserId !== userId) {
+        // user 或 domain 改变了,删除旧条目并用新 key 创建
+        const merged = {
+          ...this.profile.users[userId],
+          ...updatedProfile,
+          user: newUser,
+          domain: newDomain
+        };
+        delete this.profile.users[userId];
+        this.profile.users[newUserId] = merged;
+        if (this.profile.user === userId) {
+          this.profile.user = newUserId;
+        }
+      } else {
+        this.profile.users[userId] = {
+          ...this.profile.users[userId],
+          ...updatedProfile,
+          user: newUser,
+          domain: newDomain
+        };
+      }
       this.save();
     }
   }
@@ -239,7 +296,7 @@ export class WebPhone {
     this.reconnectEnabled = settings.reconnect;
     this.reconnectAttempts = 0;
     this.reconnectStartTime = null;
-    this.reconnectTotalDuration = 1 * 60 * 1000; // 1分钟
+    this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
     this.isReconnecting = false;
     this.reconnectTimerId = null;
     this._isHandlingDisconnect = false;
@@ -249,8 +306,9 @@ export class WebPhone {
     this.ringbackMedia.loop = true;
     this.remoteMedia = new Audio();
     this.localMedia = new Audio();
+    this.peerConnection = null;
+    this.localStream = null;
 
-    // this.applyWebSocketPatch();
     this.initUA();
   }
 
@@ -297,49 +355,64 @@ export class WebPhone {
 
   initUA() {
     if (!this.profile.server || !this.profile.user || !this.profile.domain) {
-      console.error('[SIP] 配置不完整');
+      console.error('[jsSip] 配置不完整');
       this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
       return;
     }
-    const socket = new JsSIP.WebSocketInterface(this.profile.server, { protocols: [] });
-    socket.via_transport = this.profile.transport || 'wss';
+    // JsSIP 3.x 底层创建 WebSocket 时已自动使用 'sip' 子协议(RFC 7118)
+    const socket = new JsSIP.WebSocketInterface(this.profile.server);
+
+    // 修复 JsSIP 3.x 的 via_transport 问题:
+    // 对于 WSS 连接,JsSIP 设置 via_transport = 'WSS'(WebSocketInterface.js:23)
+    // 但 RFC 3261/7118 中 Via header transport 应用 'WS'(不分是否 TLS)
+    // 若服务器不识别 'WSS' 会静默丢弃 REGISTER 请求,导致超时
+    if (String(this.profile.server || '').startsWith('wss://')) {
+      socket.via_transport = 'WS';
+      console.log('[jsSip] WSS 连接: 修正 via_transport WSS → WS(RFC 7118)');
+    }
 
     const user = String(this.profile.user || '');
-    const domain = String(this.profile.domain || '');
     const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
     const password = this.profile.password ? String(this.profile.password) : '';
     const server = String(this.profile.server || '');
-    const transport = String(this.profile.transport || 'wss');
+    // SIP over WebSocket 的 transport 始终为 'ws'(RFC 7118),与是否 WSS/TLS 无关
+    const transport = 'ws';
 
+    const domain = String(this.profile.domain || '');
     if (!user || !domain || !password) {
-      console.error('[SIP] 账号配置缺失:', { user, domain, hasPassword: !!password });
+      console.error('[jsSip] 账号配置缺失:', { user, domain, hasPassword: !!password });
       this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
       return;
     }
 
+    // 检测混合内容问题:HTTP 页面使用 WSS 连接会被浏览器阻止
+    if (server.startsWith('wss://') && window.location.protocol === 'http:') {
+      console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容,请使用 HTTPS 或改用 WS 协议');
+      this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
+    }
+
     const uri = new JsSIP.URI('sip', user, domain);
-    const contactUriStr = `sip:${user}@${domain};transport=ws`;
+    const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
 
     this.configuration = {
       sockets: [socket],
       authorization_user: user,
-      hack_ip_in_contact: true,
-      user_agent: this.settings.user_agent || 'JsSIP',
+      user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
       display_name: displayName || undefined,
+      // 启用会话定时器,防止长时间通话被中间代理断开
       session_timers: true,
+      session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
       no_answer_timeout: 60,
+      register: true,
       uri: uri.toAor(),
       contact_uri: contactUriStr,
+      // 始终使用 password 模式进行认证
+      // 移除旧的 ha1 启发式判断(password.length === 32),避免错误地将32位密码当成HA1
+      password: password
     };
 
-    if (password && password.length === 32) {
-      this.configuration.ha1 = password;
-      this.configuration.realm = domain;
-    } else {
-      this.configuration.password = password;
-    }
-
-    console.log(`[SIP] UA配置完成: ${user}@${domain}`);
+    console.log(`[jsSip] UA配置完成: ${user}@${domain}, 服务器: ${server}, 认证方式: password`);
   }
 
   createUA() {
@@ -354,12 +427,19 @@ export class WebPhone {
     this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
     this.ua.on('newRTCSession', this.newRTCSession.bind(this));
     this.ua.on('newMessage', this.newMessage.bind(this));
+    this.ua.on('transportError', this.transportError.bind(this));
   }
 
   On(event, callback) {
     this.events[event] = callback;
   }
 
+  Off(event) {
+    if (this.events[event]) {
+      delete this.events[event];
+    }
+  }
+
   emit(event, ...args) {
     if (this.events[event]) {
       try {
@@ -474,20 +554,31 @@ export class WebPhone {
 
   // ---- JsSIP 事件处理 ----
   connecting() {
-    console.log('[SIP] 连接中...');
-    this.emit('OnStatusMessage', { type: 'info', text: '连接中...' });
+    console.log('[jsSip] 连接中...');
+    this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' });
   }
   connected() {
-    console.log('[SIP] 已连接,开始注册');
+    console.log('[jsSip] 已连接,开始注册');
     this.Register();
-    this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
+    this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' });
   }
   disconnected(e) {
     if (this._isHandlingDisconnect) return;
     this._isHandlingDisconnect = true;
-    console.log('[SIP] 连接断开');
+    const reason = e?.cause || e?.message || '未知原因';
+    const code = e?.code || '';
+    if (code !== '') {
+      console.error(`[SIP] 连接断开: ${reason}`, {
+        code,
+        cause: e?.cause,
+        message: e?.message,
+        server: this.profile?.server || 'unknown',
+        fullEvent: e
+      });
+    }
+
     this.emit('OnRegister', { registered: false });
-    this.emit('OnStatusMessage', { type: 'error', text: '连接断开' });
+    this.emit('OnStatusMessage', { type: 'error', text: 'WSS断开: ' + reason });
     if (this.ua) {
       try { this.ua.stop(); } catch (err) {}
       this.ua = null;
@@ -496,27 +587,74 @@ export class WebPhone {
     setTimeout(() => { this._isHandlingDisconnect = false; }, 1000);
   }
   registered() {
-    console.log('[SIP] 连接成功');
     this.resetReconnectState();
     this.emit('OnRegister', { registered: true });
-    this.emit('OnStatusMessage', { type: 'success', text: '连接成功' });
+    this.emit('OnStatusMessage', { type: 'success', text: '连接' });
     this.SetQueueIn();
   }
   unregistered() {
-    console.log('[SIP] 已注销');
     this.emit('OnRegister', { registered: false });
-    this.emit('OnStatusMessage', { type: 'info', text: '已注销' });
+    this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' });
   }
   registrationFailed(e) {
-    console.error('[SIP] 注册失败:', e.cause || '未知原因');
+    const cause = e?.cause || e?.message || '未知原因';
+    const statusCode = e?.response?.status_code || '';
+    console.error('[jsSip] 注册失败:', {
+      cause,
+      status_code: statusCode,
+      response: e?.response,
+      server: this.profile?.server || 'unknown',
+      user: this.profile?.user || 'unknown',
+      domain: this.profile?.domain || 'unknown'
+    });
+
+    // 根据失败原因给出更明确的中文提示
+    let errorText = '注册失败: ' + cause;
+    if (cause === 'Connection Error') {
+      errorText = '注册失败: 无法连接到SIP服务器,请检查服务器地址 ' + (this.profile?.server || '') + ' 是否可访问';
+    } else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401') || cause.includes('Unauthorized')) {
+      errorText = '注册失败: 认证失败(code:' + statusCode + '),请检查分机号和密码是否正确';
+    } else if (cause.includes('404') || cause.includes('Not Found')) {
+      errorText = '注册失败: 用户不存在(code:404),请检查分机号是否正确';
+    } else if (cause.includes('408') || cause.includes('Timeout') || cause.includes('timeout')) {
+      errorText = '注册失败: 注册请求超时,服务器无响应';
+    }
+
     this.emit('OnRegister', { registered: false });
-    this.emit('OnStatusMessage', { type: 'error', text: '注册失败: ' + (e.cause || '未知原因') });
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
     if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
   }
   registrationExpiring() {
-    console.log('[SIP] 注册即将过期,重新注册');
+    console.log('[jsSip] 注册即将过期,重新注册');
     this.Register();
   }
+  transportError(err) {
+    // 详细输出 WebSocket 错误信息,帮助排查连接问题
+    const errorCode = err?.code || '';
+    const errorMessage = err?.message || '';
+    const errorReason = err?.reason || '';
+    console.error('[SIP] WebSocket传输错误:', {
+      code: errorCode,
+      message: errorMessage,
+      reason: errorReason,
+      server: this.profile?.server || 'unknown',
+      fullError: err
+    });
+
+    let detail = errorMessage || errorReason || JSON.stringify(err);
+    let errorText = 'WSS连接失败: ' + detail;
+
+    if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content') || errorMessage.includes('Mixed Content')) {
+      errorText = 'WSS连接被浏览器阻止: 页面是HTTP协议,无法连接安全的WSS服务。请使用HTTPS访问页面或改用WS协议';
+    } else if (errorCode === 1006 || errorMessage.includes('close with code 1006')) {
+      errorText = 'WSS连接异常关闭(code 1006): 服务器无响应或SSL证书错误, 服务地址:' + (this.profile?.server || 'unknown');
+    } else if (errorCode === 1005 || errorMessage.includes('close with code 1005')) {
+      errorText = 'WSS连接被拒绝(code 1005): 服务器不接受WebSocket连接, 请检查服务地址和端口';
+    }
+
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
+    if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
 
   // 外呼功能由 ccPhoneBarSocket 实现,WebPhone 不再实现 Call 方法
   // 但保留 Answer、Terminate、ToggleHold、ToggleMicPhone 等方法供来电和通话控制
@@ -558,6 +696,11 @@ export class WebPhone {
 
   SetMicPhone(paused, volume) {
     this.localMedia.volume = paused ? 0 : volume;
+    if (this.localStream) {
+      this.localStream.getAudioTracks().forEach((track) => {
+        track.enabled = !paused;
+      });
+    }
   }
 
   SetQueueIn() {
@@ -704,6 +847,9 @@ export class WebPhone {
       return;
     }
 
+    // 保存 peerConnection 引用
+    this.peerConnection = connection;
+
     // 监听远程流
     connection.ontrack = (e) => {
       if (!this.remoteMedia.srcObject) {
@@ -715,9 +861,31 @@ export class WebPhone {
       }
     };
 
+    // 如果已有本地流,先清理
+    if (this.localStream) {
+      console.log('[音频] 清理旧的本地流');
+      this.localStream.getTracks().forEach(track => {
+        track.stop();
+        if (this.peerConnection) {
+          try {
+            this.peerConnection.removeTrack(track, this.localStream);
+          } catch (err) {
+            // ignore
+          }
+        }
+      });
+      this.localStream = null;
+    }
+
+    // 清理 localMedia 的旧 srcObject
+    if (this.localMedia.srcObject) {
+      this.localMedia.srcObject = null;
+    }
+
     // 获取并添加本地流
     navigator.mediaDevices.getUserMedia({ audio: true })
       .then(stream => {
+        this.localStream = stream;
         this.localMedia.srcObject = stream;
         stream.getTracks().forEach(track => {
           try {
@@ -736,12 +904,40 @@ export class WebPhone {
 
   sessionClosed(succeed, reason) {
     if (!this.session) return;
+
+    console.log('[通话] 会话关闭,开始清理资源');
     this.session = null;
     if (this.callTimerId) clearInterval(this.callTimerId);
     this.pauseRingback();
 
-    if (this.remoteMedia.srcObject) this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
-    if (this.localMedia.srcObject) this.localMedia.srcObject.getTracks().forEach(t => t.stop());
+    // 清理远程媒体
+    if (this.remoteMedia.srcObject) {
+      this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
+      this.remoteMedia.srcObject = null;
+    }
+
+    // 清理本地媒体
+    if (this.localMedia.srcObject) {
+      this.localMedia.srcObject.getTracks().forEach(t => t.stop());
+      this.localMedia.srcObject = null;
+    }
+
+    // 清理本地流引用
+    if (this.localStream) {
+      this.localStream.getTracks().forEach(t => t.stop());
+      this.localStream = null;
+    }
+
+    // 清理 peerConnection
+    if (this.peerConnection) {
+      try {
+        this.peerConnection.close();
+      } catch (err) {
+        // ignore
+      }
+      this.peerConnection = null;
+    }
+
     this.emit('OnSessionClosed', { succeeded: succeed, reason });
   }
 
@@ -798,4 +994,4 @@ export class WebPhone {
   }
 }
 
-export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL };
+export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS };

+ 81 - 21
src/components/FloatingSoftPhone/index.vue

@@ -140,22 +140,22 @@
                     :title="callStatus === 'talking' ? '发送DTMF: ' + digit : '输入: ' + digit">{{ digit }}</button>
           </div>
 
-          <!-- 呼叫按钮组 -->
+          <!-- 呼叫按钮组:空闲=绿色外呼;外呼振铃/通话中=中间挂断;来电振铃=左右接听/拒接;接通=左保持/中挂断/右转移 -->
           <div class="call-buttons">
             <button class="call-button call-left-button"
-                    :class="{ hidden: !showLeftButton, normal: leftButtonNormal }"
+                    :class="{ hidden: !showSideCallButtons, normal: callStatus === 'talking' && leftButtonNormal }"
                     @click="onLeftButtonClick"
                     :title="getLeftButtonTitle()">
               <i class="material-icons">{{ leftButtonIcon }}</i>
             </button>
             <button class="call-button call-hangup-button"
-                    :class="[getHangupButtonClass(), { hidden: callStatus === 'ringing' }]"
+                    :class="[getHangupButtonClass(), { hidden: !showCenterCallButton }]"
                     @click="onHangupClick"
                     :title="getHangupButtonTitle()">
               <i class="material-icons">{{ hangupButtonIcon }}</i>
             </button>
             <button class="call-button call-right-button"
-                    :class="{ hidden: !showRightButton, normal: rightButtonNormal, hangup: rightButtonHangup }"
+                    :class="{ hidden: !showSideCallButtons, normal: callStatus === 'talking' && rightButtonNormal, hangup: isIncomingRinging }"
                     @click="onRightButtonClick"
                     :title="getRightButtonTitle()">
               <i class="material-icons">{{ rightButtonIcon }}</i>
@@ -340,6 +340,7 @@ export default {
       isRegistered: false,
       isConnected: false,
       callStatus: UI_STATE.IDLE,
+      isIncomingCall: false,  // 振铃阶段是否为来电(非外呼)
       incomingCaller: '',  // 来电号码
 
       // ===== 音频控制 =====
@@ -420,6 +421,7 @@ export default {
       _ccReconnectTimer: null,
       _ccReconnectAttempts: 0,
       _isDestroying: false,
+      _ccEventsBoundFor: null,
 
       // ===== 坐席状态 =====
       isCallingReady: false,
@@ -435,6 +437,7 @@ export default {
 
       // ===== 委托通话标记 =====
       delegatedCallActive: false,  // 是否有通话委托给弹窗的phoneBar
+      isOutboundInProgress: false,  // 外呼进行中(含 SIP 回振坐席阶段,需自动接听)
       incomingJsipCall: false,  // 是否为JsSIP直接来电(转人工等)
       pendingManualNavigation: false  // 转人工来电结束后是否需要跳转到通话列表
     };
@@ -491,8 +494,22 @@ export default {
       if (this.fabX === null) return false;
       return this.fabX + 28 < window.innerWidth / 2;
     },
+    /** 来电振铃(左右接听/拒接,隐藏中间按钮) */
+    isIncomingRinging() {
+      return this.callStatus === UI_STATE.RINGING && this.isIncomingCall;
+    },
+    /** 中间按钮:空闲外呼 / 外呼振铃挂断 / 通话中挂断 */
+    showCenterCallButton() {
+      return this.callStatus === UI_STATE.IDLE
+        || (this.callStatus === UI_STATE.RINGING && !this.isIncomingCall)
+        || this.callStatus === UI_STATE.TALKING;
+    },
+    /** 左右侧按钮:来电振铃 或 通话中 */
+    showSideCallButtons() {
+      return this.isIncomingRinging || this.callStatus === UI_STATE.TALKING;
+    },
     leftButtonIcon() {
-      if (this.callStatus === 'ringing') return 'phone';
+      if (this.isIncomingRinging) return 'phone';
       if (this.callStatus === 'talking') {
         if (this.ccPhoneBar && this.ccSocketConnected) {
           return this.isOnHold ? 'play_arrow' : 'pause';
@@ -502,7 +519,7 @@ export default {
       return '';
     },
     rightButtonIcon() {
-      if (this.callStatus === 'ringing') return 'call_end';
+      if (this.isIncomingRinging) return 'call_end';
       if (this.callStatus === 'talking') return 'call_split';
       return '';
     }
@@ -879,7 +896,7 @@ export default {
 
     // ==================== UI事件 ====================
     getLeftButtonTitle() {
-      if (this.callStatus === 'ringing') return '接听来电';
+      if (this.isIncomingRinging) return '接听来电';
       if (this.callStatus === 'talking') return this.isOnHold ? '恢复通话' : '保持通话';
       return '';
     },
@@ -891,7 +908,7 @@ export default {
       return '未就绪';
     },
     getRightButtonTitle() {
-      if (this.callStatus === 'ringing') return '拒绝来电';
+      if (this.isIncomingRinging) return '拒绝来电';
       if (this.callStatus === 'talking') return '呼叫转移';
       return '';
     },
@@ -1054,6 +1071,8 @@ export default {
       }
     },
     _bindCCEvents() {
+      if (this._ccEventsBoundFor === this.ccPhoneBar) return;
+      this._ccEventsBoundFor = this.ccPhoneBar;
       this.ccPhoneBar.on(EventList.WS_CONNECTED, () => {
         // 注册为共享实例,供其他组件复用
         window.__sharedCCPhoneBar = this.ccPhoneBar;
@@ -1092,6 +1111,11 @@ export default {
         if (!this.ccSocketConnected && this.ccConnectingReject) this.ccConnectingReject(new Error('服务器错误'));
       });
       this.ccPhoneBar.on(EventList.OUTBOUND_START, (msg) => {
+        this.isOutboundInProgress = true;
+        this.isIncomingCall = false;
+        this.callStatus = UI_STATE.RINGING;
+        this.showLeftButton = false;
+        this.showRightButton = false;
         this.showStatus('拨号中...', 'info');
         if (msg?.object?.uuid) {
           this.currentCallUuid = msg.object.uuid;
@@ -1099,7 +1123,10 @@ export default {
         }
       });
       this.ccPhoneBar.on(EventList.CALLEE_RINGING, () => {
+        this.isIncomingCall = false;
         this.callStatus = UI_STATE.RINGING;
+        this.showLeftButton = false;
+        this.showRightButton = false;
         this.showStatus('振铃中...', 'info');
         if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
           this.callUuidMap[this.currentCallUuid].status = 'ringing';
@@ -1120,6 +1147,7 @@ export default {
           }
         }
         this.callStatus = UI_STATE.TALKING;
+        this.isIncomingCall = false;
         this.showLeftButton = true;
         this.showRightButton = true;
         this.leftButtonNormal = true;
@@ -1306,6 +1334,12 @@ export default {
     },
     onRing(event) {
       if (!event.outgoing) {
+        // 外呼时呼叫中心回振坐席 SIP,自动接听,不展示来电 UI
+        if (this.isOutboundInProgress) {
+          if (this.phone) this.phone.Answer();
+          return;
+        }
+        this.isIncomingCall = true;
         this.incomingJsipCall = true;
         this.incomingCaller = event.caller || '';
         this.callStatus = UI_STATE.RINGING;
@@ -1320,6 +1354,7 @@ export default {
       }
     },
     onAnswered() {
+      this.isIncomingCall = false;
       this.callStatus = UI_STATE.TALKING;
       this.showLeftButton = true;
       this.showRightButton = true;
@@ -1331,6 +1366,7 @@ export default {
     _resetCallState() {
       if (this.callStatus !== UI_STATE.IDLE) {
         this.callStatus = UI_STATE.IDLE;
+        this.isIncomingCall = false;
         this.isOnHold = false;
         this.province = '';
         this.callDuration = '00:00';
@@ -1341,6 +1377,7 @@ export default {
         this.rightButtonHangup = false;
       }
       this.delegatedCallActive = false;
+      this.isOutboundInProgress = false;
       this.incomingJsipCall = false;
       this.incomingCaller = '';
       // 注意:不在_resetCallState中重置pendingManualNavigation,它由onSessionClosed消费
@@ -1360,15 +1397,18 @@ export default {
     onCallTimer(time) { this.callDuration = time; },
 
     // ==================== 弹窗通话状态同步 ====================
-    onDialogCallRinging() {
+    onDialogCallRinging(payload) {
+      const incoming = !!(payload && payload.incoming);
+      this.isIncomingCall = incoming;
       this.callStatus = UI_STATE.RINGING;
-      this.showLeftButton = true;
-      this.showRightButton = true;
-      this.rightButtonHangup = true;
-      this.delegatedCallActive = true;  // 来电由弹窗phoneBar处理,标记为委托通话
-      this.showStatus('振铃中...', 'info');
+      this.showLeftButton = incoming;
+      this.showRightButton = incoming;
+      this.rightButtonHangup = incoming;
+      this.delegatedCallActive = true;
+      this.showStatus(incoming ? '来电振铃中...' : '振铃中...', 'info');
     },
     onDialogCallTalking() {
+      this.isIncomingCall = false;
       this.callStatus = UI_STATE.TALKING;
       this.showLeftButton = true;
       this.showRightButton = true;
@@ -1402,11 +1442,30 @@ export default {
         this._scheduleCCReconnect();
         return;
       }
-      // 通知弹窗组件通过其phoneBar执行外呼(走弹窗完整的通话记录、客户信息流程)
-      // 不再通过浮动电话自己的ccPhoneBar.call()拨号,避免创建独立通话
-      this.$root.$emit('floating-softphone-call-triggered', phoneNumber);
-      this.delegatedCallActive = true;
-      this.showStatus('拨号中...', 'info');
+      if (!this.isCallingReady) {
+        this.showStatus('坐席未就绪,请稍候', 'warn');
+        return;
+      }
+
+      // 人工外呼弹窗打开时,委托弹窗 phoneBar(含通话记录、工作流等业务逻辑)
+      if (window.__floatingPhoneCallDelegateActive) {
+        this.isOutboundInProgress = true;
+        this.$root.$emit('floating-softphone-call-triggered', phoneNumber);
+        this.delegatedCallActive = true;
+        this.showStatus('拨号中...', 'info');
+        return;
+      }
+
+      // 普通页面:使用浮动软电话自身的 ccPhoneBar 直接外呼
+      try {
+        this.delegatedCallActive = false;
+        this.isOutboundInProgress = true;
+        this.ccPhoneBar.call(phoneNumber, 'audio', VideoLevels.HD.levelId);
+      } catch (err) {
+        this.isOutboundInProgress = false;
+        console.error('[FloatingSoftPhone] 外呼失败:', err);
+        this.showStatus('拨号失败', 'error');
+      }
     },
     endCall() {
       if (this.callStatus === UI_STATE.IDLE) { this.showStatus('当前无通话', 'warn'); return; }
@@ -1448,7 +1507,7 @@ export default {
       else this.makeCall();
     },
     onLeftButtonClick() {
-      if (this.callStatus === UI_STATE.RINGING) {
+      if (this.isIncomingRinging) {
         if (this.phone) this.phone.Answer();
       } else if (this.callStatus === UI_STATE.TALKING) {
         // 委托通话时,通知弹窗执行保持/恢复
@@ -1465,7 +1524,7 @@ export default {
       }
     },
     onRightButtonClick() {
-      if (this.callStatus === UI_STATE.RINGING) this.endCall();
+      if (this.isIncomingRinging) this.endCall();
       else if (this.callStatus === UI_STATE.TALKING) this.showStatus('呼叫转移功能暂未实现', 'info');
     },
 
@@ -1669,6 +1728,7 @@ export default {
           }
         }
         this.ccPhoneBar = null;
+        this._ccEventsBoundFor = null;
       }
       this.callUuidMap = {};
       this.currentCallUuid = '';

+ 5 - 0
src/components/ManualCallDialog/index.vue

@@ -49,6 +49,9 @@ export default {
       workflowInstanceId: null
     };
   },
+  beforeDestroy() {
+    window.__floatingPhoneCallDelegateActive = false;
+  },
   methods: {
     /**
      * 打开外呼弹窗
@@ -71,6 +74,7 @@ export default {
       this.title = `人工外呼 - ${options.customerName || '未知客户'}`;
       this.dialogKey += 1;
       this.dialogVisible = true;
+      window.__floatingPhoneCallDelegateActive = !!this.useFloatingPhone;
 
       // 浮动软电话模式:同步弹出浮动电话并填充号码
       if (this.useFloatingPhone && this.phone) {
@@ -81,6 +85,7 @@ export default {
       }
     },
     handleClose() {
+      window.__floatingPhoneCallDelegateActive = false;
       this.phone = '';
       this.customerId = null;
       this.companyId = null;

+ 9 - 7
src/views/aiSipCall/aiSipCallManualOutbound.vue

@@ -1018,8 +1018,8 @@ export default {
                 console.log('被叫振铃事件:' + msg.content);
                 this.updatePhoneBar(msg, EventList.CALLEE_RINGING);
                 this.markExecuteSuccess();
-                // 同步状态到浮动软电话
-                this.$root.$emit('dialog-call-ringing');
+                // 同步状态到浮动软电话(外呼被叫振铃,非来电)
+                this.$root.$emit('dialog-call-ringing', { incoming: false });
             });
 
             this._phoneBarOn(EventList.CALLER_ANSWERED, (msg) => {
@@ -1513,11 +1513,13 @@ export default {
                     return;
                 }
             }
-            // 检查外呼按钮状态
-            const callBtn = document.getElementById('callBtn');
-            if (!callBtn || !callBtn.classList.contains('on')) {
-                this.$message.warning('当前无法外呼,请先置忙');
-                return;
+            // 工具条可见时依赖 DOM 按钮状态;浮动软电话模式下 UI 隐藏,改由连接状态判断
+            if (!this.hidePhoneBar) {
+                const callBtn = document.getElementById('callBtn');
+                if (!callBtn || !callBtn.classList.contains('on')) {
+                    this.$message.warning('当前无法外呼,请先置忙');
+                    return;
+                }
             }
             // 检查当前是否有通话
             if (this.phoneBar.getCallConnected()) {

+ 350 - 0
src/views/crm/customer/myManualOutboundCallLog.vue

@@ -0,0 +1,350 @@
+<template>
+  <div class="app-container">
+    <div class="app-content">
+      <div class="title">我的外呼记录 <span v-if="sumBillingMinute != null" class="sum-info">计费分钟合计:{{ sumBillingMinute }}分钟</span></div>
+      <!-- 筛选区域 -->
+      <el-form class="search-form" :model="queryParams" ref="queryForm" :inline="true">
+        <el-form-item label="手机" prop="callerNum">
+          <el-input
+            v-model="queryParams.callerNum"
+            placeholder="请输入手机号"
+            clearable
+            size="small"
+            style="width: 180px"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="加密手机" prop="encryptedCallerNum">
+          <el-input
+            v-model="queryParams.encryptedCallerNum"
+            placeholder="请输入加密手机"
+            clearable
+            size="small"
+            style="width: 220px"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="请选择状态" clearable size="small">
+            <el-option label="成功" :value="2" />
+            <el-option label="失败" :value="3" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="创建时间" prop="dateRange">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            value-format="yyyy-MM-dd"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            size="small"
+            style="width: 240px"
+          ></el-date-picker>
+        </el-form-item>
+        <el-form-item label="通话时长(秒)">
+          <el-input v-model="queryParams.minCallTime" placeholder="最小时长" size="small" style="width: 100px" />
+          <span style="margin: 0 4px">-</span>
+          <el-input v-model="queryParams.maxCallTime" placeholder="最大时长" size="small" style="width: 100px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="cyan" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 表格区域 -->
+      <el-table border v-loading="loading" :data="list">
+        <el-table-column label="客户号码" align="center" prop="callerNum" width="140">
+          <template slot-scope="scope">
+            <span>{{ desensitizePhone(scope.row.callerNum) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="坐席号码" align="center" prop="calleeNum" width="140" />
+        <el-table-column label="呼叫时间" align="center" prop="callCreateTime" width="165">
+          <template slot-scope="scope">
+            <span>{{ scope.row.callCreateTime ? parseTime(scope.row.callCreateTime, '{y}-{m}-{d} {h}:{i}') : '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="接通时间" align="center" prop="callAnswerTime" width="165">
+          <template slot-scope="scope">
+            <span>{{ formatAnswerTime(scope.row.callAnswerTime, scope.row.callCreateTime) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="通话时长" align="center" prop="callTime" width="100">
+          <template slot-scope="scope">
+            <span v-if="scope.row.callTime != null">{{ Math.ceil(scope.row.callTime / 1000) }}秒</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" align="center" prop="status" width="90">
+          <template slot-scope="scope">
+            <el-tag v-if="scope.row.status == 2" type="success">成功</el-tag>
+            <el-tag v-else-if="scope.row.status == 3" type="danger">失败</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="计费分钟" align="center" prop="billingMinute" width="90">
+          <template slot-scope="scope">
+            <span v-if="scope.row.billingMinute != null">{{ scope.row.billingMinute }}分钟</span>
+          </template>
+        </el-table-column>
+        <el-table-column v-if="checkPermi(['crm:customer:showCallInfo'])" label="录音" align="center" prop="recordPath" min-width="280" show-overflow-tooltip>
+          <template slot-scope="scope">
+            <audio v-if="scope.row.recordPath != null" controls :src="handleRecordPath(scope.row.recordPath)"></audio>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" align="center" prop="createTime" width="165">
+          <template slot-scope="scope">
+            <span>{{ formatCreateTime(scope.row.createTime) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="100">
+          <template slot-scope="scope">
+            <el-button v-if="checkPermi(['crm:customer:showCallInfo'])" size="mini" type="text" @click="handleShowContent(scope.row)">查看对话</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page.sync="queryParams.pageNum"
+        :limit.sync="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </div>
+
+    <!-- 对话记录弹窗 -->
+    <el-dialog title="对话记录" :visible.sync="contentDialog.visible" width="900px" append-to-body class="content-dialog">
+      <div class="content-dialog-wrapper">
+        <div v-if="!contentDialog.content" class="content-empty">
+          暂无对话内容
+        </div>
+        <div v-else class="chat-container">
+          <div
+            v-for="(msg, index) in parseContentList(contentDialog.content)"
+            :key="index"
+            :class="['chat-item', msg.role === 'user' ? 'chat-right' : 'chat-left']"
+          >
+            <div class="chat-bubble-wrapper">
+              <div class="chat-role">
+                {{ msg.role === 'user' ? '客户' : '客服' }}
+              </div>
+              <div class="chat-bubble">
+                {{ msg.content }}
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listMyManualOutboundCallLog, mySumBillingMinute } from "@/api/crm/manualOutboundCallLog";
+import { parseTime } from "@/utils/common";
+import { checkPermi } from "@/utils/permission";
+
+export default {
+  name: "MyManualOutboundCallLog",
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 总条数
+      total: 0,
+      // 列表数据
+      list: [],
+      // 日期范围
+      dateRange: [],
+      // 计费分钟合计
+      sumBillingMinute: null,
+      // 对话记录弹窗
+      contentDialog: {
+        visible: false,
+        content: ''
+      },
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        callerNum: null,
+        encryptedCallerNum: null,
+        status: null,
+        minCallTime: null,
+        maxCallTime: null
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    parseTime,
+    checkPermi,
+    desensitizePhone(phone) {
+      if (!phone || phone.length < 7) return phone;
+      return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
+    },
+    formatAnswerTime(answerTime, callCreateTime) {
+      if (!answerTime || answerTime === 0 || answerTime === '0' || answerTime === '') return '';
+      if (callCreateTime && new Date(answerTime).getTime() < new Date(callCreateTime).getTime()) return '';
+      var result = parseTime(answerTime, '{y}-{m}-{d} {h}:{i}');
+      return result || '';
+    },
+    formatCreateTime(time) {
+      if (!time) return '-';
+      if (typeof time === 'string') {
+        return time.replace('T', ' ').substring(0, 16);
+      }
+      return parseTime(time, '{y}-{m}-{d} {h}:{i}') || '-';
+    },
+    handleRecordPath(recordPath) {
+      if (!recordPath) return '';
+      let fullUrl = '';
+      if (recordPath.startsWith('http')) {
+        fullUrl = recordPath;
+      } else {
+        fullUrl = 'http://129.28.164.235:8899/recordings/files?filename=' + recordPath;
+      }
+      return process.env.VUE_APP_BASE_API + '/common/proxy/recording?url=' + encodeURIComponent(fullUrl);
+    },
+    handleShowContent(row) {
+      this.contentDialog.content = row.contentList || '';
+      this.contentDialog.visible = true;
+    },
+    parseContentList(content) {
+      if (!content) return [];
+      try {
+        const parsed = typeof content === 'string' ? JSON.parse(content) : content;
+        if (!Array.isArray(parsed)) return [];
+        return parsed.filter(item => {
+          if (!item) return false;
+          if (item.role === 'system') return false;
+          const text = item.content || '';
+          if (!String(text).trim()) return false;
+          return true;
+        });
+      } catch (e) {
+        console.error('解析 contentList 失败', e);
+        return [];
+      }
+    },
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      const params = { ...this.queryParams };
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.beginTime = this.dateRange[0];
+        params.endTime = this.dateRange[1];
+      }
+      // 前端输入秒,直接传秒给后端,由SQL用CEILING(call_time/1000)匹配
+      listMyManualOutboundCallLog(params).then(response => {
+        this.list = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+      // 查询当前条件下的计费分钟合计
+      mySumBillingMinute(params).then(response => {
+        this.sumBillingMinute = response.data.sumBillingMinute;
+      });
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRange = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    }
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  padding: 12px;
+
+  .app-content {
+    background-color: #fff;
+    padding: 20px;
+    border-radius: 4px;
+
+    .title {
+      font-size: 18px;
+      font-weight: bold;
+      color: #303133;
+      margin-bottom: 20px;
+
+      .sum-info {
+        font-size: 14px;
+        font-weight: normal;
+        color: #409eff;
+        margin-left: 16px;
+      }
+    }
+
+    .search-form {
+      margin-bottom: 20px;
+    }
+  }
+}
+
+.content-dialog-wrapper {
+  padding: 0 10px;
+}
+.content-empty {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+}
+.chat-container {
+  max-height: 600px;
+  overflow-y: auto;
+  padding: 16px;
+  background: #f5f7fa;
+  border-radius: 8px;
+}
+.chat-item {
+  display: flex;
+  margin-bottom: 14px;
+}
+.chat-left {
+  justify-content: flex-start;
+}
+.chat-right {
+  justify-content: flex-end;
+}
+.chat-bubble-wrapper {
+  max-width: 75%;
+}
+.chat-role {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+.chat-left .chat-role {
+  text-align: left;
+}
+.chat-right .chat-role {
+  text-align: right;
+}
+.chat-bubble {
+  padding: 10px 14px;
+  border-radius: 12px;
+  font-size: 14px;
+  line-height: 1.6;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+.chat-left .chat-bubble {
+  background: #fff;
+  border: 1px solid #ebeef5;
+  color: #303133;
+}
+.chat-right .chat-bubble {
+  background: #409eff;
+  color: #fff;
+}
+</style>