فهرست منبع

提交外呼功能提交

吴树波 2 ماه پیش
والد
کامیت
fd76d26aed

+ 21 - 9
src/api/aiSipCall/softPhone.js

@@ -525,12 +525,16 @@ export class WebPhone {
   }
 
   Terminate(code) {
-    if (this.session) {
-      if (code) this.session.terminate({ status_code: code });
-      else this.session.terminate();
+    const currentSession = this.session;
+    if (currentSession) {
+      if (code) currentSession.terminate({ status_code: code });
+      else currentSession.terminate();
     }
     if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
-    this.sessionCloseTimerId = setTimeout(() => this.sessionClosed(true, ''), 1000);
+    // 捕获当前session引用,定时器回调只在session未变时执行清理
+    this.sessionCloseTimerId = setTimeout(() => {
+      if (this.session === currentSession) this.sessionClosed(true, '');
+    }, 1000);
   }
 
   ToggleHold() {
@@ -644,21 +648,30 @@ export class WebPhone {
   newRTCSession(event) {
     console.log('[通话] 新会话创建');
     if (this.session) { event.session.terminate({ status_code: 486 }); return; }
+    // 清除上一次通话可能残留的定时器,防止其回调误清当前session
+    if (this.sessionCloseTimerId) { clearTimeout(this.sessionCloseTimerId); this.sessionCloseTimerId = null; }
     this.session = event.session;
+    // 捕获当前session引用,避免旧session的异步事件回调误清新session
+    const sessionRef = this.session;
     this.session.on('progress', (e) => {
       this.emit('OnRing', {
-        outgoing: this.session.direction === 'outgoing',
+        outgoing: sessionRef.direction === 'outgoing',
+        caller: sessionRef.remote_identity?.uri?.user || '',
         province: e.response?.getHeader('X-Province') || '',
         city: e.response?.getHeader('X-City') || ''
       });
     });
     this.session.on('confirmed', () => {
       this.pauseRingback();
-      this.emit('OnAnswered', this.session.direction === 'outgoing');
+      this.emit('OnAnswered', sessionRef.direction === 'outgoing');
       this.startCallTimer();
     });
-    this.session.on('ended', () => this.sessionClosed(true, ''));
-    this.session.on('failed', (e) => this.sessionClosed(false, e.cause));
+    this.session.on('ended', () => {
+      if (this.session === sessionRef) this.sessionClosed(true, '');
+    });
+    this.session.on('failed', (e) => {
+      if (this.session === sessionRef) this.sessionClosed(false, e.cause);
+    });
     this.session.on('peerconnection', (pc) => {
       if (pc && typeof pc.addTrack === 'function') {
         this.registerRemoteMedia(pc);
@@ -676,7 +689,6 @@ export class WebPhone {
       province: event.request?.getHeader('X-Province') || '',
       city: event.request?.getHeader('X-City') || ''
     });
-    if (!outgoing && this.settings.auto_answer) this.Answer();
     if (outgoing) this.playRingback();
   }
 

+ 1891 - 0
src/components/FloatingSoftPhone/index.vue

@@ -0,0 +1,1891 @@
+<template>
+  <div class="floating-softphone" v-if="isEnabled">
+    <!-- 来电提示气泡 -->
+    <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>
+      </div>
+    </transition>
+
+    <!-- 右下角FAB触发按钮(可拖拽、自动吸边、可折叠) -->
+    <div class="softphone-fab"
+         :class="{
+           'fab-active': panelVisible,
+           'fab-connected': isRegistered,
+           'fab-collapsed': fabIsCollapsed,
+           'fab-left': fabOnLeftEdge,
+           'fab-ringing': callStatus === 'ringing'
+         }"
+         :style="fabStyle"
+         @mousedown="onFabDragStart"
+         @click="onFabClick">
+      <i class="material-icons">{{ panelVisible ? 'close' : 'phone' }}</i>
+      <span class="fab-badge" v-if="callStatus !== 'idle'"></span>
+    </div>
+
+    <!-- 可拖拽的软电话面板 -->
+    <transition name="softphone-fade">
+      <div class="softphone-panel"
+           v-show="panelVisible"
+           :style="panelStyle"
+           ref="panel">
+        <div class="dialer">
+          <!-- 状态栏(拖拽手柄) -->
+          <div class="status-bar drag-handle" @mousedown="startDrag">
+            <!-- 左侧区域:头像下拉 + 呼叫状态图标 -->
+            <div class="status-left">
+              <div class="user-avatar-dropdown" v-click-outside="closeDropdown">
+                <i class="material-icons user-avatar-icon"
+                   :class="{ 'network-available': isRegistered, 'no-network': !isConnected }"
+                   @click.stop="toggleDropdown"
+                   @mousedown.stop
+                   title="点击切换账号">account_circle</i>
+                <div class="dropdown-menu" v-show="dropdownVisible">
+                  <div class="dropdown-group">
+                    <a href="#" class="dropdown-item" v-for="(userProfile, userId) in userList" :key="userId" @click.prevent="switchAccount(userId)" :title="'切换到: ' + userProfile.note">
+                      <i class="material-icons" v-if="currentUserId === userId">check</i>
+                      <i class="material-icons" v-else style="visibility: hidden;">check</i>
+                      {{ userProfile.note }}
+                    </a>
+                  </div>
+                  <div class="dropdown-group">
+                    <a href="#" class="dropdown-item" @click.prevent="openEditAccountDialog" title="编辑当前账号信息"><i class="material-icons">edit</i>编辑账号</a>
+                    <a href="#" class="dropdown-item" @click.prevent="openAddAccountDialog" title="添加新的SIP账号"><i class="material-icons">add</i>添加账号</a>
+                    <a href="#" class="dropdown-item" @click.prevent="confirmDeleteAccount" title="删除当前账号"><i class="material-icons">delete</i>删除账号</a>
+                  </div>
+                  <div class="dropdown-group">
+                    <a href="#" class="dropdown-item" @click.prevent="resetSettings" title="恢复默认设置"><i class="material-icons">settings_backup_restore</i>清空设置</a>
+                    <a href="#" class="dropdown-item" @click.prevent="resetReconnectState" title="重新连接服务器"><i class="material-icons">autorenew</i>重新连接</a>
+                  </div>
+                </div>
+              </div>
+              <i class="material-icons call-status-icon"
+                 v-show="callStatus !== 'idle'"
+                 :class="{ inprogress: callStatus === 'ringing', 'ringing-icon': callStatus === 'ringing' }"
+                 title="通话中">call</i>
+            </div>
+
+            <!-- 中间区域:用户名居中显示 -->
+            <div class="status-center">
+              <span class="display-user"
+                    :class="{ 'network-available': isRegistered }"
+                    :title="currentUserDisplay">{{ currentUserDisplay }}</span>
+            </div>
+
+            <!-- 右侧区域:麦克风、扬声器、网络状态、最小化 -->
+            <div class="status-right">
+              <div class="volume-control-group" @mouseenter="showMicSlider" @mouseleave="startHideSliderTimer" @mousedown.stop>
+                <i class="material-icons microphone-icon"
+                   :class="{ muted: isMicMuted, 'connection-success': isConnected && isRegistered, 'connection-failed': !isConnected }"
+                   @click="toggleMuteMic"
+                   :title="isMicMuted ? '取消静音' : '静音'">mic</i>
+                <div class="volume-slider-container mic-volume-slider" v-show="micSliderVisible" @mouseenter="cancelHideSliderTimer" @mouseleave="startHideSliderTimer">
+                  <input type="range" min="0" max="1" step="0.01" v-model="micVolume" @input="changeMicVolume" class="volume-slider">
+                </div>
+              </div>
+              <div class="volume-control-group" @mouseenter="showSpeakerSlider" @mouseleave="startHideSliderTimer" @mousedown.stop>
+                <i class="material-icons speaker-icon"
+                   :class="{ muted: isSpeakerMuted, 'connection-success': isConnected && isRegistered, 'connection-failed': !isConnected }"
+                   @click="toggleMuteSpeaker"
+                   :title="isSpeakerMuted ? '取消静音' : '静音'">volume_up</i>
+                <div class="volume-slider-container speaker-volume-slider" v-show="speakerSliderVisible" @mouseenter="cancelHideSliderTimer" @mouseleave="startHideSliderTimer">
+                  <input type="range" min="0" max="1" step="0.01" v-model="speakerVolume" @input="changeSpeakerVolume" class="volume-slider">
+                </div>
+              </div>
+              <i class="material-icons network-icon"
+                 :class="{ 'no-network': !isConnected, 'network-available': isConnected && isRegistered, 'network-connecting': isConnected && !isRegistered }"
+                 :title="!isConnected ? '未连接' : (isRegistered ? '已注册' : '连接中')"
+                 @mousedown.stop>signal_cellular_alt</i>
+              <i class="material-icons minimize-btn" @click.stop="hidePanel" @mousedown.stop title="最小化">remove</i>
+            </div>
+          </div>
+
+          <!-- 拨号显示屏与删除按钮 -->
+          <div class="display-wrapper">
+            <!-- 输入框:支持光标定位和键盘删除 -->
+            <input type="text"
+                   class="dialer-display"
+                   v-model="displayText"
+                   @keydown="handleKeydown"
+                   @input="handleInput"
+                   placeholder="输入电话号码"
+                   ref="dialerInput">
+            <i class="material-icons delete-icon"
+               @click="deleteAtCursor"
+               title="删除">backspace</i>
+          </div>
+
+          <!-- 归属地与计时器 -->
+          <div class="container" v-show="province" title="来电归属地">
+            <span class="province">{{ province }}</span>
+          </div>
+          <div class="container" title="通话时长">
+            <span class="call-timer" v-show="callDuration !== '00:00'">{{ callDuration }}</span>
+          </div>
+
+          <!-- 拨号键盘 -->
+          <div class="dialer-keypad">
+            <button class="dialer-button"
+                    v-for="digit in dialKeys"
+                    :key="digit"
+                    @click="onDigitClick(digit)"
+                    :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 }"
+                    @click="onLeftButtonClick"
+                    :title="getLeftButtonTitle()">
+              <i class="material-icons">{{ leftButtonIcon }}</i>
+            </button>
+            <button class="call-button call-hangup-button"
+                    :class="[getHangupButtonClass(), { hidden: callStatus === 'ringing' }]"
+                    @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 }"
+                    @click="onRightButtonClick"
+                    :title="getRightButtonTitle()">
+              <i class="material-icons">{{ rightButtonIcon }}</i>
+            </button>
+          </div>
+
+          <!-- 底部状态栏 -->
+          <div class="status-footer">
+            <div class="status-footer-left">
+              <div class="status-bar-message"
+                   :class="statusType"
+                   v-if="statusText"
+                   :title="statusText">{{ statusText }}</div>
+              <div class="reconnect-failed" v-if="reconnectFailed" title="重连超时,请尝试手动重新连接">
+                <span>重连超时</span>
+              </div>
+            </div>
+            <div class="version-ribbon" title="软电话版本">v1.0.0</div>
+          </div>
+        </div>
+
+        <!-- 添加/编辑账号模态框 -->
+        <div class="modal" v-show="accountDialogVisible" @click.self="accountDialogVisible = false">
+          <div class="modal-header">
+            <i class="material-icons">{{ accountDialogTitle === '添加账号' ? 'add' : 'edit' }}</i>
+            <span>{{ accountDialogTitle }}</span>
+          </div>
+          <div class="modal-content">
+            <form @submit.prevent="saveAccount">
+              <div class="form-group">
+                <input type="text" v-model="accountForm.note" placeholder="备注">
+              </div>
+              <div class="form-group">
+                <input type="text" v-model="accountForm.server" placeholder="服务(ws://129.28.164.235:5066)" required>
+              </div>
+              <div class="form-group">
+                <input type="text" v-model="accountForm.username" placeholder="用户名">
+              </div>
+              <div class="form-group">
+                <input type="text" v-model="accountForm.domain" placeholder="域名" required>
+              </div>
+              <div class="form-group">
+                <input type="text" v-model="accountForm.loginName" placeholder="登录名" required>
+              </div>
+              <div class="form-group">
+                <input :type="showPassword ? 'text' : 'password'" v-model="accountForm.password" placeholder="密码" required>
+                <i class="material-icons password-toggle" @click="showPassword = !showPassword">{{ showPassword ? 'visibility_off' : 'visibility' }}</i>
+              </div>
+              <div class="form-group">
+                <select v-model="accountForm.transport">
+                  <option value="wss">Transport (WSS)</option>
+                  <option value="ws">Transport (WS)</option>
+                </select>
+              </div>
+              <div class="form-buttons">
+                <button type="button" class="cancel-button" @click="accountDialogVisible = false">取消</button>
+                <button type="submit" class="add-button">保存</button>
+              </div>
+            </form>
+          </div>
+        </div>
+
+        <!-- 设置模态框 -->
+        <div class="modal" v-show="settingsDialogVisible" @click.self="settingsDialogVisible = false">
+          <div class="modal-header">
+            <i class="material-icons">settings</i>
+            <span>SIP 设置</span>
+          </div>
+          <div class="modal-content">
+            <form @submit.prevent="saveSettings">
+              <div class="form-group">
+                <label class="form-label">User Agent</label>
+                <input type="text" v-model="settingsForm.userAgent" placeholder="例如: JsSIP" required>
+              </div>
+              <div class="form-group">
+                <label class="form-label">Session Expires</label>
+                <input type="number" v-model.number="settingsForm.sessionExpires" placeholder="例如: 180" required min="60">
+              </div>
+              <div class="form-group">
+                <label class="form-label">Min Session Expires</label>
+                <input type="number" v-model.number="settingsForm.minSessionExpires" placeholder="例如: 120" required min="30">
+              </div>
+              <div class="form-group">
+                <label class="form-label">启用 STUN</label>
+                <select v-model="settingsForm.stun">
+                  <option :value="false">否</option>
+                  <option :value="true">是</option>
+                </select>
+              </div>
+              <div class="form-group">
+                <label class="form-label">ICE Server</label>
+                <input type="text" v-model="settingsForm.iceServer" placeholder="例如: stun:stun.l.google.com:19302">
+              </div>
+              <div class="form-group">
+                <label class="form-label">自动重连</label>
+                <select v-model="settingsForm.reconnect">
+                  <option :value="true">是</option>
+                  <option :value="false">否</option>
+                </select>
+              </div>
+              <div class="form-group" v-if="settingsForm.reconnect">
+                <label class="form-label">重连间隔(秒)</label>
+                <input type="number" v-model.number="settingsForm.reconnectInterval" placeholder="例如: 15" min="5" max="300">
+              </div>
+              <div class="form-buttons">
+                <button type="button" class="cancel-button" @click="settingsDialogVisible = false">取消</button>
+                <button type="submit" class="add-button">保存</button>
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    </transition>
+  </div>
+</template>
+
+<script>
+import { WebPhone, ProfileManager, checkMicrophonePermission } from '@/api/aiSipCall/softPhone.js';
+import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket.js';
+import { EventList, VideoLevels, AgentStatusEnum } from '@/assets/callCenterPhoneBarSdk/constants.js';
+import { myCallUser, getToolbarBasicParam } from '@/api/aiSipCall/aiSipCallUser.js';
+import { syncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr.js';
+
+const IPCC_CONFIG = {
+  SERVER_PROD: 'sip.ylrzcloud.com',
+  SERVER_LOCAL: '129.28.164.235',
+  PORT_LOCAL: 1081,
+  CONNECT_TIMEOUT: 15000
+};
+
+const SIP_DEFAULT_CONFIG = {
+  SERVER: 'ws://129.28.164.235:5066',
+  DOMAIN: '129.28.164.235',
+  TRANSPORT: 'ws'
+};
+
+const VOLUME_CONFIG = {
+  DEFAULT: 0.8,
+  HIDE_DELAY: 1000
+};
+
+const UI_STATE = {
+  IDLE: 'idle',
+  RINGING: 'ringing',
+  TALKING: 'talking'
+};
+
+export default {
+  name: 'FloatingSoftPhone',
+  directives: {
+    'click-outside': {
+      bind(el, binding, vnode) {
+        el.clickOutsideEvent = function(event) {
+          if (!(el === event.target || el.contains(event.target))) {
+            vnode.context[binding.expression]();
+          }
+        };
+        document.body.addEventListener('click', el.clickOutsideEvent);
+      },
+      unbind(el) {
+        document.body.removeEventListener('click', el.clickOutsideEvent);
+      }
+    }
+  },
+  data() {
+    return {
+      // ===== 面板与拖拽状态 =====
+      panelVisible: false,
+      isDragging: false,
+      panelX: null,
+      panelY: null,
+      dragOffsetX: 0,
+      dragOffsetY: 0,
+
+      // ===== FAB按钮拖拽与吸边 =====
+      fabX: null,
+      fabY: null,
+      fabIsDragging: false,
+      fabDragOffsetX: 0,
+      fabDragOffsetY: 0,
+      fabIsCollapsed: false,  // 是否折叠到边缘(半隐藏)
+      fabDragMoved: false,  // 拖拽过程中是否产生了位移(区分点击和拖拽)
+
+      // ===== 拨号与显示 =====
+      dialNumber: '',
+      callDuration: '00:00',
+      province: '',
+      isContentFit: true,
+
+      // ===== 网络与注册状态 =====
+      isRegistered: false,
+      isConnected: false,
+      callStatus: UI_STATE.IDLE,
+      incomingCaller: '',  // 来电号码
+
+      // ===== 音频控制 =====
+      speakerVolume: VOLUME_CONFIG.DEFAULT,
+      micVolume: VOLUME_CONFIG.DEFAULT,
+      isSpeakerMuted: false,
+      isMicMuted: false,
+      speakerSliderVisible: false,
+      micSliderVisible: false,
+      volumeTimerId: null,
+
+      // ===== UI按钮状态 =====
+      showLeftButton: false,
+      showRightButton: false,
+      leftButtonNormal: false,
+      rightButtonNormal: false,
+      rightButtonHangup: false,
+
+      // ===== 用户账号管理 =====
+      dropdownVisible: false,
+      userList: {},
+      currentUserId: '',
+      currentUserDisplay: '',
+
+      // ===== 账号对话框 =====
+      accountDialogVisible: false,
+      accountDialogTitle: '添加账号',
+      isEditMode: false,
+      editingUserId: null,
+      accountForm: {
+        note: '',
+        server: SIP_DEFAULT_CONFIG.SERVER,
+        username: '',
+        domain: SIP_DEFAULT_CONFIG.DOMAIN,
+        loginName: '',
+        password: '',
+        transport: SIP_DEFAULT_CONFIG.TRANSPORT
+      },
+      showPassword: false,
+
+      // ===== 设置对话框 =====
+      settingsDialogVisible: false,
+      settingsForm: {
+        userAgent: 'JsSIP',
+        sessionExpires: 180,
+        minSessionExpires: 120,
+        stun: false,
+        iceServer: '',
+        autoAnswer: false,
+        reconnect: true,
+        reconnectInterval: 15
+      },
+
+      // ===== 拨号键盘 =====
+      dialKeys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'],
+
+      // ===== SIP电话实例 =====
+      phone: null,
+      profileManager: null,
+
+      // ===== 状态提示 =====
+      statusText: '',
+      statusType: 'info',
+      statusTimerId: null,
+      isReconnecting: false,
+      reconnectFailed: false,
+
+      // ===== 呼叫中心集成 =====
+      ccPhoneBar: null,
+      ccSocketConnected: false,
+      ccSocketFailed: false,
+      ccConnectingPromise: null,
+      ccConnectingResolve: null,
+      ccConnectingReject: null,
+
+      // ===== 坐席状态 =====
+      isCallingReady: false,
+      isOnHold: false,
+
+      // ===== 通话记录 =====
+      currentCallUuid: '',
+      callUuidMap: {},
+
+      // ===== 号码存储与展示 =====
+      plaintextRealNumber: '',  // 存储的真实号码(用于拨号)
+      displayText: '',  // 展示文本(脱敏格式或原始输入)
+
+      // ===== 委托通话标记 =====
+      delegatedCallActive: false,  // 是否有通话委托给弹窗的phoneBar
+      incomingJsipCall: false,  // 是否为JsSIP直接来电(转人工等)
+      pendingManualNavigation: false  // 转人工来电结束后是否需要跳转到通话列表
+    };
+  },
+  computed: {
+    // 真实号码(用于拨号)
+    realNumber() {
+      return this.plaintextRealNumber || '';
+    },
+    isEnabled() {
+      const permissions = this.$store.getters && this.$store.getters.permissions;
+      if (!permissions || permissions.length === 0) return false;
+      return permissions.some(p =>
+        p.includes('aiSipCall') || p.includes('softPhone') || p === '*:*:*'
+      );
+    },
+    isOnSoftPhonePage() {
+      return this.$route && this.$route.path && this.$route.path.includes('/softPhone');
+    },
+    panelStyle() {
+      if (this.panelX === null) {
+        return { position: 'fixed', bottom: '90px', right: '24px', zIndex: 9999 };
+      }
+      return {
+        position: 'fixed',
+        left: this.panelX + 'px',
+        top: this.panelY + 'px',
+        zIndex: 9999
+      };
+    },
+    fabStyle() {
+      if (this.fabX === null) {
+        return { position: 'fixed', bottom: '24px', right: '24px', zIndex: 9998 };
+      }
+      return {
+        position: 'fixed',
+        left: this.fabX + 'px',
+        top: this.fabY + 'px',
+        zIndex: 9998
+      };
+    },
+    bubbleStyle() {
+      if (this.fabX === null) {
+        return { position: 'fixed', bottom: '88px', right: '24px', zIndex: 9997 };
+      }
+      // 气泡在FAB上方
+      return {
+        position: 'fixed',
+        left: this.fabX + 'px',
+        top: (this.fabY - 48) + 'px',
+        zIndex: 9997
+      };
+    },
+    hangupButtonIcon() {
+      return this.callStatus !== 'idle' ? 'call_end' : 'phone';
+    },
+    fabOnLeftEdge() {
+      if (this.fabX === null) return false;
+      return this.fabX + 28 < window.innerWidth / 2;
+    },
+    leftButtonIcon() {
+      if (this.callStatus === 'ringing') return 'phone';
+      if (this.callStatus === 'talking') {
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          return this.isOnHold ? 'play_arrow' : 'pause';
+        }
+        return this.phone && this.phone.IsOnHold() ? 'play_arrow' : 'pause';
+      }
+      return '';
+    },
+    rightButtonIcon() {
+      if (this.callStatus === 'ringing') return 'call_end';
+      if (this.callStatus === 'talking') return 'call_split';
+      return '';
+    }
+  },
+  watch: {
+    speakerVolume(val) {
+      if (this.phone) this.phone.SetSpeaker(this.isSpeakerMuted, val);
+    },
+    micVolume(val) {
+      if (this.phone) this.phone.SetMicPhone(this.isMicMuted, val);
+    },
+    dialNumber() {
+      this.updateContentAlignment();
+    },
+    callStatus(val) {
+      if (val === 'ringing' || val === 'talking') {
+        this.panelVisible = true;
+        this.fabIsCollapsed = false;  // 来电/通话时自动展开FAB
+      }
+    },
+    '$route.path'(newPath, oldPath) {
+      if (this.isOnSoftPhonePage) {
+        this.destroyAllConnections();
+      } else if (oldPath && oldPath.includes('/softPhone')) {
+        this.initCCAndStart();
+      }
+    }
+  },
+  async mounted() {
+    this.profileManager = new ProfileManager();
+    const profile = this.profileManager.getProfile();
+    this.userList = profile.users || {};
+    this.currentUserId = profile.user || '';
+    if (this.currentUserId && this.userList[this.currentUserId]) {
+      this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
+    }
+    const settings = this.profileManager.getSettings();
+    this.settingsForm = { ...settings };
+    this.speakerVolume = profile.speaker_volume !== undefined ? profile.speaker_volume : 0.8;
+    this.micVolume = profile.mic_volume !== undefined ? profile.mic_volume : 0.8;
+    this.isSpeakerMuted = profile.speaker_paused || false;
+    this.isMicMuted = profile.mic_paused || false;
+
+    // 加载面板位置
+    this.loadPosition();
+    // 加载FAB按钮位置
+    this.loadFabPosition();
+    window.addEventListener('resize', this.handleWindowResize);
+
+    // 如果不在原softPhone页面,初始化连接
+    if (!this.isOnSoftPhonePage) {
+      await this.initCCAndStart();
+    }
+
+    window.addEventListener('beforeunload', this.handleBeforeUnload);
+
+    // 监听全局外部拨号事件
+    this.$root.$on('floating-softphone-dial', this.dialExternal);
+    // 监听弹窗phoneBar的通话状态同步事件
+    this.$root.$on('dialog-call-ringing', this.onDialogCallRinging);
+    this.$root.$on('dialog-call-talking', this.onDialogCallTalking);
+    this.$root.$on('dialog-call-ended', this.onDialogCallEnded);
+    this.$root.$on('dialog-call-hold', this.onDialogCallHold);
+    this.$root.$on('dialog-call-unhold', this.onDialogCallUnhold);
+  },
+  beforeDestroy() {
+    this.destroyAllConnections();
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    window.removeEventListener('resize', this.handleWindowResize);
+    this.clearAllTimers();
+    this.removeEventListeners();
+    this.$root.$off('floating-softphone-dial', this.dialExternal);
+    this.$root.$off('dialog-call-ringing', this.onDialogCallRinging);
+    this.$root.$off('dialog-call-talking', this.onDialogCallTalking);
+    this.$root.$off('dialog-call-ended', this.onDialogCallEnded);
+    this.$root.$off('dialog-call-hold', this.onDialogCallHold);
+    this.$root.$off('dialog-call-unhold', this.onDialogCallUnhold);
+  },
+  methods: {
+    // ==================== FAB按钮拖拽与吸边 ====================
+    onFabDragStart(e) {
+      if (e.button !== 0) return;
+      this.fabIsDragging = true;
+      this.fabDragMoved = false;
+      const fabEl = e.currentTarget;
+      const rect = fabEl.getBoundingClientRect();
+      this.fabDragOffsetX = e.clientX - rect.left;
+      this.fabDragOffsetY = e.clientY - rect.top;
+      // 如果还没有位置信息,用当前rect初始化
+      if (this.fabX === null) {
+        this.fabX = rect.left;
+        this.fabY = rect.top;
+      }
+      document.addEventListener('mousemove', this.onFabDrag);
+      document.addEventListener('mouseup', this.onFabDragEnd);
+      document.body.style.userSelect = 'none';
+      e.preventDefault();
+    },
+    onFabDrag(e) {
+      if (!this.fabIsDragging) return;
+      this.fabDragMoved = true;
+      const fabSize = 56;
+      let x = e.clientX - this.fabDragOffsetX;
+      let y = e.clientY - this.fabDragOffsetY;
+      // 允许超出边缘一半(折叠效果)
+      x = Math.max(-fabSize / 2, Math.min(x, window.innerWidth - fabSize / 2));
+      y = Math.max(0, Math.min(y, window.innerHeight - fabSize));
+      this.fabX = x;
+      this.fabY = y;
+      // 拖拽时取消折叠状态
+      this.fabIsCollapsed = false;
+    },
+    onFabDragEnd() {
+      this.fabIsDragging = false;
+      document.removeEventListener('mousemove', this.onFabDrag);
+      document.removeEventListener('mouseup', this.onFabDragEnd);
+      document.body.style.userSelect = '';
+      // 吸附到最近的边缘
+      this.snapFabToEdge();
+      this.saveFabPosition();
+    },
+    snapFabToEdge() {
+      if (this.fabX === null) return;
+      const fabSize = 56;
+      const centerX = this.fabX + fabSize / 2;
+      // 吸附到左边或右边
+      if (centerX < window.innerWidth / 2) {
+        this.fabX = -8;  // 左侧折叠偏移
+      } else {
+        this.fabX = window.innerWidth - fabSize + 8;  // 右侧折叠偏移
+      }
+      // 面板打开时不折叠,避免影响操作
+      this.fabIsCollapsed = !this.panelVisible;
+    },
+    onFabClick(e) {
+      // 如果拖拽过程中产生了位移,不触发点击
+      if (this.fabDragMoved) {
+        this.fabDragMoved = false;
+        return;
+      }
+      // 折叠状态:展开并打开面板
+      if (this.fabIsCollapsed) {
+        this.fabIsCollapsed = false;
+        this.panelVisible = true;
+        return;
+      }
+      this.togglePanel();
+    },
+    saveFabPosition() {
+      if (this.fabX !== null) {
+        localStorage.setItem('FloatingSoftPhoneFabPosition', JSON.stringify({
+          x: this.fabX, y: this.fabY
+        }));
+      }
+    },
+    loadFabPosition() {
+      try {
+        const saved = JSON.parse(localStorage.getItem('FloatingSoftPhoneFabPosition'));
+        if (saved && saved.x !== null && saved.x !== undefined) {
+          this.fabX = saved.x;
+          this.fabY = saved.y;
+        }
+      } catch (e) { /* ignore */ }
+      // 如果没有保存的位置,初始化到右下角
+      if (this.fabX === null) {
+        this.fabX = window.innerWidth - 56 + 8;  // 右侧贴边偏移
+        this.fabY = window.innerHeight - 56 - 24;
+      }
+      // 初始加载时折叠贴边
+      this.fabIsCollapsed = true;
+    },
+    clampFabPosition() {
+      if (this.fabX === null) return;
+      const fabSize = 56;
+      this.fabX = Math.max(-fabSize / 2, Math.min(this.fabX, window.innerWidth - fabSize / 2));
+      this.fabY = Math.max(0, Math.min(this.fabY, window.innerHeight - fabSize));
+    },
+
+    // ==================== 外部拨号接口 ====================
+    /**
+     * 外部调用拨号:存储号码并脱敏展示
+     * 号码由调用方通过服务端API获取已解密的明文号码
+     * @param {Object} params - { phone: string, customerName?: string }
+     */
+    dialExternal({ phone, customerName }) {
+      if (!phone) return;
+      this.dialNumber = phone;
+      this.plaintextRealNumber = phone;
+      this.displayText = this.maskNumber(phone);
+      this.panelVisible = true;
+      if (customerName) {
+        this.showStatus(`外呼: ${customerName}`, 'info');
+      }
+    },
+
+    // ==================== 面板控制 ====================
+    togglePanel() {
+      if (this.panelVisible && this.callStatus !== 'idle') {
+        this.$message.warning('通话中无法收起软电话');
+        return;
+      }
+      this.panelVisible = !this.panelVisible;
+      // 面板关闭后自动折叠FAB,面板打开时展开FAB
+      this.fabIsCollapsed = !this.panelVisible && this.fabX !== null;
+    },
+    hidePanel() {
+      if (this.callStatus !== 'idle') {
+        this.$message.warning('通话中无法收起软电话');
+        return;
+      }
+      this.panelVisible = false;
+      this.fabIsCollapsed = this.fabX !== null;
+    },
+
+    // ==================== 拖拽功能 ====================
+    startDrag(e) {
+      if (e.button !== 0) return;
+      // 不拖拽下拉菜单和按钮
+      if (e.target.closest('.user-avatar-dropdown') || e.target.closest('.minimize-btn') || e.target.closest('.volume-control-group')) return;
+      this.isDragging = true;
+      const rect = this.$refs.panel.getBoundingClientRect();
+      this.dragOffsetX = e.clientX - rect.left;
+      this.dragOffsetY = e.clientY - rect.top;
+      document.addEventListener('mousemove', this.onDrag);
+      document.addEventListener('mouseup', this.endDrag);
+      document.body.style.userSelect = 'none';
+      e.preventDefault();
+    },
+    onDrag(e) {
+      if (!this.isDragging) return;
+      const panel = this.$refs.panel;
+      if (!panel) return;
+      let x = e.clientX - this.dragOffsetX;
+      let y = e.clientY - this.dragOffsetY;
+      const maxX = window.innerWidth - panel.offsetWidth;
+      const maxY = window.innerHeight - panel.offsetHeight;
+      x = Math.max(0, Math.min(x, maxX));
+      y = Math.max(0, Math.min(y, maxY));
+      this.panelX = x;
+      this.panelY = y;
+    },
+    endDrag() {
+      this.isDragging = false;
+      document.removeEventListener('mousemove', this.onDrag);
+      document.removeEventListener('mouseup', this.endDrag);
+      document.body.style.userSelect = '';
+      this.savePosition();
+    },
+    savePosition() {
+      if (this.panelX !== null) {
+        localStorage.setItem('FloatingSoftPhonePosition', JSON.stringify({
+          x: this.panelX, y: this.panelY
+        }));
+      }
+    },
+    loadPosition() {
+      try {
+        const saved = JSON.parse(localStorage.getItem('FloatingSoftPhonePosition'));
+        if (saved && saved.x !== null && saved.x !== undefined) {
+          this.panelX = saved.x;
+          this.panelY = saved.y;
+          this.$nextTick(() => this.clampPosition());
+        }
+      } catch (e) { /* ignore */ }
+    },
+    clampPosition() {
+      if (this.panelX === null) return;
+      this.$nextTick(() => {
+        const panel = this.$refs.panel;
+        if (!panel) return;
+        const maxX = window.innerWidth - panel.offsetWidth;
+        const maxY = window.innerHeight - panel.offsetHeight;
+        this.panelX = Math.max(0, Math.min(this.panelX, maxX));
+        this.panelY = Math.max(0, Math.min(this.panelY, maxY));
+      });
+    },
+    handleWindowResize() {
+      this.clampPosition();
+      this.clampFabPosition();
+    },
+
+    // ==================== 号码管理 ====================
+    /** 生成脱敏格式:前3 + **** + 后3 */
+    maskNumber(number) {
+      if (!number || number.length < 7) return number || '';
+      return number.substring(0, 3) + '****' + number.substring(number.length - 3);
+    },
+    /** 清除全部号码 */
+    clearNumber() {
+      this.dialNumber = '';
+      this.plaintextRealNumber = '';
+      this.displayText = '';
+    },
+    /**
+     * 键盘按下事件:处理删除和数字输入
+     * 允许:Backspace/Delete(原生行为删除displayText中的字符)
+     * 允许:数字0-9、*、#(追加到displayText)
+     * 拦截:其他所有输入
+     */
+    handleKeydown(event) {
+      const key = event.key;
+      // 允许的控制键
+      if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab'].includes(key)) {
+        // 通话中 Backspace/Delete 不操作
+        if ((key === 'Backspace' || key === 'Delete') && this.callStatus === 'talking') {
+          event.preventDefault();
+          return;
+        }
+        // 允许原生行为(光标移动和删除)
+        return;
+      }
+      // Enter 触发拨号/挂机
+      if (key === 'Enter') {
+        event.preventDefault();
+        this.onHangupClick();
+        return;
+      }
+      // 数字和符号:只在无真实号码时允许输入
+      if ((key >= '0' && key <= '9') || key === '*' || key === '#') {
+        if (this.realNumber) {
+          // 已有真实号码(脱敏状态),不允许追加
+          event.preventDefault();
+          return;
+        }
+        // 通话中发送DTMF
+        if (this.callStatus === 'talking' && this.phone) {
+          this.phone.SendDTMF(key);
+          this.phone.PlayDtmfTone(key);
+          event.preventDefault();
+          return;
+        }
+        // 允许原生输入(会被handleInput捕获同步到dialNumber)
+        return;
+      }
+      // 其他字符一律拦截
+      event.preventDefault();
+    },
+    /**
+     * 输入变化事件:同步displayText到dialNumber(无脱敏状态时)
+     */
+    handleInput() {
+      if (!this.realNumber) {
+        // 无真实号码时,displayText就是dialNumber
+        this.dialNumber = this.displayText;
+      }
+      // 有真实号码时(脱敏状态),displayText变化意味着用户在删除字符
+      // 这里不需要额外操作,因为删除是通过原生Backspace完成的
+    },
+    /**
+     * 右侧删除按钮:模拟在光标位置删除
+     */
+    deleteAtCursor() {
+      const input = this.$refs.dialerInput;
+      if (!input || !this.displayText) return;
+      const pos = input.selectionStart;
+      if (pos === null || pos === 0) {
+        // 没有光标位置或在开头,删末尾
+        this.displayText = this.displayText.slice(0, -1);
+      } else {
+        // 删除光标前一位
+        this.displayText = this.displayText.slice(0, pos - 1) + this.displayText.slice(pos);
+        this.$nextTick(() => {
+          input.setSelectionRange(pos - 1, pos - 1);
+        });
+      }
+      // 同步dialNumber
+      if (!this.realNumber) {
+        this.dialNumber = this.displayText;
+      }
+    },
+
+    // ==================== UI事件 ====================
+    getLeftButtonTitle() {
+      if (this.callStatus === 'ringing') return '接听来电';
+      if (this.callStatus === 'talking') return this.isOnHold ? '恢复通话' : '保持通话';
+      return '';
+    },
+    getHangupButtonTitle() {
+      if (this.callStatus !== 'idle') return '结束通话';
+      if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) return '发起外呼';
+      // 委托模式下:有号码时显示可拨号
+      if (this.realNumber) return '发起外呼';
+      return '未就绪';
+    },
+    getRightButtonTitle() {
+      if (this.callStatus === 'ringing') return '拒绝来电';
+      if (this.callStatus === 'talking') return '呼叫转移';
+      return '';
+    },
+    getHangupButtonClass() {
+      if (this.callStatus !== 'idle') return 'hangup';
+      if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) return 'call-ready';
+      // 委托模式下:有号码时可拨号(实际由弹窗phoneBar执行)
+      if (this.realNumber) return 'call-ready';
+      return 'disabled';
+    },
+    updateContentAlignment() {
+      this.$nextTick(() => {
+        const inputEl = this.$refs.dialerInput;
+        if (!inputEl) return;
+        this.isContentFit = inputEl.scrollWidth <= inputEl.clientWidth;
+      });
+    },
+    scrollInputToEnd() {
+      const inputEl = this.$refs.dialerInput;
+      if (!inputEl) return;
+      this.$nextTick(() => {
+        inputEl.focus();
+        inputEl.setSelectionRange(this.dialNumber.length, this.dialNumber.length);
+        inputEl.scrollLeft = inputEl.scrollWidth - inputEl.clientWidth;
+      });
+    },
+    restoreCursorAfterDelete(start, end) {
+      const inputEl = this.$refs.dialerInput;
+      if (!inputEl) return;
+      this.$nextTick(() => {
+        inputEl.focus();
+        inputEl.setSelectionRange(start, end);
+        this.updateContentAlignment();
+      });
+    },
+    deleteCharByCursor() {
+      const inputEl = this.$refs.dialerInput;
+      if (!inputEl) return;
+      if (document.activeElement !== inputEl) {
+        inputEl.focus();
+        inputEl.setSelectionRange(this.dialNumber.length, this.dialNumber.length);
+      }
+      const cursorPos = inputEl.selectionStart || this.dialNumber.length;
+      if (cursorPos > 0) {
+        this.dialNumber = this.dialNumber.slice(0, cursorPos - 1) + this.dialNumber.slice(cursorPos);
+        this.restoreCursorAfterDelete(cursorPos - 1, cursorPos - 1);
+      }
+    },
+    showStatus(text, type = 'info') {
+      if (this.statusTimerId) { clearTimeout(this.statusTimerId); this.statusTimerId = null; }
+      this.statusText = text;
+      this.statusType = type;
+      if (text === '就绪') return;
+      this.statusTimerId = setTimeout(() => {
+        if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) {
+          this.statusText = '就绪';
+          this.statusType = 'success';
+        } else {
+          this.statusText = '';
+        }
+        this.statusTimerId = null;
+      }, 10000);
+    },
+
+    // ==================== 呼叫中心集成 ====================
+    async initCCAndStart() {
+      this.ccSocketConnected = false;
+      this.ccSocketFailed = false;
+      this.isCallingReady = false;
+      try {
+        await this.ensureCCSocketConnect();
+        await this.startPhone();
+      } catch (err) {
+        this.ccSocketFailed = true;
+        this.showStatus(`初始化失败: ${err.message}`, 'error');
+      }
+    },
+    ensureCCSocketConnect() {
+      if (this.ccSocketConnected) return Promise.resolve();
+      if (this.ccConnectingPromise) return this.ccConnectingPromise;
+      this.ccConnectingPromise = new Promise((resolve, reject) => {
+        this.ccConnectingResolve = resolve;
+        this.ccConnectingReject = reject;
+        this._doConnectCCSocket();
+      });
+      return this.ccConnectingPromise;
+    },
+    async _doConnectCCSocket() {
+      try {
+        const extRes = await myCallUser();
+        if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
+          throw new Error('未查询到分机号信息');
+        }
+        const { extNum, extPass, gatewayIds: myGateway } = extRes.data;
+        this.setupDefaultAccount(extNum, extPass);
+        const basicRes = await getToolbarBasicParam({ extNum, myGateway });
+        if (basicRes.code !== 0) throw new Error(basicRes.message || '获取配置失败');
+        const configData = basicRes.data;
+        if (!configData.loginToken) throw new Error('登录令牌无效');
+
+        const callConfig = {
+          useDefaultUi: false,
+          loginToken: configData.loginToken,
+          ipccServer: IPCC_CONFIG.SERVER_PROD,
+          gatewayList: configData.gatewayList,
+          gatewayEncrypted: false,
+          extPassword: configData.encryptPsw,
+          extnum: extNum,
+          opnum: configData.opNum || configData.userName,
+          enableWss: true,
+          enableHeartBeat: true,
+          heartBeatIntervalSecs: 16
+        };
+
+        this.ccPhoneBar = new ccPhoneBarSocket();
+        this.ccPhoneBar.initConfig(callConfig);
+        this._bindCCEvents();
+        this.ccPhoneBar.connect();
+
+        const timeoutId = setTimeout(() => {
+          if (!this.ccSocketConnected && this.ccConnectingReject) {
+            this.ccConnectingReject(new Error('连接超时'));
+          }
+        }, IPCC_CONFIG.CONNECT_TIMEOUT);
+
+        const originalResolve = this.ccConnectingResolve;
+        this.ccConnectingResolve = () => {
+          clearTimeout(timeoutId);
+          if (originalResolve) originalResolve();
+        };
+      } catch (err) {
+        if (this.ccConnectingReject) this.ccConnectingReject(err);
+        this.ccConnectingPromise = null;
+        throw err;
+      }
+    },
+    _bindCCEvents() {
+      this.ccPhoneBar.on(EventList.WS_CONNECTED, () => {
+        this.ccSocketConnected = true;
+        this.ccSocketFailed = false;
+        if (this.ccConnectingResolve) this.ccConnectingResolve();
+        this.ccConnectingPromise = null;
+      });
+      this.ccPhoneBar.on(EventList.WS_DISCONNECTED, () => {
+        this.ccSocketConnected = false;
+        this.isCallingReady = false;
+        if (this.phone && this.isRegistered) this.showStatus('连接断开', 'warn');
+      });
+      this.ccPhoneBar.on(EventList.STATUS_CHANGED, (msg) => {
+        if (msg?.object) {
+          const statusCode = msg.object.status;
+          const busyStatuses = [AgentStatusEnum.BUSY, AgentStatusEnum.BUSY_REST, AgentStatusEnum.BUSY_MEETING, AgentStatusEnum.BUSY_TRAINING];
+          this.isCallingReady = busyStatuses.includes(statusCode);
+        }
+      });
+      this.ccPhoneBar.on(EventList.REQUEST_ARGS_ERROR, () => {
+        if (!this.ccSocketConnected && this.ccConnectingReject) this.ccConnectingReject(new Error('请求参数错误'));
+      });
+      this.ccPhoneBar.on(EventList.SERVER_ERROR, () => {
+        if (!this.ccSocketConnected && this.ccConnectingReject) this.ccConnectingReject(new Error('服务器错误'));
+      });
+      this.ccPhoneBar.on(EventList.OUTBOUND_START, (msg) => {
+        this.showStatus('拨号中...', 'info');
+        if (msg?.object?.uuid) {
+          this.currentCallUuid = msg.object.uuid;
+          this.callUuidMap[msg.object.uuid] = { startTime: Date.now(), phoneNumber: this.dialNumber, status: 'outbound_start' };
+        }
+      });
+      this.ccPhoneBar.on(EventList.CALLEE_RINGING, () => {
+        this.callStatus = UI_STATE.RINGING;
+        this.showStatus('振铃中...', 'info');
+        if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
+          this.callUuidMap[this.currentCallUuid].status = 'ringing';
+        }
+      });
+
+      const handleCallAnswered = (msg) => {
+        if (msg?.object?.uuid) {
+          const realUuid = msg.object.uuid;
+          if (!this.currentCallUuid || this.currentCallUuid !== realUuid) {
+            if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
+              this.callUuidMap[realUuid] = this.callUuidMap[this.currentCallUuid];
+              delete this.callUuidMap[this.currentCallUuid];
+            } else if (!this.currentCallUuid) {
+              this.callUuidMap[realUuid] = { startTime: Date.now(), phoneNumber: this.dialNumber, status: 'answered' };
+            }
+            this.currentCallUuid = realUuid;
+          }
+        }
+        this.callStatus = UI_STATE.TALKING;
+        this.showLeftButton = true;
+        this.showRightButton = true;
+        this.leftButtonNormal = true;
+        this.rightButtonNormal = true;
+        this.showStatus('通话中', 'success');
+        if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
+          this.callUuidMap[this.currentCallUuid].status = 'answered';
+          this.callUuidMap[this.currentCallUuid].answerTime = Date.now();
+        }
+      };
+      this.ccPhoneBar.on(EventList.CALLER_ANSWERED, handleCallAnswered);
+      this.ccPhoneBar.on(EventList.CALLEE_ANSWERED, handleCallAnswered);
+
+      const handleCallHangup = () => {
+        const callUuid = this.currentCallUuid;
+        if (callUuid && this.callUuidMap[callUuid]) {
+          this.callUuidMap[callUuid].status = 'ended';
+          this.callUuidMap[callUuid].endTime = Date.now();
+        }
+        // 如果是转人工来电,挂断时保留导航意图给onSessionClosed使用
+        if (this.incomingJsipCall) {
+          this.pendingManualNavigation = true;
+        }
+        this._resetCallState();
+        this.showStatus('已挂机', 'info');
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
+        }
+        this._handleCallEnd(callUuid);
+      };
+      this.ccPhoneBar.on(EventList.CALLER_HANGUP, handleCallHangup);
+      this.ccPhoneBar.on(EventList.CALLEE_HANGUP, handleCallHangup);
+
+      this.ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
+        this.isOnHold = true;
+        this.showStatus('暂停通话中', 'warn');
+      });
+      this.ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
+        this.isOnHold = false;
+        this.showStatus('通话中', 'success');
+      });
+    },
+    setupDefaultAccount(extNum, extPass) {
+      if (!extNum || !extPass) return;
+      extNum = String(extNum).trim();
+      extPass = String(extPass).trim();
+      if (!extNum || !extPass) return;
+
+      let existingUserId = null;
+      for (const [id, user] of Object.entries(this.userList)) {
+        if (user.user === extNum) { existingUserId = id; break; }
+      }
+
+      if (existingUserId) {
+        const updatedProfile = {
+          ...this.userList[existingUserId],
+          note: extNum, display_name: extNum, password: extPass, user: extNum,
+          domain: this.userList[existingUserId].domain || IPCC_CONFIG.SERVER_PROD,
+          server: this.userList[existingUserId].server || `wss://${IPCC_CONFIG.SERVER_PROD}`,
+          transport: this.userList[existingUserId].transport || 'wss'
+        };
+        this.profileManager.updateUser(existingUserId, updatedProfile);
+        if (this.currentUserId !== existingUserId) {
+          this.profileManager.switchUser(existingUserId);
+          this.currentUserId = existingUserId;
+        }
+      } else {
+        const newProfile = {
+          note: extNum, user: extNum, domain: SIP_DEFAULT_CONFIG.DOMAIN,
+          password: extPass, display_name: extNum,
+          server: SIP_DEFAULT_CONFIG.SERVER, transport: SIP_DEFAULT_CONFIG.TRANSPORT
+        };
+        this.profileManager.addUser(newProfile);
+        const updatedProfile = this.profileManager.getProfile();
+        this.userList = updatedProfile.users;
+        for (const [id, user] of Object.entries(this.userList)) {
+          if (user.user === extNum) { this.currentUserId = id; this.profileManager.switchUser(id); break; }
+        }
+      }
+
+      if (this.currentUserId && this.userList[this.currentUserId]) {
+        this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
+      }
+      const profile = this.profileManager.getProfile();
+      this.speakerVolume = profile.speaker_volume ?? VOLUME_CONFIG.DEFAULT;
+      this.micVolume = profile.mic_volume ?? VOLUME_CONFIG.DEFAULT;
+      this.isSpeakerMuted = profile.speaker_paused || false;
+      this.isMicMuted = profile.mic_paused || false;
+    },
+    async startPhone() {
+      if (!this.ccSocketConnected) { this.showStatus('未连接', 'error'); return; }
+      const userProfile = this.profileManager.getCurrentUserProfile();
+      if (!userProfile) { this.showStatus('无可用账号', 'warn'); return; }
+      if (!userProfile.user || !userProfile.domain || !userProfile.password) { this.showStatus('账号配置错误', 'error'); return; }
+
+      await checkMicrophonePermission();
+
+      if (this.phone) { this.phone.destroy(); this.phone = null; }
+
+      const settings = this.profileManager.getSettings();
+      this.phone = new WebPhone(userProfile, settings);
+      this.phone.On('OnRegister', this.onRegisterEvent);
+      this.phone.On('OnSessionCreated', this.onSessionCreated);
+      this.phone.On('OnRing', this.onRing);
+      this.phone.On('OnAnswered', this.onAnswered);
+      this.phone.On('OnSessionClosed', this.onSessionClosed);
+      this.phone.On('OnCallTimer', this.onCallTimer);
+      this.phone.On('OnStatusMessage', this.onStatusMessage);
+      this.phone.On('OnReconnectStatus', this.onReconnectStatus);
+      this.phone.Start(settings.reconnect);
+    },
+    onReconnectStatus({ isReconnecting, failed }) {
+      this.isReconnecting = isReconnecting;
+      this.reconnectFailed = failed;
+      if (failed) this.showStatus('连接超时', 'error');
+      else if (isReconnecting) this.showStatus('重连中...', 'info');
+    },
+    onStatusMessage({ type, text }) { this.showStatus(text, type); },
+    onRegisterEvent(event) {
+      this.isRegistered = event.registered;
+      this.isConnected = event.registered;
+      if (event.registered) {
+        this.currentUserDisplay = this.userList[this.currentUserId]?.note || this.userList[this.currentUserId]?.user || '';
+        this.showStatus('就绪', 'success');
+        if (this.ccPhoneBar && this.ccSocketConnected) this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
+      } else if (!this.isReconnecting) {
+        this.showStatus('未注册', 'warn');
+      }
+    },
+    onSessionCreated(event) {
+      // 不再自动接听,由来电振铃 UI 等待用户手动操作
+    },
+    onRing(event) {
+      if (!event.outgoing) {
+        this.incomingJsipCall = true;
+        this.incomingCaller = event.caller || '';
+        this.callStatus = UI_STATE.RINGING;
+        this.province = `${event.province || ''}${event.city ? '-' + event.city : ''}`;
+        this.showLeftButton = true;
+        this.showRightButton = true;
+        this.rightButtonHangup = true;
+        if (this.incomingCaller) {
+          this.displayText = this.maskNumber(this.incomingCaller);
+        }
+        this.showStatus('来电振铃中...', 'info');
+      }
+    },
+    onAnswered() {
+      this.callStatus = UI_STATE.TALKING;
+      this.showLeftButton = true;
+      this.showRightButton = true;
+      this.leftButtonNormal = true;
+      this.rightButtonNormal = true;
+      this.rightButtonHangup = false;
+      this.showStatus('通话中', 'success');
+    },
+    _resetCallState() {
+      if (this.callStatus !== UI_STATE.IDLE) {
+        this.callStatus = UI_STATE.IDLE;
+        this.isOnHold = false;
+        this.province = '';
+        this.callDuration = '00:00';
+        this.showLeftButton = false;
+        this.showRightButton = false;
+        this.leftButtonNormal = false;
+        this.rightButtonNormal = false;
+        this.rightButtonHangup = false;
+      }
+      this.delegatedCallActive = false;
+      this.incomingJsipCall = false;
+      this.incomingCaller = '';
+      // 注意:不在_resetCallState中重置pendingManualNavigation,它由onSessionClosed消费
+    },
+    onSessionClosed() {
+      // 转人工来电(incomingJsipCall)或主动设置的导航标记,挂断后都跳转
+      const shouldNavigate = this.incomingJsipCall || this.pendingManualNavigation;
+      this.showStatus('已挂机', 'info');
+      this._resetCallState();
+      // 转人工来电挂断后,跳转到转人工通话列表页面
+      if (shouldNavigate) {
+        this.pendingManualNavigation = false;
+        this.$router.push({ path: '/companyWx/companyWorkflow/manual', query: { t: Date.now() } });
+        this.$message({ message: '数据正在加载请稍后', type: 'info', duration: 3000 });
+      }
+    },
+    onCallTimer(time) { this.callDuration = time; },
+
+    // ==================== 弹窗通话状态同步 ====================
+    onDialogCallRinging() {
+      this.callStatus = UI_STATE.RINGING;
+      this.showLeftButton = true;
+      this.showRightButton = true;
+      this.rightButtonHangup = true;
+      this.delegatedCallActive = true;  // 来电由弹窗phoneBar处理,标记为委托通话
+      this.showStatus('振铃中...', 'info');
+    },
+    onDialogCallTalking() {
+      this.callStatus = UI_STATE.TALKING;
+      this.showLeftButton = true;
+      this.showRightButton = true;
+      this.leftButtonNormal = true;
+      this.rightButtonNormal = true;
+      this.rightButtonHangup = false;
+      this.showStatus('通话中', 'success');
+    },
+    onDialogCallEnded() {
+      this._resetCallState();
+      this.showStatus('已挂机', 'info');
+    },
+    onDialogCallHold() {
+      this.isOnHold = true;
+      this.showStatus('暂停通话中', 'warn');
+    },
+    onDialogCallUnhold() {
+      this.isOnHold = false;
+      this.showStatus('通话中', 'success');
+    },
+
+    // ==================== 外呼/挂机 ====================
+    async makeCall() {
+      if (this.callStatus !== UI_STATE.IDLE) { this.showStatus('请先结束当前通话', 'warn'); return; }
+
+      const phoneNumber = this.plaintextRealNumber || this.dialNumber.trim();
+      if (!phoneNumber || phoneNumber.length < 3) { this.showStatus('请输入正确的号码', 'warn'); return; }
+      // 通知弹窗组件通过其phoneBar执行外呼(走弹窗完整的通话记录、客户信息流程)
+      // 不再通过浮动电话自己的ccPhoneBar.call()拨号,避免创建独立通话
+      this.$root.$emit('floating-softphone-call-triggered', phoneNumber);
+      this.delegatedCallActive = true;
+      this.showStatus('拨号中...', 'info');
+    },
+    endCall() {
+      if (this.callStatus === UI_STATE.IDLE) { this.showStatus('当前无通话', 'warn'); return; }
+      // 委托通话时,通知弹窗挂机
+      if (this.delegatedCallActive) {
+        this.$root.$emit('floating-softphone-hangup-triggered');
+        return;
+      }
+      // JsSIP直接来电(转人工等):同时通知呼叫中心挂机(确保客户方断开)并终止SIP会话
+      if (this.incomingJsipCall) {
+        this.pendingManualNavigation = true;
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          try { this.ccPhoneBar.hangup(); } catch (err) { /* ignore */ }
+        }
+        if (this.phone) {
+          try { this.phone.Terminate(); } catch (err) { this.showStatus('挂机失败', 'error'); }
+        }
+      } else if (this.ccPhoneBar && this.ccSocketConnected) {
+        try { this.ccPhoneBar.hangup(); } catch (err) { this.showStatus('挂机失败', 'error'); }
+      } else if (this.phone) {
+        try { this.phone.Terminate(); } catch (err) { this.showStatus('挂机失败', 'error'); }
+      } else {
+        this.showStatus('无法挂机:未连接', 'error');
+      }
+    },
+    onDigitClick(digit) {
+      if (this.callStatus === 'talking' && this.phone) {
+        this.phone.SendDTMF(digit);
+        this.phone.PlayDtmfTone(digit);
+        return;
+      }
+      // 如果已有真实号码(从外部传入的脱敏号码),不允许追加
+      if (this.realNumber) return;
+      this.dialNumber += digit;
+      this.displayText += digit;
+    },
+    onHangupClick() {
+      if (this.callStatus !== UI_STATE.IDLE) this.endCall();
+      else this.makeCall();
+    },
+    onLeftButtonClick() {
+      if (this.callStatus === UI_STATE.RINGING) {
+        if (this.phone) this.phone.Answer();
+      } else if (this.callStatus === UI_STATE.TALKING) {
+        // 委托通话时,通知弹窗执行保持/恢复
+        if (this.delegatedCallActive) {
+          this.$root.$emit('floating-softphone-hold-triggered');
+          return;
+        }
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          try { if (this.isOnHold) this.ccPhoneBar.unHoldCall(); else this.ccPhoneBar.holdCall(); }
+          catch (err) { this.showStatus('操作失败', 'error'); }
+        } else if (this.phone) {
+          try { this.phone.ToggleHold(); } catch (err) { this.showStatus('操作失败', 'error'); }
+        }
+      }
+    },
+    onRightButtonClick() {
+      if (this.callStatus === UI_STATE.RINGING) this.endCall();
+      else if (this.callStatus === UI_STATE.TALKING) this.showStatus('呼叫转移功能暂未实现', 'info');
+    },
+
+    // ==================== 音频控制 ====================
+    toggleMuteMic() {
+      if (!this.phone) return;
+      this.isMicMuted = !this.isMicMuted;
+      this.phone.ToggleMicPhone();
+      if (!this.isMicMuted) this.micVolume = this.profileManager.getProfile().mic_volume || 0.8;
+    },
+    toggleMuteSpeaker() {
+      if (!this.phone) return;
+      this.isSpeakerMuted = !this.isSpeakerMuted;
+      this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume);
+      if (!this.isSpeakerMuted) this.speakerVolume = this.profileManager.getProfile().speaker_volume || 0.8;
+    },
+    changeMicVolume(event) { this.micVolume = parseFloat(event.target.value); },
+    changeSpeakerVolume(event) { this.speakerVolume = parseFloat(event.target.value); },
+    showMicSlider() { this.cancelHideSliderTimer(); this.speakerSliderVisible = false; this.micSliderVisible = true; },
+    showSpeakerSlider() { this.cancelHideSliderTimer(); this.micSliderVisible = false; this.speakerSliderVisible = true; },
+    startHideSliderTimer() {
+      if (this.volumeTimerId) clearTimeout(this.volumeTimerId);
+      this.volumeTimerId = setTimeout(() => { this.micSliderVisible = this.speakerSliderVisible = false; }, 1000);
+    },
+    cancelHideSliderTimer() { if (this.volumeTimerId) { clearTimeout(this.volumeTimerId); this.volumeTimerId = null; } },
+
+    // ==================== 账号管理 ====================
+    toggleDropdown() { this.dropdownVisible = !this.dropdownVisible; },
+    closeDropdown() { this.dropdownVisible = false; },
+    async switchAccount(userId) {
+      if (userId === this.currentUserId) { this.closeDropdown(); return; }
+      this.profileManager.switchUser(userId);
+      this.currentUserId = userId;
+      const user = this.userList[userId];
+      this.currentUserDisplay = user.note || user.user;
+      if (this.phone) { this.phone.destroy(); this.phone = null; }
+      if (!this.ccSocketConnected) { this.showStatus('呼叫中心连接已断开', 'error'); return; }
+      await this.startPhone();
+      this.closeDropdown();
+    },
+    openAddAccountDialog() {
+      this.isEditMode = false;
+      this.editingUserId = null;
+      this.accountDialogTitle = '添加账号';
+      this.accountForm = { note: '', server: SIP_DEFAULT_CONFIG.SERVER, username: '', domain: SIP_DEFAULT_CONFIG.DOMAIN, loginName: '', password: '', transport: SIP_DEFAULT_CONFIG.TRANSPORT };
+      this.showPassword = false;
+      this.accountDialogVisible = true;
+    },
+    openEditAccountDialog() {
+      const profile = this.profileManager.getCurrentUserProfile();
+      if (!profile) return;
+      this.isEditMode = true;
+      this.editingUserId = this.currentUserId;
+      this.accountDialogTitle = '编辑账号';
+      this.accountForm = {
+        note: profile.note || profile.display_name,
+        server: profile.server,
+        username: profile.display_name,
+        domain: profile.domain,
+        loginName: profile.user,
+        password: profile.password,
+        transport: profile.transport || 'wss'
+      };
+      this.showPassword = false;
+      this.accountDialogVisible = true;
+    },
+    saveAccount() {
+      try {
+        if (!this.accountForm.loginName || !this.accountForm.domain || !this.accountForm.password) {
+          this.$message.warning('登录名、域名和密码为必填项');
+          return;
+        }
+        if (this.isEditMode && this.editingUserId) {
+          const updatedProfile = {
+            note: this.accountForm.note, server: this.accountForm.server,
+            display_name: this.accountForm.username, password: this.accountForm.password,
+            transport: this.accountForm.transport,
+            user: this.userList[this.editingUserId].user, domain: this.userList[this.editingUserId].domain
+          };
+          this.profileManager.updateUser(this.editingUserId, updatedProfile);
+          this.showStatus('账号已更新', 'success');
+        } else {
+          const profile = {
+            note: this.accountForm.note, user: this.accountForm.loginName,
+            domain: this.accountForm.domain, password: this.accountForm.password,
+            display_name: this.accountForm.username, server: this.accountForm.server,
+            transport: this.accountForm.transport
+          };
+          this.profileManager.addUser(profile);
+          this.showStatus('账号已添加', 'success');
+        }
+        this.userList = this.profileManager.getProfile().users;
+        this.currentUserId = this.profileManager.getProfile().user;
+        if (this.currentUserId && this.userList[this.currentUserId]) {
+          this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
+        }
+        this.accountDialogVisible = false;
+        if (this.phone) { this.phone.destroy(); this.phone = null; }
+        if (this.ccSocketConnected) this.startPhone();
+      } catch (err) {
+        this.$message.error(err.message);
+      }
+    },
+    confirmDeleteAccount() {
+      this.$confirm('删除账号将清除本地配置,确认删除?', '提示', {
+        confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        this.profileManager.deleteCurrentUser();
+        const newProfile = this.profileManager.getProfile();
+        this.userList = newProfile.users || {};
+        this.currentUserId = newProfile.user || '';
+        this.currentUserDisplay = (this.currentUserId && this.userList[this.currentUserId])
+          ? (this.userList[this.currentUserId].note || this.userList[this.currentUserId].user) : '';
+        if (this.phone) { this.phone.destroy(); this.phone = null; }
+        if (this.ccSocketConnected) this.startPhone();
+        this.showStatus('账号已删除', 'info');
+      }).catch(() => {});
+    },
+    saveSettings() {
+      this.profileManager.updateSettings({
+        user_agent: this.settingsForm.userAgent, session_expires: this.settingsForm.sessionExpires,
+        min_session_expires: this.settingsForm.minSessionExpires, stun: this.settingsForm.stun,
+        ice_server: this.settingsForm.iceServer, auto_answer: false,
+        reconnect: this.settingsForm.reconnect, reconnect_interval: this.settingsForm.reconnectInterval
+      });
+      this.settingsDialogVisible = false;
+      if (this.phone) { this.phone.destroy(); this.phone = null; }
+      this.startPhone();
+      this.showStatus('设置已保存', 'success');
+    },
+    resetSettings() {
+      this.profileManager.resetSettings();
+      const settings = this.profileManager.getSettings();
+      this.settingsForm = { ...settings };
+      this.showStatus('设置已重置', 'success');
+    },
+    async resetReconnectState() {
+      this.showStatus('正在重置...', 'info');
+      if (this.volumeTimerId) { clearTimeout(this.volumeTimerId); this.volumeTimerId = null; }
+      if (this.statusTimerId) { clearTimeout(this.statusTimerId); this.statusTimerId = null; }
+      if (this.phone) { this.phone.destroy(); this.phone = null; }
+      if (this.ccPhoneBar) { try { this.ccPhoneBar.disconnect(); } catch(e) {} this.ccPhoneBar = null; }
+      this.ccSocketConnected = false;
+      this.ccSocketFailed = false;
+      this.isCallingReady = false;
+      this.isReconnecting = false;
+      this.reconnectFailed = false;
+      this.isConnected = false;
+      this.isRegistered = false;
+      this.callStatus = UI_STATE.IDLE;
+      if (this.ccConnectingPromise) {
+        if (this.ccConnectingReject) { try { this.ccConnectingReject(new Error('用户主动重置')); } catch(e) {} }
+        this.ccConnectingPromise = null;
+        this.ccConnectingResolve = null;
+        this.ccConnectingReject = null;
+      }
+      this.dropdownVisible = false;
+      try {
+        await this.initCCAndStart();
+        this.showStatus('重连成功', 'success');
+      } catch (err) {
+        this.showStatus('重连失败', 'error');
+      }
+    },
+
+    // ==================== 清理 ====================
+    destroyAllConnections() {
+      this.clearAllTimers();
+      if (this.phone) { try { this.phone.destroy(); } catch(e) {} this.phone = null; }
+      if (this.ccPhoneBar) { try { this.ccPhoneBar.disconnect(); } catch(e) {} this.ccPhoneBar = null; }
+      this.callUuidMap = {};
+      this.currentCallUuid = '';
+      this.resetAllStates();
+    },
+    clearAllTimers() {
+      if (this.volumeTimerId) { clearTimeout(this.volumeTimerId); this.volumeTimerId = null; }
+      if (this.statusTimerId) { clearTimeout(this.statusTimerId); this.statusTimerId = null; }
+    },
+    resetAllStates() {
+      this.ccSocketConnected = false;
+      this.ccSocketFailed = false;
+      this.isCallingReady = false;
+      this.isReconnecting = false;
+      this.reconnectFailed = false;
+      this.isConnected = false;
+      this.isRegistered = false;
+      this.callStatus = UI_STATE.IDLE;
+      this.isOnHold = false;
+      this.dropdownVisible = false;
+      this.accountDialogVisible = false;
+      this.settingsDialogVisible = false;
+      this.micSliderVisible = false;
+      this.speakerSliderVisible = false;
+    },
+    removeEventListeners() {
+      if (this.phone) {
+        try {
+          this.phone.Off('OnRegister', this.onRegisterEvent);
+          this.phone.Off('OnSessionCreated', this.onSessionCreated);
+          this.phone.Off('OnRing', this.onRing);
+          this.phone.Off('OnAnswered', this.onAnswered);
+          this.phone.Off('OnSessionClosed', this.onSessionClosed);
+          this.phone.Off('OnCallTimer', this.onCallTimer);
+          this.phone.Off('OnStatusMessage', this.onStatusMessage);
+          this.phone.Off('OnReconnectStatus', this.onReconnectStatus);
+        } catch(e) {}
+      }
+    },
+    handleBeforeUnload() { this.destroyAllConnections(); },
+
+    // ==================== 通话记录 ====================
+    _handleCallEnd(callUuid) {
+      if (!callUuid) return;
+      setTimeout(() => {
+        syncByUuid({ uuid: callUuid }).then(() => {
+          const uuidKeys = Object.keys(this.callUuidMap);
+          if (uuidKeys.length > 10) delete this.callUuidMap[uuidKeys[0]];
+        }).catch(() => {});
+      }, 10000);
+    }
+  }
+};
+</script>
+
+<style scoped>
+@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
+
+/* ===== FAB按钮 ===== */
+.softphone-fab {
+  position: fixed;
+  bottom: 24px;
+  right: 24px;
+  width: 56px;
+  height: 56px;
+  border-radius: 50%;
+  background: #006CFF;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  box-shadow: 0 4px 12px rgba(0, 108, 255, 0.4);
+  z-index: 9998;
+  transition: transform 0.3s ease, box-shadow 0.2s, background 0.2s, width 0.3s ease, border-radius 0.3s ease, opacity 0.3s ease;
+}
+.softphone-fab:hover {
+  transform: scale(1.08);
+  box-shadow: 0 6px 16px rgba(0, 108, 255, 0.5);
+}
+.softphone-fab.fab-active {
+  background: #555;
+}
+.softphone-fab.fab-connected:not(.fab-active) {
+  background: #4caf50;
+  box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
+}
+/* 折叠状态:缩小并半隐藏到边缘 */
+.softphone-fab.fab-collapsed {
+  width: 36px;
+  height: 56px;
+  border-radius: 18px 0 0 18px;
+  opacity: 0.6;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+.softphone-fab.fab-collapsed.fab-left {
+  border-radius: 0 18px 18px 0;
+}
+.softphone-fab.fab-collapsed:hover {
+  opacity: 1;
+  box-shadow: 0 4px 12px rgba(0, 108, 255, 0.5);
+}
+.softphone-fab.fab-collapsed.fab-connected {
+  opacity: 0.7;
+}
+.softphone-fab.fab-collapsed .material-icons {
+  font-size: 20px;
+}
+.softphone-fab .material-icons {
+  font-size: 28px;
+  color: #fff;
+  transition: font-size 0.3s ease;
+}
+.softphone-fab .fab-badge {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  background: #f44336;
+  border: 2px solid #fff;
+  animation: pulse 1.2s infinite;
+}
+/* 振铃时FAB脉冲动画 */
+.softphone-fab.fab-ringing {
+  animation: fab-ringing-pulse 1s infinite;
+}
+@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); }
+  100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); }
+}
+.softphone-fab.fab-ringing:not(.fab-collapsed) {
+  background: #f44336;
+}
+
+/* ===== 来电提示气泡 ===== */
+.incoming-call-bubble {
+  position: fixed;
+  background: linear-gradient(135deg, #f44336, #e53935);
+  color: #fff;
+  border-radius: 24px;
+  padding: 6px 16px;
+  box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
+  cursor: pointer;
+  white-space: nowrap;
+  animation: bubble-bounce 0.5s ease;
+}
+.incoming-call-bubble.bubble-left {
+  /* 左侧FAB时气泡在右边 */
+}
+.bubble-content {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.bubble-icon {
+  font-size: 18px;
+  animation: bubble-icon-ring 0.8s infinite;
+}
+@keyframes bubble-icon-ring {
+  0%, 100% { transform: rotate(0deg); }
+  50% { transform: rotate(15deg); }
+}
+.bubble-number {
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+.bubble-label {
+  font-size: 12px;
+  opacity: 0.85;
+  margin-left: 2px;
+}
+@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); }
+}
+/* 气泡过渡 */
+.call-bubble-fade-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }
+.call-bubble-fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
+.call-bubble-fade-enter, .call-bubble-fade-leave-to { opacity: 0; transform: translateY(10px) scale(0.9); }
+
+/* ===== 面板过渡 ===== */
+.softphone-fade-enter-active, .softphone-fade-leave-active {
+  transition: opacity 0.25s ease, transform 0.25s ease;
+}
+.softphone-fade-enter, .softphone-fade-leave-to {
+  opacity: 0;
+  transform: scale(0.9) translateY(10px);
+}
+
+/* ===== 面板容器 ===== */
+.softphone-panel {
+  position: fixed;
+  z-index: 9999;
+}
+
+/* ===== 拖拽手柄 ===== */
+.drag-handle {
+  cursor: move;
+}
+.drag-handle .minimize-btn {
+  cursor: pointer;
+  color: #999;
+  font-size: 20px;
+  margin-left: 4px;
+  transition: color 0.2s;
+}
+.drag-handle .minimize-btn:hover {
+  color: #f44336;
+}
+
+/* ===== 拨号器主体(复用原始样式) ===== */
+.dialer {
+  width: 280px;
+  min-height: 480px;
+  background-color: #fafafa;
+  border-radius: 16px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  transition: box-shadow 0.3s ease;
+  padding-bottom: 8px;
+}
+
+.status-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 28px;
+  background-color: transparent;
+  padding: 8px 12px;
+  gap: 4px;
+  box-sizing: border-box;
+  border-bottom: 1px solid #f0f0f0;
+  border-radius: 16px 16px 0 0;
+}
+.status-left { flex: 0 0 auto; display: flex; align-items: center; gap: 2px; }
+.status-center { flex: 1; text-align: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 0 4px; }
+.status-right { flex: 0 0 auto; display: flex; align-items: center; gap: 4px; }
+
+.material-icons { font-size: 22px; transition: color 0.2s ease; }
+.network-icon { cursor: default; width: 22px; }
+.no-network { color: #ff5252; }
+.network-available { color: #4caf50; }
+.network-connecting { color: #ffa726; animation: pulse 1.5s infinite; }
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
+
+.microphone-icon, .speaker-icon { cursor: pointer; color: #555; z-index: 1; transition: all 0.2s ease; }
+.microphone-icon:hover, .speaker-icon:hover { color: #2196f3; transform: scale(1.1); }
+.microphone-icon.muted, .speaker-icon.muted { color: #bbb; }
+.microphone-icon.connection-success, .speaker-icon.connection-success { color: #4caf50; }
+.microphone-icon.connection-failed, .speaker-icon.connection-failed { color: #999; }
+
+.call-status-icon { cursor: default; color: #4caf50; width: 22px; }
+.call-status-icon.inprogress { color: #2196f3; }
+.ringing-icon { animation: ringing 0.8s infinite; }
+@keyframes ringing { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(8deg); } }
+
+.container { display: flex; justify-content: center; margin: 4px 0; }
+.call-timer { cursor: default; font-size: 13px; color: #666; font-weight: 500; height: 16px; }
+.province { cursor: default; font-size: 13px; color: #666; font-weight: 500; height: 16px; }
+
+.display-user { cursor: default; font-size: 14px; font-weight: 500; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #333; }
+.user-avatar-dropdown { position: relative; display: inline-block; cursor: pointer; }
+.user-avatar-icon { color: #555; transition: all 0.2s ease; }
+.user-avatar-icon:hover { color: #2196f3; transform: scale(1.1); }
+
+.dropdown-menu { position: absolute; top: 100%; left: 40%; min-width: 140px; background-color: #fff; border: 1px solid #e0e0e0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); padding: 4px; z-index: 10000; animation: fadeIn 0.2s ease; }
+@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
+.dropdown-group { margin-top: 4px; }
+.dropdown-item { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #555; width: 100%; white-space: nowrap; padding: 6px 10px; border-radius: 4px; transition: all 0.2s ease; font-size: 13px; }
+.dropdown-item i.material-icons { font-size: 18px; width: 18px; text-align: center; }
+.dropdown-item:hover { background-color: #f5f5f5; color: #2196f3; }
+
+.volume-control-group { position: relative; display: inline-flex; align-items: center; }
+.volume-slider-container { position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%); width: 160px; background: white; padding: 10px; border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); z-index: 10000; animation: slideUp 0.2s ease; }
+@keyframes slideUp { from { opacity: 0; transform: translateX(-50%) translateY(5px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
+.volume-slider { width: 100%; cursor: pointer; }
+
+.display-wrapper { display: flex; align-items: center; justify-content: center; margin: 28px 16px 0 16px; position: relative; gap: 6px; width: calc(100% - 32px); box-sizing: border-box; }
+
+.dialer-display { flex: 1; min-width: 0; height: 42px; font-size: 22px; font-weight: 500; border: none; outline: none; background-color: transparent; color: #333; border-bottom: 2px solid #e0e0e0; padding: 0 6px; box-sizing: border-box; overflow-x: auto; white-space: nowrap; transition: border-color 0.2s ease; line-height: 42px; text-align: center; }
+.dialer-display:focus { border-bottom-color: #2196f3; }
+.dialer-display.center-align { text-align: center; }
+.dialer-display.right-align { text-align: right; }
+.dialer-display::-webkit-scrollbar { height: 2px; }
+.dialer-display::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }
+
+.delete-icon { cursor: pointer; color: #999; font-size: 26px; transition: all 0.2s ease; user-select: none; flex-shrink: 0; }
+.delete-icon:hover { color: #f44336; transform: scale(1.1); }
+
+.dialer-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; padding: 8px 20px; margin: 4px 0; }
+.dialer-button { width: 100%; aspect-ratio: 1 / 1; max-width: 56px; margin: 0 auto; display: flex; justify-content: center; align-items: center; border-radius: 50%; background-color: #f5f5f5; border: 2px solid #d0d0d0; font-size: 26px; font-weight: 500; cursor: pointer; color: #333; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); }
+.dialer-button:hover { background-color: #e8e8e8; transform: scale(1.05); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-color: #bbb; }
+.dialer-button:active { background-color: #ddd; transform: scale(0.95); }
+
+.call-buttons { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 4px 24px 8px 24px; margin-bottom: 8px; }
+.call-button { width: 56px; height: 56px; border: none; border-radius: 50%; background-color: #4caf50; color: #fff; cursor: pointer; display: flex; justify-content: center; align-items: center; box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); transition: all 0.3s ease; font-size: 26px; outline: none; margin: 0 auto; }
+.call-button:hover:not(.disabled) { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(76, 175, 80, 0.4); }
+.call-button:active:not(.disabled) { transform: translateY(0); box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3); }
+.call-button.hangup { background-color: #f44336; color: white; box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3); }
+.call-button.hangup:hover { box-shadow: 0 6px 16px rgba(244, 67, 54, 0.4); }
+.call-button.normal { background-color: #f5f5f5; color: #333; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
+.call-button.normal:hover { background-color: #e8e8e8; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
+.call-button.call-ready { background-color: #4caf50; color: white; box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); }
+.call-button.call-ready:hover { box-shadow: 0 6px 16px rgba(76, 175, 80, 0.4); }
+.call-button.disabled { background-color: #e0e0e0; color: #999; cursor: not-allowed; box-shadow: none; }
+.hidden { visibility: hidden; }
+
+.status-footer { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; margin-top: 4px; background: transparent; }
+.status-footer-left { display: flex; flex: 1; align-items: center; gap: 8px; overflow: hidden; }
+.status-bar-message { font-size: 11px; max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: rgba(0, 0, 0, 0.75); padding: 4px 10px; border-radius: 16px; color: white; font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,0.2); animation: slideInLeft 0.3s ease; display: inline-block; }
+@keyframes slideInLeft { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
+.status-bar-message.error { background: rgba(244, 67, 54, 0.9); }
+.status-bar-message.success { background: rgba(76, 175, 80, 0.9); }
+.status-bar-message.warn { background: rgba(255, 152, 0, 0.9); }
+.status-bar-message.info { background: rgba(33, 150, 243, 0.9); }
+.reconnect-failed { font-size: 11px; background: rgba(244, 67, 54, 0.95); padding: 4px 10px; border-radius: 16px; color: white; animation: shake 0.5s ease; display: inline-block; }
+@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
+.version-ribbon { font-size: 11px; color: #bbb; background: rgba(0,0,0,0.03); padding: 3px 8px; border-radius: 12px; pointer-events: none; font-family: monospace; font-weight: 500; flex-shrink: 0; }
+
+/* ===== 模态框 ===== */
+.modal { display: block; position: fixed; z-index: 10001; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 340px; max-height: 80vh; overflow-y: auto; background-color: #fff; border-radius: 12px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2); animation: modalFadeIn 0.3s ease; }
+@keyframes modalFadeIn { from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
+.modal-header { display: flex; align-items: center; justify-content: center; padding: 14px; border-radius: 12px 12px 0 0; background-color: #f8f8f8; gap: 8px; border-bottom: 1px solid #e8e8e8; }
+.modal-header i.material-icons { font-size: 24px; color: #2196f3; }
+.modal-header span { font-size: 16px; font-weight: 600; color: #333; }
+.modal-content { background-color: #fff; padding: 16px; }
+.form-group { margin-bottom: 16px; position: relative; }
+.form-label { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #555; margin-bottom: 6px; }
+.form-group input[type="number"], .form-group input[type="text"], .form-group input[type="password"], .form-group select { width: 100%; padding: 10px 12px; box-sizing: border-box; border: 1px solid #e0e0e0; border-radius: 6px; color: #333; font-size: 14px; transition: all 0.2s ease; background-color: #fafafa; }
+.form-group input:focus, .form-group select:focus { outline: none; border-color: #2196f3; background-color: #fff; box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); }
+.form-group .password-toggle { position: absolute; right: 10px; top: 36px; cursor: pointer; color: #bbb; transition: all 0.2s ease; }
+.form-group .password-toggle:hover { color: #2196f3; }
+.form-buttons { display: flex; justify-content: center; gap: 60px; margin-top: 20px; }
+.form-buttons button { width: 35%; padding: 10px; background-color: transparent; color: #fff; border: none; cursor: pointer; border-radius: 6px; font-size: 15px; font-weight: 500; transition: all 0.2s ease-in-out; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); }
+.form-buttons .add-button { background-color: #4caf50; }
+.form-buttons .add-button:hover { background-color: #43a047; box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3); transform: translateY(-1px); }
+.form-buttons .cancel-button { background-color: #9e9e9e; }
+.form-buttons .cancel-button:hover { background-color: #757575; box-shadow: 0 4px 8px rgba(158, 158, 158, 0.3); transform: translateY(-1px); }
+</style>

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

@@ -0,0 +1,98 @@
+<template>
+  <el-dialog
+    :title="title"
+    :visible.sync="dialogVisible"
+    width="1500px"
+    append-to-body
+    destroy-on-close
+    class="manual-call-dialog"
+    @close="handleClose"
+  >
+    <call-center-phone-bar
+      :key="dialogKey"
+      ref="callCenterPhoneBar"
+      :init-phone-number="phone"
+      :robotic-id="roboticId"
+      :company-id="companyId"
+      :company-user-id="companyUserId"
+      :workflow-instance-id="workflowInstanceId"
+      :customer-id="customerId"
+      :hide-phone-bar="useFloatingPhone"
+      @close="dialogVisible = false"
+    />
+  </el-dialog>
+</template>
+
+<script>
+import CallCenterPhoneBar from '@/views/aiSipCall/aiSipCallManualOutbound.vue';
+
+export default {
+  name: 'ManualCallDialog',
+  components: { CallCenterPhoneBar },
+  props: {
+    /** 是否使用浮动软电话模式(隐藏弹窗内电话栏,用浮动电话拨号) */
+    useFloatingPhone: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      dialogVisible: false,
+      dialogKey: 0,
+      title: '人工外呼',
+      phone: '',
+      customerId: null,
+      companyId: null,
+      companyUserId: null,
+      roboticId: null,
+      workflowInstanceId: null
+    };
+  },
+  methods: {
+    /**
+     * 打开外呼弹窗
+     * @param {Object} options
+     * @param {string} options.phone - 电话号码(加密或明文)
+     * @param {string} [options.customerName] - 客户名称(用于标题显示)
+     * @param {number|string} [options.customerId] - 客户ID
+     * @param {number|string} [options.companyId] - 公司ID
+     * @param {number|string} [options.companyUserId] - 员工ID
+     * @param {number|string} [options.roboticId] - 机器人任务ID
+     * @param {number|string} [options.workflowInstanceId] - 工作流实例ID
+     */
+    open(options = {}) {
+      this.phone = options.phone || '';
+      this.customerId = options.customerId || null;
+      this.companyId = options.companyId || null;
+      this.companyUserId = options.companyUserId || null;
+      this.roboticId = options.roboticId || null;
+      this.workflowInstanceId = options.workflowInstanceId || null;
+      this.title = `人工外呼 - ${options.customerName || '未知客户'}`;
+      this.dialogKey += 1;
+      this.dialogVisible = true;
+
+      // 浮动软电话模式:同步弹出浮动电话并填充号码
+      if (this.useFloatingPhone && this.phone) {
+        this.$root.$emit('floating-softphone-dial', {
+          phone: this.phone,
+          customerName: options.customerName || '未知客户'
+        });
+      }
+    },
+    handleClose() {
+      this.phone = '';
+      this.customerId = null;
+      this.companyId = null;
+      this.companyUserId = null;
+      this.roboticId = null;
+      this.workflowInstanceId = null;
+      this.$emit('close');
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 弹窗样式由 el-dialog 全局样式和 aiSipCallManualOutbound 内部样式控制 */
+</style>

+ 3 - 0
src/layout/index.vue

@@ -12,10 +12,12 @@
         <settings />
       </right-panel>
     </div>
+    <floating-soft-phone />
   </div>
 </template>
 
 <script>
+import FloatingSoftPhone from '@/components/FloatingSoftPhone'
 import RightPanel from '@/components/RightPanel'
 import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
 import ResizeMixin from './mixin/ResizeHandler'
@@ -26,6 +28,7 @@ export default {
   name: 'Layout',
   components: {
     AppMain,
+    FloatingSoftPhone,
     Navbar,
     RightPanel,
     Settings,

+ 6 - 0
src/router/index.js

@@ -327,6 +327,12 @@ export const constantRoutes = [
       component: () => import('@/views/company/companyWorkflow/design'),
       name: 'AiWorkflowEdit',
       meta: { title: '编辑AI外呼工作流', activeMenu: '/companyWx/companyWorkflow' }
+    },
+    {
+      path: 'manual',
+      component: () => import('@/views/company/companyVoiceRobotic/handleManualAnswered'),
+      name: 'HandleManualAnswered',
+      meta: { title: '转人工通话列表', activeMenu: '/companyWx/companyWorkflow' }
     }
   ]
   },

+ 231 - 75
src/views/aiSipCall/aiSipCallManualOutbound.vue

@@ -1,6 +1,6 @@
 <template>
     <div class="call-center-phone-bar">
-        <form>
+        <form v-show="!hidePhoneBar">
             <table width="1224">
                 <tr>
                     <td width="70%" colspan="2" height="35" style="text-indent: 20px;">
@@ -371,9 +371,13 @@
         </form>
 
         <!-- 聊天和表单并排布局 -->
-        <div style="display: flex; gap: 20px; margin-top: 20px;">
+        <div class="call-content-layout">
             <!-- ASR 实时对话文本框 -->
-            <div id="chat-container" v-show="showChatContainer" style="flex: 1; min-width: 0;">
+            <div id="chat-container" v-show="showChatContainer" class="chat-panel">
+                <div class="panel-header">
+                    <i class="el-icon-chat-dot-round"></i>
+                    <span>实时对话</span>
+                </div>
                 <div id="chat-messages" class="message-container">
                     <div
                         v-for="(msg, index) in chatMessages"
@@ -390,43 +394,49 @@
             </div>
 
             <!-- 客户信息登记表单(外呼时自动显示) -->
-            <div v-if="showCustomerForm" class="customer-form" style="flex: 1; min-width: 0; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);">
-                <h3 style="margin-bottom: 20px; color: #409EFF; border-left: 4px solid #409EFF; padding-left: 12px;">客户信息登记表</h3>
-                <el-form :model="customerForm" label-width="100px" class="customer-form">
-                    <el-form-item label="姓名">
-                        <el-input v-model="customerForm.customerName" placeholder="请输入客户姓名"></el-input>
-                    </el-form-item>
-                    <el-form-item label="性别">
-                        <el-select v-model="customerForm.sex" placeholder="请选择性别">
-                            <el-option label="男" value="0"></el-option>
-                            <el-option label="女" value="1"></el-option>
-                            <el-option label="未知" value="2"></el-option>
-                        </el-select>
-                    </el-form-item>
-                    <el-form-item label="所在地区">
-                        <el-cascader
-                            ref="citySelect"
-                            v-model="cityIds"
-                            :options="citys"
-                            @change="handleCityChange"
-                            placeholder="请选择地区"
-                            clearable>
-                        </el-cascader>
-                    </el-form-item>
-                    <el-form-item label="详细地址">
-                        <el-input v-model="customerForm.detailAddress" type="textarea" :rows="2" placeholder="请输入详细地址"></el-input>
-                    </el-form-item>
-                    <el-form-item label="沟通内容">
-                        <el-input v-model="customerForm.communicationContent" type="textarea" :rows="4" placeholder="请输入本次沟通内容" maxlength="100" show-word-limit></el-input>
-                    </el-form-item>
-                    <el-form-item label="历史沟通记录">
-                        <el-input v-model="customerForm.historicalCommunication" type="textarea" :rows="6" readonly></el-input>
-                    </el-form-item>
-                    <el-form-item>
-                        <el-button type="primary" @click="saveCustomerForm">保存</el-button>
-                        <el-button @click="closeCustomerForm">关闭</el-button>
-                    </el-form-item>
-                </el-form>
+            <div v-if="showCustomerForm" class="customer-form-panel">
+                <div class="panel-header">
+                    <i class="el-icon-user"></i>
+                    <span>客户信息登记</span>
+                </div>
+                <div class="customer-form-body">
+                    <el-form :model="customerForm" label-width="80px" class="customer-form" size="small">
+                        <el-form-item label="姓名">
+                            <el-input v-model="customerForm.customerName" placeholder="请输入客户姓名"></el-input>
+                        </el-form-item>
+                        <el-form-item label="性别">
+                            <el-select v-model="customerForm.sex" placeholder="请选择性别">
+                                <el-option label="男" value="0"></el-option>
+                                <el-option label="女" value="1"></el-option>
+                                <el-option label="未知" value="2"></el-option>
+                            </el-select>
+                        </el-form-item>
+                        <el-form-item label="所在地区">
+                            <el-cascader
+                                ref="citySelect"
+                                v-model="cityIds"
+                                :options="citys"
+                                @change="handleCityChange"
+                                placeholder="请选择地区"
+                                clearable
+                                style="width: 100%;">
+                            </el-cascader>
+                        </el-form-item>
+                        <el-form-item label="详细地址">
+                            <el-input v-model="customerForm.detailAddress" type="textarea" :rows="2" placeholder="请输入详细地址"></el-input>
+                        </el-form-item>
+                        <el-form-item label="沟通内容">
+                            <el-input v-model="customerForm.communicationContent" type="textarea" :rows="4" placeholder="请输入本次沟通内容" maxlength="100" show-word-limit></el-input>
+                        </el-form-item>
+                        <el-form-item label="历史记录">
+                            <el-input v-model="customerForm.historicalCommunication" type="textarea" :rows="6" readonly></el-input>
+                        </el-form-item>
+                        <el-form-item>
+                            <el-button type="primary" size="small" @click="saveCustomerForm">保存</el-button>
+                            <el-button size="small" @click="closeCustomerForm">关闭</el-button>
+                        </el-form-item>
+                    </el-form>
+                </div>
             </div>
         </div>
 
@@ -511,6 +521,10 @@ export default {
         type: [Number, String],
         default: null
       },
+        hidePhoneBar: {
+            type: Boolean,
+            default: false
+        },
     },
     data() {
         return {
@@ -669,6 +683,11 @@ export default {
                 transferToGroupId.addEventListener('change', this.handleGroupIdChange);
             }
         });
+        // 监听浮动软电话的外呼触发事件
+        this.$root.$on('floating-softphone-call-triggered', this.onFloatingPhoneCallTriggered);
+        // 监听浮动软电话的挂机/保持委托事件
+        this.$root.$on('floating-softphone-hangup-triggered', this.onFloatingPhoneHangupTriggered);
+        this.$root.$on('floating-softphone-hold-triggered', this.onFloatingPhoneHoldTriggered);
     },
 
     beforeDestroy() {
@@ -685,6 +704,10 @@ export default {
         if (transferToGroupId) {
             transferToGroupId.removeEventListener('change', this.handleGroupIdChange);
         }
+        // 移除浮动软电话事件监听
+        this.$root.$off('floating-softphone-call-triggered', this.onFloatingPhoneCallTriggered);
+        this.$root.$off('floating-softphone-hangup-triggered', this.onFloatingPhoneHangupTriggered);
+        this.$root.$off('floating-softphone-hold-triggered', this.onFloatingPhoneHoldTriggered);
         if (this.recordTimer) {
             clearTimeout(this.recordTimer);
             this.recordTimer = null;
@@ -889,6 +912,8 @@ export default {
                 console.log('被叫振铃事件:' + msg.content);
                 this.updatePhoneBar(msg, EventList.CALLEE_RINGING);
                 this.markExecuteSuccess();
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-ringing');
             });
 
             this.phoneBar.on(EventList.CALLER_ANSWERED, (msg) => {
@@ -899,6 +924,8 @@ export default {
                     console.log('[主叫接通] 获取到通话 UUID:', this.currentCallUuid);
                 }
                 this.updatePhoneBar(msg, EventList.CALLER_ANSWERED);
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-talking');
             });
 
             this.phoneBar.on(EventList.CALLER_HANGUP, (msg) => {
@@ -912,6 +939,8 @@ export default {
                     this.currentCallUuid = msg.object.uuid;
                 }
                 this.updatePhoneBar(msg, EventList.CALLER_HANGUP);
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-ended');
 
                 if (this.callExecuted && !this.callExecuteFailed) {
                     this.openIntentDialog('CALLER_HANGUP', msg);
@@ -925,6 +954,8 @@ export default {
                     console.log('[被叫接通] 获取到通话 UUID:', this.currentCallUuid);
                 }
                 this.updatePhoneBar(msg, EventList.CALLEE_ANSWERED);
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-talking');
             });
 
             this.phoneBar.on(EventList.CALLEE_HANGUP, (msg) => {
@@ -935,6 +966,8 @@ export default {
                     this.currentCallUuid = msg.object.uuid;
                 }
                 this.updatePhoneBar(msg, EventList.CALLEE_HANGUP);
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-ended');
 
                 if (this.callExecuted && !this.callExecuteFailed) {
                     this.openIntentDialog('CALLEE_HANGUP', msg);
@@ -973,6 +1006,8 @@ export default {
                 this.callStatus = '通话已保持';
                 this.showHoldBtn = false;
                 this.showUnHoldBtn = true;
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-hold');
             });
 
             this.phoneBar.on(EventList.CUSTOMER_CHANNEL_UNHOLD, (msg) => {
@@ -980,6 +1015,8 @@ export default {
                 this.callStatus = '客户通话已接回';
                 this.showHoldBtn = true;
                 this.showUnHoldBtn = false;
+                // 同步状态到浮动软电话
+                this.$root.$emit('dialog-call-unhold');
             });
 
             this.phoneBar.on(EventList.CUSTOMER_ON_HOLD_HANGUP, (msg) => {
@@ -1340,6 +1377,78 @@ export default {
 
             this.phoneBar.call(this.phoneNumber.trim(), this.callType, this.videoLevel);
         },
+        /**
+         * 浮动软电话触发外呼:接收号码,通过弹窗phoneBar发起外呼(走完整的通话记录、客户信息流程)
+         * @param {string} phoneNumber - 浮动软电话传来的号码(已解密或明文)
+         */
+        onFloatingPhoneCallTriggered(phoneNumber) {
+            console.log('[浮动电话触发] 外呼号码:', phoneNumber, ',通过弹窗phoneBar执行外呼');
+            if (!phoneNumber || phoneNumber.trim().length < 3) {
+                this.$message.warning('号码格式不正确');
+                return;
+            }
+            if (!this.phoneBar || !this.phoneBar.getIsConnected()) {
+                this.$message.warning('弹窗电话未连接,请先签入');
+                return;
+            }
+            // 检查外呼按钮状态
+            const callBtn = document.getElementById('callBtn');
+            if (!callBtn || !callBtn.classList.contains('on')) {
+                this.$message.warning('当前无法外呼,请先置忙');
+                return;
+            }
+            // 检查当前是否有通话
+            if (this.phoneBar.getCallConnected()) {
+                this.$message.warning('当前有通话正在进行,请先挂机');
+                return;
+            }
+            // 设置phoneNumber并重置通话记录状态(和handleCall一致)
+            this.phoneNumber = phoneNumber.trim();
+            this.currentCallUuid = '';
+            this.callFinishedAt = '';
+            this.callExecuted = false;
+            this.callExecuteFailed = false;
+            this.callResultRecorded = false;
+            this.callFailReason = '';
+            if (this.recordTimer) {
+                clearTimeout(this.recordTimer);
+                this.recordTimer = null;
+            }
+            // 通过弹窗phoneBar发起外呼
+            this.phoneBar.call(this.phoneNumber, this.callType, this.videoLevel);
+        },
+        /**
+         * 浮动软电话触发挂机:通过弹窗phoneBar执行挂机
+         */
+        onFloatingPhoneHangupTriggered() {
+            console.log('[浮动电话触发] 通过弹窗phoneBar执行挂机');
+            if (this.phoneBar && this.phoneBar.getIsConnected()) {
+                this.phoneBar.hangup();
+            }
+        },
+        /**
+         * 浮动软电话触发保持/恢复:通过弹窗phoneBar执行
+         */
+        onFloatingPhoneHoldTriggered() {
+            console.log('[浮动电话触发] 通过弹窗phoneBar执行保持/恢复');
+            if (!this.phoneBar || !this.phoneBar.getIsConnected()) {
+                this.$message.warning('请先上线');
+                return;
+            }
+            if (this.showUnHoldBtn) {
+                // 当前是保持状态,执行取消保持
+                const btn = document.getElementById('unHoldBtn');
+                if (btn && btn.classList.contains('on')) {
+                    this.phoneBar.unHoldCall();
+                }
+            } else {
+                // 当前是通话状态,执行保持
+                const btn = document.getElementById('holdBtn');
+                if (btn && btn.classList.contains('on')) {
+                    this.phoneBar.holdCall();
+                }
+            }
+        },
 
         formatDateTime(date = new Date()) {
             const pad = (n) => String(n).padStart(2, '0');
@@ -2668,28 +2777,75 @@ export default {
 }
 
 /* ASR 聊天容器 */
+.call-center-phone-bar .call-content-layout {
+    display: flex;
+    gap: 16px;
+    margin-top: 16px;
+    min-height: 0;
+}
+
+.call-center-phone-bar .chat-panel,
+.call-center-phone-bar .customer-form-panel {
+    flex: 1;
+    min-width: 0;
+    background: #fff;
+    border: 1px solid #e4e7ed;
+    border-radius: 8px;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+}
+
+.call-center-phone-bar .panel-header {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 10px 16px;
+    background: #f5f7fa;
+    border-bottom: 1px solid #e4e7ed;
+    font-size: 14px;
+    font-weight: 600;
+    color: #303133;
+    flex-shrink: 0;
+}
+
+.call-center-phone-bar .panel-header i {
+    font-size: 16px;
+    color: #409EFF;
+}
+
+.call-center-phone-bar .customer-form-body {
+    padding: 16px;
+    overflow-y: auto;
+    flex: 1;
+}
+
 .call-center-phone-bar #chat-container {
-    width: 90%;
-    max-width: 600px;
-    margin: 20px auto;
-    background: rgba(255, 255, 255, 0.98);
-    border: 1px solid #e0e6ed;
-    border-radius: 12px;
-    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
-    padding: 15px;
+    margin: 0;
+    max-width: none;
+    width: auto;
+    background: transparent;
+    border: none;
+    border-radius: 0;
+    box-shadow: none;
+    padding: 0;
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
 }
 
 .call-center-phone-bar .message {
-    padding: 12px 16px;
-    margin: 10px 0;
-    border-radius: 12px;
+    padding: 10px 14px;
+    margin: 6px 12px;
+    border-radius: 8px;
     animation: slideIn 0.3s ease;
 }
 
 @keyframes slideIn {
     from {
         opacity: 0;
-        transform: translateY(10px);
+        transform: translateY(8px);
     }
     to {
         opacity: 1;
@@ -2698,69 +2854,69 @@ export default {
 }
 
 .call-center-phone-bar .customer {
-    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
-    border-left: 4px solid #909399;
+    background: #f0f0f0;
+    border-left: 3px solid #909399;
 }
 
 .call-center-phone-bar .agent {
-    background: linear-gradient(135deg, #f0f9ff 0%, #c6efff 100%);
-    border-right: 4px solid #409EFF;
+    background: #ecf5ff;
+    border-left: 3px solid #409EFF;
 }
 
 .call-center-phone-bar .system-message {
     text-align: center;
     color: #909399;
-    font-style: italic;
-    padding: 10px;
-    background: rgba(245, 247, 250, 0.5);
-    border-radius: 8px;
-    margin: 10px 0;
+    font-size: 12px;
+    padding: 6px 12px;
+    background: #fafafa;
+    border-radius: 4px;
+    margin: 6px 12px;
 }
 
 .call-center-phone-bar .message-container {
     display: flex;
     flex-direction: column;
-    max-height: 400px;
+    max-height: 500px;
     overflow-y: auto;
+    padding: 8px 0;
 }
 
 .call-center-phone-bar .message-container::-webkit-scrollbar {
-    width: 6px;
+    width: 5px;
 }
 
 .call-center-phone-bar .message-container::-webkit-scrollbar-track {
-    background: #f1f1f1;
-    border-radius: 3px;
+    background: transparent;
 }
 
 .call-center-phone-bar .message-container::-webkit-scrollbar-thumb {
-    background: #c0c4cc;
+    background: #dcdfe6;
     border-radius: 3px;
 }
 
 .call-center-phone-bar .message-container::-webkit-scrollbar-thumb:hover {
-    background: #909399;
+    background: #c0c4cc;
 }
 
 .call-center-phone-bar .message-header {
     font-weight: 600;
-    margin-bottom: 6px;
+    margin-bottom: 4px;
     color: #606266;
-    font-size: 13px;
+    font-size: 12px;
 }
 
 .call-center-phone-bar .message-content {
     color: #303133;
-    font-size: 14px;
-    line-height: 1.6;
+    font-size: 13px;
+    line-height: 1.5;
 }
 
 /* 客户信息表单 */
 .call-center-phone-bar .customer-form {
-    background: rgba(255, 255, 255, 0.98);
-    border-radius: 12px;
-    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
-    border: 1px solid #e0e6ed;
+    background: transparent;
+    border-radius: 0;
+    box-shadow: none;
+    border: none;
 }
 
 .call-center-phone-bar .customer-form h3 {

+ 215 - 21
src/views/company/companyVoiceRobotic/handleManualAnswered.vue

@@ -127,22 +127,92 @@
     </el-dialog>
 
     <!-- 查看弹窗(已处理时使用,只读展示) -->
-    <el-dialog title="查看处理结果" :visible.sync="viewDialogVisible" width="600px" append-to-body>
-      <el-form :model="viewForm" label-width="110px" v-loading="viewLoading">
-        <el-form-item label="客户名称">
-          <span>{{ viewForm.customerName }}</span>
-        </el-form-item>
-        <el-form-item label="客户号码">
-          <span>{{ desensitizePhone(viewForm.mobile) }}</span>
-        </el-form-item>
-        <el-form-item label="有效客户">
-          <el-tag v-if="viewForm.effectiveCustomer === 1" type="success">有效</el-tag>
-          <el-tag v-else type="info">无效</el-tag>
-        </el-form-item>
-        <el-form-item label="AI外呼备注">
-          <span>{{ viewForm.aiCallRemark || '无' }}</span>
-        </el-form-item>
-      </el-form>
+    <el-dialog title="查看处理结果" :visible.sync="viewDialogVisible" width="900px" append-to-body class="manual-view-dialog">
+      <div v-loading="viewLoading">
+        <el-form :model="viewForm" label-width="110px" class="view-info-form">
+          <el-row>
+            <el-col :span="12">
+              <el-form-item label="任务名称">
+                <span>{{ viewForm.roboticName || '-' }}</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="客户号码">
+                <span>{{ desensitizePhone(viewForm.callerNum || viewForm.mobile) }}</span>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row>
+            <el-col :span="12">
+              <el-form-item label="客户名称">
+                <span>{{ viewForm.customerName || '-' }}</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="有效客户">
+                <el-tag v-if="viewForm.effectiveCustomer === 1" type="success">有效</el-tag>
+                <el-tag v-else-if="viewForm.effectiveCustomer === 0" type="info">无效</el-tag>
+                <span v-else>-</span>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row>
+            <el-col :span="12">
+              <el-form-item label="AI通话时长">
+                <span>{{ viewForm.callTime || 0 }} 秒</span>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="人工接听时长">
+                <span>{{ msToSeconds(viewForm.manualAnsweredTimeLen) }} 秒</span>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item label="AI外呼备注">
+            <span>{{ viewForm.aiCallRemark || '无' }}</span>
+          </el-form-item>
+        </el-form>
+
+        <!-- 录音播放 -->
+        <div class="view-section">
+          <div class="view-section-title">
+            <i class="el-icon-headset"></i>
+            <span>通话录音</span>
+          </div>
+          <div class="view-section-body">
+            <audio
+              v-if="viewForm.recordPath"
+              controls
+              :src="handleRecordPath(viewForm.recordPath)"
+              class="audio-player"
+            >您的浏览器不支持音频播放</audio>
+            <div v-else class="empty-tip">暂无录音</div>
+          </div>
+        </div>
+
+        <!-- 聊天记录 -->
+        <div class="view-section">
+          <div class="view-section-title">
+            <i class="el-icon-chat-line-square"></i>
+            <span>对话内容</span>
+          </div>
+          <div class="view-section-body">
+            <div v-if="!viewForm.contentList || parseContentList(viewForm.contentList).length === 0" class="empty-tip">暂无对话内容</div>
+            <div v-else class="chat-container">
+              <div
+                v-for="(msg, index) in parseContentList(viewForm.contentList)"
+                :key="index"
+                :class="['chat-item', msg.role === 'user' ? 'chat-right' : 'chat-left']"
+              >
+                <div class="chat-bubble-wrapper">
+                  <div class="chat-role">{{ msg.role === 'user' ? '客户' : 'AI客服' }}</div>
+                  <div class="chat-bubble">{{ msg.content }}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
       <div slot="footer" class="dialog-footer">
         <el-button @click="viewDialogVisible = false">关 闭</el-button>
       </div>
@@ -238,18 +308,23 @@ export default {
     },
     /** 查看处理结果(已处理数据,只读展示) */
     handleView(row) {
-      this.viewForm = {};
+      this.viewForm = {
+        recordPath: row.recordPath || '',
+        contentList: row.contentList || '',
+        callerNum: row.callerNum || '',
+        roboticName: row.roboticName || '',
+        callTime: row.callTime || 0,
+        manualAnsweredTimeLen: row.manualAnsweredTimeLen || 0
+      };
       this.viewDialogVisible = true;
       this.viewLoading = true;
       getCrmCustomerByLogId(row.logId).then(response => {
         if (response.code === 200 && response.data) {
-          this.viewForm = response.data;
-        } else {
-          this.viewForm = {};
+          // 合并CRM客户信息(保留通话相关字段)
+          this.viewForm = { ...this.viewForm, ...response.data };
         }
         this.viewLoading = false;
       }).catch(() => {
-        this.viewForm = {};
         this.viewLoading = false;
       });
     },
@@ -301,7 +376,126 @@ export default {
     msToSeconds(ms) {
       if (!ms || ms <= 0) return 0;
       return Math.ceil(ms / 1000);
+    },
+    /** 录音URL处理(统一走代理转发,避免跨域) */
+    handleRecordPath(url) {
+      if (!url) return '';
+      let fullUrl = '';
+      if (url.startsWith('http')) {
+        fullUrl = url;
+      } else {
+        fullUrl = 'http://129.28.164.235:8899/recordings/files?filename=' + url;
+      }
+      return process.env.VUE_APP_BASE_API + '/common/proxy/recording?url=' + encodeURIComponent(fullUrl);
+    },
+    /** 解析聊天记录(contentList JSON字符串) */
+    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) {
+        return [];
+      }
     }
   }
 };
 </script>
+
+<style scoped>
+.manual-view-dialog .view-info-form {
+  background: #fafafa;
+  padding: 12px 16px 0 16px;
+  border-radius: 6px;
+  margin-bottom: 16px;
+}
+.view-section {
+  margin-top: 16px;
+  border: 1px solid #e8e8e8;
+  border-radius: 6px;
+  overflow: hidden;
+}
+.view-section-title {
+  padding: 10px 16px;
+  background: #f5f7fa;
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  border-bottom: 1px solid #e8e8e8;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.view-section-title i {
+  font-size: 16px;
+  color: #409EFF;
+}
+.view-section-body {
+  padding: 16px;
+  background: #fff;
+}
+.audio-player {
+  width: 100%;
+  outline: none;
+}
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  font-size: 13px;
+  padding: 20px 0;
+}
+.chat-container {
+  max-height: 360px;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+.chat-item {
+  display: flex;
+  margin-bottom: 14px;
+}
+.chat-item.chat-left {
+  justify-content: flex-start;
+}
+.chat-item.chat-right {
+  justify-content: flex-end;
+}
+.chat-bubble-wrapper {
+  max-width: 70%;
+  display: flex;
+  flex-direction: column;
+}
+.chat-item.chat-right .chat-bubble-wrapper {
+  align-items: flex-end;
+}
+.chat-role {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 4px;
+  padding: 0 4px;
+}
+.chat-bubble {
+  padding: 10px 14px;
+  border-radius: 8px;
+  font-size: 14px;
+  line-height: 1.6;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+.chat-item.chat-left .chat-bubble {
+  background: #f0f2f5;
+  color: #303133;
+  border-top-left-radius: 2px;
+}
+.chat-item.chat-right .chat-bubble {
+  background: #95ec69;
+  color: #303133;
+  border-top-right-radius: 2px;
+}
+</style>

+ 29 - 57
src/views/company/companyVoiceRobotic/index.vue

@@ -855,26 +855,11 @@
     <el-drawer size="75%" title="客户详情" :visible.sync="customerDetailShow" append-to-body>
       <customer-details ref="customerDetails" />
     </el-drawer>
-    <el-dialog
-      :title="manualCallDialog.title"
-      :visible.sync="manualCallDialog.visible"
-      width="1500px"
-      append-to-body
-      destroy-on-close
-      class="manual-call-dialog"
+    <manual-call-dialog
+      ref="manualCallDialog"
+      :use-floating-phone="true"
       @close="handleManualCallDialogClose"
-    >
-      <call-center-phone-bar
-          :key="manualCallDialog.key"
-          ref="callCenterPhoneBar"
-          :init-phone-number="manualCallDialog.phone"
-          :robotic-id="manualCallDialog.roboticId"
-          :company-id="manualCallDialog.companyId"
-          :company-user-id="manualCallDialog.companyUserId"
-          :workflow-instance-id="manualCallDialog.workflowInstanceId"
-          :customer-id="manualCallDialog.customerId"
-      />
-    </el-dialog>
+    />
 
     <el-dialog
       title="对话内容"
@@ -955,12 +940,12 @@ import { queryPhone } from "@/api/crm/customer";
 import {getDicts} from "@/api/system/dict/data";
 import { optionList, getWorkflowNodeTypeCodes } from '@/api/company/companyWorkflow'
 import {wxListQw} from "../../../api/company/companyVoiceRobotic";
-import CallCenterPhoneBar from '../../aiSipCall/aiSipCallManualOutbound.vue'
+import ManualCallDialog from '@/components/ManualCallDialog/index.vue'
 import AiTagPanel from "../../crm/components/AiTagPanel.vue";
 
 export default {
   name: "Robotic",
-  components: {AiTagPanel, draggable, customerDetails, customerSelect, appendCustomerSelect, qwUserSelect,qwUserSelectTwo,CallCenterPhoneBar},
+  components: {AiTagPanel, draggable, customerDetails, customerSelect, appendCustomerSelect, qwUserSelect,qwUserSelectTwo,ManualCallDialog},
   data() {
     return {
       submitFormLoading:false,
@@ -1094,18 +1079,6 @@ export default {
         encryptPhone: '',
         onlyCallNode: false
       },
-      manualCallDialog: {
-        visible: false,
-        phone: '',
-        title: "人工外呼",
-        record: null,
-        key: 0,
-        roboticId: null,
-        companyId: null,
-        companyUserId: null,
-        workflowInstanceId: null,
-        customerId: null
-      },
       contentDialog: {
         visible: false,
         content: '',
@@ -1858,37 +1831,36 @@ export default {
       this.getExecLogs()
     },
     handleManualCall(record) {
-      const phone = record.customerPhone || '';
-
-      if (!phone) {
-          this.$message.warning('当前客户没有可外呼的手机号');
-          return;
+      const customerId = record.customerId;
+      if (!customerId) {
+        this.$message.warning('客户ID不存在,无法获取手机号');
+        return;
       }
 
-      this.manualCallDialog.record = record;
-      this.manualCallDialog.phone = phone;
-      this.manualCallDialog.title = `人工外呼 - ${record.customerName || '未知客户'}`;
-
       const currentTask = this.roboticList.find(
         item => item.id === this.execLogs.currentTaskId
       );
-      // 带过去的额外参数
-      this.manualCallDialog.roboticId = record.roboticId || null;
-      this.manualCallDialog.companyId = (currentTask && currentTask.companyId) || null;
-      this.manualCallDialog.companyUserId = (currentTask && currentTask.companyUserId) || null;
-      this.manualCallDialog.workflowInstanceId = record.workflowInstanceId || null;
-      this.manualCallDialog.customerId = record.customerId || null;
-      this.manualCallDialog.key += 1;
-      this.manualCallDialog.visible = true;
+
+      queryPhone(customerId).then(response => {
+        const phone = response.mobile || '';
+        if (!phone) {
+          this.$message.warning('当前客户没有可外呼的手机号');
+          return;
+        }
+        this.$refs.manualCallDialog.open({
+          phone,
+          customerName: record.customerName || '未知客户',
+          roboticId: record.roboticId || null,
+          companyId: (currentTask && currentTask.companyId) || null,
+          companyUserId: (currentTask && currentTask.companyUserId) || null,
+          workflowInstanceId: record.workflowInstanceId || null,
+          customerId: customerId
+        });
+      }).catch(() => {
+        this.$message.error('获取手机号失败');
+      });
     },
     handleManualCallDialogClose() {
-      this.manualCallDialog.phone = '';
-      this.manualCallDialog.record = null;
-      this.manualCallDialog.roboticId = null;
-      this.manualCallDialog.companyId = null;
-      this.manualCallDialog.companyUserId = null;
-      this.manualCallDialog.workflowInstanceId = null;
-      this.manualCallDialog.customerId = null;
     },
     handleShowContent(record,log) {
       this.contentDialog.customerName = record.customerName || '';

+ 29 - 57
src/views/company/companyVoiceRobotic/myIndex.vue

@@ -838,26 +838,11 @@
     <el-drawer size="75%" title="客户详情" :visible.sync="customerDetailShow" append-to-body>
       <customer-details ref="customerDetails" />
     </el-drawer>
-    <el-dialog
-      :title="manualCallDialog.title"
-      :visible.sync="manualCallDialog.visible"
-      width="1500px"
-      append-to-body
-      destroy-on-close
-      class="manual-call-dialog"
+    <manual-call-dialog
+      ref="manualCallDialog"
+      :use-floating-phone="true"
       @close="handleManualCallDialogClose"
-    >
-      <call-center-phone-bar
-          :key="manualCallDialog.key"
-          ref="callCenterPhoneBar"
-          :init-phone-number="manualCallDialog.phone"
-          :robotic-id="manualCallDialog.roboticId"
-          :company-id="manualCallDialog.companyId"
-          :company-user-id="manualCallDialog.companyUserId"
-          :workflow-instance-id="manualCallDialog.workflowInstanceId"
-          :customer-id="manualCallDialog.customerId"
-      />
-    </el-dialog>
+    />
 
     <el-dialog
       title="对话内容"
@@ -933,12 +918,12 @@ import { queryPhone } from "@/api/crm/customer";
 import {getDicts} from "@/api/system/dict/data";
 import { optionList, getWorkflowNodeTypeCodes } from '@/api/company/companyWorkflow'
 import {wxListQw} from "../../../api/company/companyVoiceRobotic";
-import CallCenterPhoneBar from '../../aiSipCall/aiSipCallManualOutbound.vue'
+import ManualCallDialog from '@/components/ManualCallDialog/index.vue'
 import AiTagPanel from "../../crm/components/AiTagPanel.vue";
 
 export default {
   name: "MyRobotic",
-  components: {AiTagPanel, draggable, customerDetails, customerSelect, qwUserSelect,qwUserSelectTwo,CallCenterPhoneBar},
+  components: {AiTagPanel, draggable, customerDetails, customerSelect, qwUserSelect,qwUserSelectTwo,ManualCallDialog},
   data() {
     return {
       submitFormLoading:false,
@@ -1070,18 +1055,6 @@ export default {
         customerPhone: '',
         onlyCallNode: false
       },
-      manualCallDialog: {
-        visible: false,
-        phone: '',
-        title: "人工外呼",
-        record: null,
-        key: 0,
-        roboticId: null,
-        companyId: null,
-        companyUserId: null,
-        workflowInstanceId: null,
-        customerId: null
-      },
       contentDialog: {
         visible: false,
         content: '',
@@ -1749,37 +1722,36 @@ export default {
       this.getExecLogs()
     },
     handleManualCall(record) {
-      const phone = record.customerPhone || '';
-
-      if (!phone) {
-          this.$message.warning('当前客户没有可外呼的手机号');
-          return;
+      const customerId = record.customerId;
+      if (!customerId) {
+        this.$message.warning('客户ID不存在,无法获取手机号');
+        return;
       }
 
-      this.manualCallDialog.record = record;
-      this.manualCallDialog.phone = phone;
-      this.manualCallDialog.title = `人工外呼 - ${record.customerName || '未知客户'}`;
-
       const currentTask = this.roboticList.find(
         item => item.id === this.execLogs.currentTaskId
       );
-      // 带过去的额外参数
-      this.manualCallDialog.roboticId = record.roboticId || null;
-      this.manualCallDialog.companyId = (currentTask && currentTask.companyId) || null;
-      this.manualCallDialog.companyUserId = (currentTask && currentTask.companyUserId) || null;
-      this.manualCallDialog.workflowInstanceId = record.workflowInstanceId || null;
-      this.manualCallDialog.customerId = record.customerId || null;
-      this.manualCallDialog.key += 1;
-      this.manualCallDialog.visible = true;
+
+      queryPhone(customerId).then(response => {
+        const phone = response.mobile || '';
+        if (!phone) {
+          this.$message.warning('当前客户没有可外呼的手机号');
+          return;
+        }
+        this.$refs.manualCallDialog.open({
+          phone,
+          customerName: record.customerName || '未知客户',
+          roboticId: record.roboticId || null,
+          companyId: (currentTask && currentTask.companyId) || null,
+          companyUserId: (currentTask && currentTask.companyUserId) || null,
+          workflowInstanceId: record.workflowInstanceId || null,
+          customerId: customerId
+        });
+      }).catch(() => {
+        this.$message.error('获取手机号失败');
+      });
     },
     handleManualCallDialogClose() {
-      this.manualCallDialog.phone = '';
-      this.manualCallDialog.record = null;
-      this.manualCallDialog.roboticId = null;
-      this.manualCallDialog.companyId = null;
-      this.manualCallDialog.companyUserId = null;
-      this.manualCallDialog.workflowInstanceId = null;
-      this.manualCallDialog.customerId = null;
     },
     handleShowContent(record,log) {
       this.contentDialog.customerName = record.customerName || '';

+ 13 - 45
src/views/crm/customer/my.vue

@@ -298,25 +298,11 @@
         <add-visit-status ref="visitStatus" @close="closeVisitStatus()"></add-visit-status>
     </el-dialog>
     <!--人工外呼弹窗-->
-    <el-dialog
-      :title="manualCallDialog.title"
-      :visible.sync="manualCallDialog.visible"
-      width="1500px"
-      append-to-body
-      destroy-on-close
-      class="manual-call-dialog"
+    <manual-call-dialog
+      ref="manualCallDialog"
+      :use-floating-phone="true"
       @close="handleManualCallDialogClose"
-    >
-      <call-center-phone-bar
-        :key="manualCallDialog.key"
-        ref="callCenterPhoneBar"
-        :init-phone-number="manualCallDialog.phone"
-        :company-id="manualCallDialog.companyId"
-        :company-user-id="manualCallDialog.companyUserId"
-        :customer-id="manualCallDialog.customerId"
-        @close="manualCallDialog.visible = false"
-      />
-    </el-dialog>
+    />
   </div>
 </template>
 
@@ -333,10 +319,10 @@ import addTag from '../components/addTag.vue';
 import addRemark from '../components/addRemark.vue';
 import addCustomerType from '../components/addCustomerType.vue';
 import addVisitStatus from '../components/addVisitStatus.vue';
-import CallCenterPhoneBar from '../../aiSipCall/aiSipCallManualOutbound.vue';
+import ManualCallDialog from '@/components/ManualCallDialog/index.vue';
 export default {
   name: "Customer",
-  components: {addVisitStatus,addCustomerType,addRemark,addTag,assignUser,addOrEditCustomer,editSource, addBatchSms,customerDetails,addVisit,CallCenterPhoneBar },
+  components: {addVisitStatus,addCustomerType,addRemark,addTag,assignUser,addOrEditCustomer,editSource, addBatchSms,customerDetails,addVisit,ManualCallDialog },
   data() {
     return {
       addVisitStatus:{
@@ -477,16 +463,6 @@ export default {
         ],
       },
       loading:null,
-      manualCallDialog: {
-        visible: false,
-        customerId: null,
-        phone: '',
-        title: '人工外呼',
-        record: null,
-        key: 0,
-        companyId: null,
-        companyUserId: null
-      },
     };
   },
   created() {
@@ -766,15 +742,13 @@ export default {
           this.$message.warning('当前客户没有可外呼的手机号');
           return;
         }
-        this.manualCallDialog.record = row;
-        this.manualCallDialog.phone = phone;
-        this.manualCallDialog.customerId = row.customerId || null;
-        this.manualCallDialog.title = `人工外呼 - ${row.customerName || '未知客户'}`;
-        this.manualCallDialog.companyId = row.companyId || null;
-        this.manualCallDialog.companyUserId = row.companyUserId ||  null;
-        this.manualCallDialog.key += 1;
-        this.manualCallDialog.visible = true;
-
+        this.$refs.manualCallDialog.open({
+          phone,
+          customerName: row.customerName || '未知客户',
+          customerId: row.customerId || null,
+          companyId: row.companyId || null,
+          companyUserId: row.companyUserId || null
+        });
       } catch (e) {
         console.error(e);
         this.$message.error('获取手机号失败');
@@ -782,12 +756,6 @@ export default {
     },
 
     handleManualCallDialogClose() {
-      this.manualCallDialog.phone = '';
-      this.manualCallDialog.record = null;
-      this.manualCallDialog.roboticId = null;
-      this.manualCallDialog.companyId = null;
-      this.manualCallDialog.companyUserId = null;
-      this.manualCallDialog.workflowInstanceId = null;
       // 完成手动外呼关闭弹窗后刷新列表,保证“是否已外呼”筛选及外呼次数列数据同步
       this.getList();
     }