Просмотр исходного кода

Merge remote-tracking branch 'origin/master'

ct 22 часов назад
Родитель
Сommit
f2c37bb7e8

+ 3 - 0
.env.development

@@ -22,3 +22,6 @@ VUE_APP_PROJECT_FROM=ylrz
 # 路由懒加载
 VUE_CLI_BABEL_TRANSPILE_MODULES = true
 
+# 电话加密密钥
+VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY = 'ylrz112233'
+

+ 3 - 0
.env.production

@@ -22,3 +22,6 @@ VUE_APP_PROJECT_FROM=ylrz
 # 路由懒加载
 VUE_CLI_BABEL_TRANSPILE_MODULES = true
 
+# 电话加密密钥
+VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY = 'ylrz112233'
+

+ 1 - 0
package.json

@@ -104,6 +104,7 @@
     "js-beautify": "1.10.2",
     "js-cookie": "2.2.0",
     "jsencrypt": "3.0.0-rc.1",
+    "jssip": "^3.13.8",
     "lodash.clonedeep": "^4.5.0",
     "lodash.merge": "^4.6.2",
     "moment": "^2.29.4",

+ 18 - 0
src/api/aiSipCall/aiSipCallOutboundCdr.js

@@ -86,3 +86,21 @@ export function syncByUuid(data) {
     })
 }
 
+// 获取加密电话(密文拨号模式)
+export function encryptMobile(data) {
+    return request({
+        url: '/company/aiSipCall/outboundCdr/encryptMobile',
+        method: 'post',
+        data: data
+    })
+}
+
+// 同步当前通话记录(PC端软电话挂机后调用)
+export function callEndSyncByUuid(data) {
+    return request({
+        url: '/company/aiSipCall/outboundCdr/callEndSyncByUuid',
+        method: 'post',
+        data: data
+    })
+}
+

+ 9 - 0
src/api/aiSipCall/aiSipCallUser.js

@@ -101,3 +101,12 @@ export function getToolbarBasicParam(data) {
         data: data
     })
 }
+
+// 坐席登录接口(WebSocket连接成功后调用)
+export function agentLogin(data) {
+    return request({
+        url: '/company/aiSipCall/aiSipCallUser/agentLogin',
+        method: 'post',
+        data: data
+    })
+}

+ 789 - 0
src/api/aiSipCall/softPhone.js

@@ -0,0 +1,789 @@
+// softPhone.js - WebPhone核心逻辑与配置管理(使用 localStorage 存储,密码混淆)
+// 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
+
+import * as JsSIP from 'jssip';
+
+// ========== 回铃音 URL(使用本地音频文件) ==========
+export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
+
+/**
+ * 简单的密码混淆(非加密,仅避免明文暴露)
+ * 注意:生产环境应使用服务端认证token或OAuth
+ */
+const encodePassword = (pwd) => {
+  if (!pwd) return '';
+  try {
+    const timestamp = Date.now().toString(36);
+    const pwdStr = String(pwd);
+    const encoded = btoa(unescape(encodeURIComponent(pwdStr)));
+    return `${timestamp}:${encoded}`;
+  } catch (e) {
+    console.error('[密码] 编码失败');
+    try {
+      return btoa(String(pwd));
+    } catch (fallbackError) {
+      console.error('[密码] 备选编码失败');
+      return '';
+    }
+  }
+};
+
+const decodePassword = (encoded) => {
+  if (!encoded) return '';
+  try {
+    let base64Part = encoded;
+    if (encoded.includes(':')) {
+      base64Part = encoded.split(':')[1];
+    }
+    return decodeURIComponent(escape(atob(base64Part)));
+  } catch (e) {
+    console.warn('[密码] 解码失败,可能是旧格式');
+    return '';
+  }
+};
+
+// 检查麦克风权限
+export async function checkMicrophonePermission() {
+  try {
+    const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
+    if (permissionStatus.state === 'prompt') {
+      await navigator.mediaDevices.getUserMedia({ audio: true });
+      return true;
+    } else if (permissionStatus.state === 'denied') {
+      return false;
+    } else if (permissionStatus.state === 'granted') {
+      return true;
+    }
+  } catch (err) {
+    return false;
+  }
+}
+
+// ---------- ProfileManager(localStorage 安全存储)----------
+export class ProfileManager {
+  constructor() {
+    this.profile = null;
+    this.load();
+  }
+
+  load() {
+    try {
+      const json = localStorage.getItem('WebPhoneProfile');
+      if (!json) {
+        this.reset();
+      } else {
+        this.profile = JSON.parse(json);
+        if (this.profile.users) {
+          Object.keys(this.profile.users).forEach(uid => {
+            const user = this.profile.users[uid];
+            if (user.password && typeof user.password === 'string') {
+              user.password = decodePassword(user.password);
+            }
+          });
+        }
+      }
+    } catch (error) {
+      console.error('[配置] 加载失败,重置为默认值');
+      this.reset();
+    }
+  }
+
+  save() {
+    const toStore = JSON.parse(JSON.stringify(this.profile));
+    if (toStore.users) {
+      Object.keys(toStore.users).forEach(uid => {
+        const user = toStore.users[uid];
+        if (user.password && typeof user.password === 'string') {
+          user.password = encodePassword(user.password);
+        }
+      });
+    }
+    localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
+  }
+
+  reset() {
+    this.profile = {
+      users: {},
+      user: '',
+      reconnect: true,
+      reconnect_interval: 15,
+      user_agent: 'JsSIP',
+      session_expires: 180,
+      min_session_expires: 120,
+      speaker_volume: 0.8,
+      mic_volume: 0.8,
+      speaker_paused: false,
+      mic_paused: false,
+      auto_answer: false,
+      stun: false,
+      ice_server: ''
+    };
+    this.save();
+  }
+
+  getProfile() {
+    return this.profile;
+  }
+
+  getSettings() {
+    let reconnectInterval = this.profile.reconnect_interval || 15;
+    if (reconnectInterval > 1000) {
+      reconnectInterval = Math.floor(reconnectInterval / 1000);
+      this.profile.reconnect_interval = reconnectInterval;
+      this.save();
+    }
+    const settings = {
+      user_agent: this.profile.user_agent,
+      session_expires: this.profile.session_expires,
+      min_session_expires: this.profile.min_session_expires,
+      stun: this.profile.stun,
+      ice_server: this.profile.ice_server,
+      auto_answer: this.profile.auto_answer,
+      reconnect: this.profile.reconnect,
+      reconnect_interval: reconnectInterval
+    };
+    return settings;
+  }
+
+  updateSettings(settings) {
+    Object.assign(this.profile, settings);
+    this.save();
+  }
+
+  resetSettings() {
+    this.profile.reconnect = true;
+    this.profile.reconnect_interval = 15;
+    this.profile.user_agent = 'JsSIP';
+    this.profile.session_expires = 180;
+    this.profile.min_session_expires = 120;
+    this.profile.auto_answer = false;
+    this.profile.stun = false;
+    this.profile.ice_server = '';
+    this.save();
+  }
+
+  getCurrentUserProfile() {
+    if (!this.profile.users || !this.profile.user) return null;
+    return this.profile.users[this.profile.user];
+  }
+
+  addUser(profile) {
+    if (!profile.user || !profile.domain || !profile.password) {
+      throw new Error('登录名、域名和密码为必填项');
+    }
+    const userId = `${profile.user}@${profile.domain}`;
+    if (this.profile.users[userId]) {
+      this.profile.users[userId] = {
+        ...this.profile.users[userId],
+        ...profile,
+        user: profile.user,
+        domain: profile.domain
+      };
+      this.profile.user = userId;
+      this.save();
+      return;
+    }
+    this.profile.user = userId;
+    this.profile.users[userId] = profile;
+    this.save();
+  }
+
+  updateUser(userId, updatedProfile) {
+    if (this.profile.users[userId]) {
+      const existing = this.profile.users[userId];
+      const merged = {
+        ...existing,
+        ...updatedProfile,
+        user: existing.user,
+        domain: existing.domain
+      };
+      this.profile.users[userId] = merged;
+      this.save();
+    }
+  }
+
+  deleteCurrentUser() {
+    if (!this.profile.user) return;
+    delete this.profile.users[this.profile.user];
+    const keys = Object.keys(this.profile.users);
+    this.profile.user = keys.length > 0 ? keys[0] : '';
+    this.save();
+  }
+
+  switchUser(userId) {
+    if (this.profile.users[userId]) {
+      this.profile.user = userId;
+      this.save();
+    }
+  }
+}
+
+// ---------- WebPhone 核心类(SIP注册、媒体处理、保持/接回、DTMF、回铃音)----------
+// 外呼功能已移至 Vue 组件中的 ccPhoneBarSocket 实现
+export class WebPhone {
+  constructor(profile, settings) {
+    this.profile = profile;
+    this.settings = settings;
+    this.session = null;
+    this.ua = null;
+    this.call_id = this.randomUUID();
+    this.events = {};
+    this.sessionCloseTimerId = null;
+    this.callTimerId = null;
+    this.dtfmTimerId = null;
+    this.audioCtx = null;
+    this.oscillatorLow = null;
+    this.oscillatorHigh = null;
+    this.gainNode = null;
+
+    this.reconnectEnabled = settings.reconnect;
+    this.reconnectAttempts = 0;
+    this.reconnectStartTime = null;
+    this.reconnectTotalDuration = 1 * 60 * 1000; // 1分钟
+    this.isReconnecting = false;
+    this.reconnectTimerId = null;
+    this._isHandlingDisconnect = false;
+
+    // 使用 Base64 回铃音
+    this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL);
+    this.ringbackMedia.loop = true;
+    this.remoteMedia = new Audio();
+    this.localMedia = new Audio();
+
+    // this.applyWebSocketPatch();
+    this.initUA();
+  }
+
+  randomUUID() {
+    if (crypto && typeof crypto.randomUUID === 'function') {
+      return crypto.randomUUID().replace(/-/g, '');
+    }
+    return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+      const r = Math.random() * 16 | 0;
+      const v = c === 'x' ? r : (r & 0x3 | 0x8);
+      return v.toString(16);
+    });
+  }
+
+  applyWebSocketPatch() {
+    if (window._webSocketPatchedForSip) return;
+    const targetServer = this.profile.server;
+    if (!targetServer || !(targetServer.startsWith('ws://') || targetServer.startsWith('wss://'))) {
+      console.warn('[WebPhone] WebSocket地址无效:', targetServer);
+      return;
+    }
+    try {
+      new URL(targetServer);
+    } catch (e) {
+      console.error('[WebPhone] WebSocket URL格式错误:', targetServer);
+      return;
+    }
+    const originalWebSocket = window.WebSocket;
+    const patchedWebSocket = function(url, protocols) {
+      if (url === targetServer && protocols && protocols.length > 0) {
+        return new originalWebSocket(url);
+      }
+      return new originalWebSocket(url, protocols);
+    };
+    for (let key in originalWebSocket) {
+      if (originalWebSocket.hasOwnProperty(key)) {
+        patchedWebSocket[key] = originalWebSocket[key];
+      }
+    }
+    window.WebSocket = patchedWebSocket;
+    window._webSocketPatchedForSip = true;
+    console.log('[WebPhone] WebSocket Patch已应用');
+  }
+
+  initUA() {
+    if (!this.profile.server || !this.profile.user || !this.profile.domain) {
+      console.error('[SIP] 配置不完整');
+      this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
+      return;
+    }
+    const socket = new JsSIP.WebSocketInterface(this.profile.server, { protocols: [] });
+    socket.via_transport = this.profile.transport || 'wss';
+
+    const user = String(this.profile.user || '');
+    const domain = String(this.profile.domain || '');
+    const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
+    const password = this.profile.password ? String(this.profile.password) : '';
+    const server = String(this.profile.server || '');
+    const transport = String(this.profile.transport || 'wss');
+
+    if (!user || !domain || !password) {
+      console.error('[SIP] 账号配置缺失:', { user, domain, hasPassword: !!password });
+      this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
+      return;
+    }
+
+    const uri = new JsSIP.URI('sip', user, domain);
+    const contactUriStr = `sip:${user}@${domain};transport=ws`;
+
+    this.configuration = {
+      sockets: [socket],
+      authorization_user: user,
+      hack_ip_in_contact: true,
+      user_agent: this.settings.user_agent || 'JsSIP',
+      display_name: displayName || undefined,
+      session_timers: true,
+      no_answer_timeout: 60,
+      uri: uri.toAor(),
+      contact_uri: contactUriStr,
+    };
+
+    if (password && password.length === 32) {
+      this.configuration.ha1 = password;
+      this.configuration.realm = domain;
+    } else {
+      this.configuration.password = password;
+    }
+
+    console.log(`[SIP] UA配置完成: ${user}@${domain}`);
+  }
+
+  createUA() {
+    this.ua = new JsSIP.UA(this.configuration);
+    this.ua.set('display_name', this.profile.display_name);
+    this.ua.on('connecting', this.connecting.bind(this));
+    this.ua.on('connected', this.connected.bind(this));
+    this.ua.on('disconnected', this.disconnected.bind(this));
+    this.ua.on('registered', this.registered.bind(this));
+    this.ua.on('unregistered', this.unregistered.bind(this));
+    this.ua.on('registrationFailed', this.registrationFailed.bind(this));
+    this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
+    this.ua.on('newRTCSession', this.newRTCSession.bind(this));
+    this.ua.on('newMessage', this.newMessage.bind(this));
+  }
+
+  On(event, callback) {
+    this.events[event] = callback;
+  }
+
+  emit(event, ...args) {
+    if (this.events[event]) {
+      try {
+        this.events[event](...args);
+      } catch (e) {
+        console.error(e);
+      }
+    }
+  }
+
+  resetReconnectState() {
+    if (this.reconnectTimerId) {
+      clearTimeout(this.reconnectTimerId);
+      this.reconnectTimerId = null;
+    }
+    this.isReconnecting = false;
+    this.reconnectAttempts = 0;
+    this.reconnectStartTime = null;
+    this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
+  }
+
+  Start(reconnect, isReconnect = false) {
+    console.log(`[SIP] 启动 ${reconnect ? '启用重连' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
+    this.reconnectEnabled = reconnect;
+    if (this.ua) {
+      try {
+        if (this.ua.isRegistered()) this.ua.unregister();
+        this.ua.stop();
+      } catch (e) {}
+      this.ua = null;
+    }
+    if (!isReconnect) this.resetReconnectState();
+    setTimeout(() => {
+      this.createUA();
+      if (this.ua.isRegistered()) {
+        this.SetQueueIn();
+        return;
+      }
+      if (this.ua.isConnected()) {
+        this.Register();
+        return;
+      }
+      try {
+        this.ua.start();
+      } catch (error) {
+        console.error('[SIP] UA启动失败:', error.message);
+        this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
+        this.scheduleReconnect();
+      }
+    }, 50);
+  }
+
+  Register() {
+    if (this.ua) this.ua.register();
+  }
+
+  UnRegister() {
+    this.reconnectEnabled = false;
+    this.resetReconnectState();
+    if (this.ua) {
+      try {
+        if (this.ua.isRegistered()) this.ua.unregister();
+        this.ua.stop();
+      } catch (e) {}
+      this.ua = null;
+    }
+    if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
+    if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
+  }
+
+  scheduleReconnect() {
+    if (!this.reconnectEnabled) return;
+    if (this.reconnectTimerId) {
+      clearTimeout(this.reconnectTimerId);
+      this.reconnectTimerId = null;
+    }
+    if (this.isReconnecting) return;
+    const now = Date.now();
+    if (this.reconnectStartTime === null) this.reconnectStartTime = now;
+    const elapsed = now - this.reconnectStartTime;
+    if (elapsed >= this.reconnectTotalDuration) {
+      console.error('[SIP] 重连超时(超过1分钟)');
+      this.isReconnecting = false;
+      this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
+      this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
+      return;
+    }
+    this.reconnectAttempts++;
+    let interval = (this.settings.reconnect_interval || 15) * 1000;
+    if (this.reconnectAttempts > 5) interval = 10000;
+    if (this.reconnectAttempts > 10) interval = 20000;
+    if (elapsed + interval > this.reconnectTotalDuration) {
+      const remainingTime = this.reconnectTotalDuration - elapsed;
+      if (remainingTime < 1000) {
+        this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
+        this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
+        return;
+      }
+      interval = Math.min(interval, remainingTime);
+    }
+    console.log(`[SIP] ${Math.ceil(interval/1000)}秒后重连 (第${this.reconnectAttempts}次)`);
+    this.isReconnecting = true;
+    this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
+    this.reconnectTimerId = setTimeout(() => {
+      this.isReconnecting = false;
+      this.reconnectTimerId = null;
+      this.Start(true, true);
+    }, interval);
+  }
+
+  // ---- JsSIP 事件处理 ----
+  connecting() {
+    console.log('[SIP] 连接中...');
+    this.emit('OnStatusMessage', { type: 'info', text: '连接中...' });
+  }
+  connected() {
+    console.log('[SIP] 已连接,开始注册');
+    this.Register();
+    this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
+  }
+  disconnected(e) {
+    if (this._isHandlingDisconnect) return;
+    this._isHandlingDisconnect = true;
+    console.log('[SIP] 连接断开');
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'error', text: '连接断开' });
+    if (this.ua) {
+      try { this.ua.stop(); } catch (err) {}
+      this.ua = null;
+    }
+    if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+    setTimeout(() => { this._isHandlingDisconnect = false; }, 1000);
+  }
+  registered() {
+    console.log('[SIP] 连接成功');
+    this.resetReconnectState();
+    this.emit('OnRegister', { registered: true });
+    this.emit('OnStatusMessage', { type: 'success', text: '连接成功' });
+    this.SetQueueIn();
+  }
+  unregistered() {
+    console.log('[SIP] 已注销');
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'info', text: '已注销' });
+  }
+  registrationFailed(e) {
+    console.error('[SIP] 注册失败:', e.cause || '未知原因');
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'error', text: '注册失败: ' + (e.cause || '未知原因') });
+    if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
+  registrationExpiring() {
+    console.log('[SIP] 注册即将过期,重新注册');
+    this.Register();
+  }
+
+  // 外呼功能由 ccPhoneBarSocket 实现,WebPhone 不再实现 Call 方法
+  // 但保留 Answer、Terminate、ToggleHold、ToggleMicPhone 等方法供来电和通话控制
+  Answer() {
+    if (this.session) this.session.answer({ mediaConstraints: { audio: true, video: false } });
+  }
+
+  Terminate(code) {
+    if (this.session) {
+      if (code) this.session.terminate({ status_code: code });
+      else this.session.terminate();
+    }
+    if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
+    this.sessionCloseTimerId = setTimeout(() => this.sessionClosed(true, ''), 1000);
+  }
+
+  ToggleHold() {
+    if (this.session && this.session.isEstablished()) {
+      if (this.session.isOnHold().local) this.session.unhold();
+      else this.session.hold();
+    }
+  }
+
+  ToggleMicPhone() {
+    if (this.session) {
+      if (this.session.isMuted().audio) this.session.unmute();
+      else this.session.mute();
+    }
+  }
+
+  SetSpeaker(paused, volume) {
+    this.remoteMedia.volume = paused ? 0 : volume;
+    this.ringbackMedia.volume = paused ? 0 : volume;
+  }
+
+  SetMicPhone(paused, volume) {
+    this.localMedia.volume = paused ? 0 : volume;
+  }
+
+  SetQueueIn() {
+    if (this.ua && this.ua.isRegistered()) {
+      this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {});
+    }
+  }
+
+  SetQueueOut() {
+    if (this.ua && this.ua.isRegistered()) {
+      this.ua.sendMessage('execute_logout', `${this.profile.user}@${this.profile.domain}`, {});
+    }
+  }
+
+  SendDTMF(tone) {
+    if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone);
+  }
+
+  PlayDtmfTone(key) {
+    const DTMF_MAP = {
+      '0': [697, 1633], '1': [697, 1209], '2': [697, 1336], '3': [697, 1477],
+      '4': [770, 1209], '5': [770, 1336], '6': [770, 1477], '7': [852, 1209],
+      '8': [852, 1336], '9': [852, 1477], '*': [697, 1633], '#': [770, 1633]
+    };
+    const [lowFreq, highFreq] = DTMF_MAP[key] || [697, 1209];
+    this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
+  }
+
+  generateDtmfTone(lowFreq, highFreq, duration, volume) {
+    if (this.dtfmTimerId) {
+      clearTimeout(this.dtfmTimerId);
+      if (this.oscillatorLow) {
+        try { this.oscillatorLow.stop(); } catch (e) {}
+      }
+      if (this.oscillatorHigh) {
+        try { this.oscillatorHigh.stop(); } catch (e) {}
+      }
+    }
+
+    if (!this.audioCtx || this.audioCtx.state === 'closed') {
+      this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+    }
+
+    this.gainNode = this.audioCtx.createGain();
+    this.oscillatorLow = this.audioCtx.createOscillator();
+    this.oscillatorHigh = this.audioCtx.createOscillator();
+    this.oscillatorLow.type = 'sine';
+    this.oscillatorLow.frequency.value = lowFreq;
+    this.oscillatorHigh.type = 'sine';
+    this.oscillatorHigh.frequency.value = highFreq;
+    this.oscillatorLow.connect(this.gainNode);
+    this.oscillatorHigh.connect(this.gainNode);
+    this.gainNode.connect(this.audioCtx.destination);
+    this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
+    this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
+    this.oscillatorLow.start();
+    this.oscillatorHigh.start();
+    this.dtfmTimerId = setTimeout(() => {
+      if (this.oscillatorLow) {
+        try { this.oscillatorLow.stop(); } catch (e) {}
+      }
+      if (this.oscillatorHigh) {
+        try { this.oscillatorHigh.stop(); } catch (e) {}
+      }
+      this.dtfmTimerId = null;
+    }, duration * 1000);
+  }
+
+  IsOnHold() {
+    return this.session ? this.session.isOnHold().local : false;
+  }
+
+  pauseRingback() {
+    if (this.ringbackMedia && !this.ringbackMedia.paused) {
+      this.ringbackMedia.pause();
+      console.log('[音频] 暂停回铃音');
+    }
+  }
+
+  playRingback() {
+    if (this.ringbackMedia && this.ringbackMedia.paused) {
+      this.ringbackMedia.currentTime = 0;
+      this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败'));
+      console.log('[音频] 播放回铃音');
+    }
+  }
+
+  newRTCSession(event) {
+    console.log('[通话] 新会话创建');
+    if (this.session) { event.session.terminate({ status_code: 486 }); return; }
+    this.session = event.session;
+    this.session.on('progress', (e) => {
+      this.emit('OnRing', {
+        outgoing: this.session.direction === 'outgoing',
+        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.startCallTimer();
+    });
+    this.session.on('ended', () => this.sessionClosed(true, ''));
+    this.session.on('failed', (e) => this.sessionClosed(false, e.cause));
+    this.session.on('peerconnection', (pc) => {
+      if (pc && typeof pc.addTrack === 'function') {
+        this.registerRemoteMedia(pc);
+      } else if (this.session && this.session.connection && typeof this.session.connection.addTrack === 'function') {
+        console.log('[通话] 使用session.connection');
+        this.registerRemoteMedia(this.session.connection);
+      } else {
+        console.error('[通话] 未找到有效的RTCPeerConnection');
+      }
+    });
+    const outgoing = this.session.direction === 'outgoing';
+    this.emit('OnSessionCreated', {
+      outgoing,
+      callee: this.session.remote_identity?.uri?.user || '',
+      province: event.request?.getHeader('X-Province') || '',
+      city: event.request?.getHeader('X-City') || ''
+    });
+    if (!outgoing && this.settings.auto_answer) this.Answer();
+    if (outgoing) this.playRingback();
+  }
+
+  newMessage(event) {
+    if (event.message.direction === 'incoming') {
+      this.emit('OnMessage', { type: 'request', message: event.request.body });
+    }
+  }
+
+  registerRemoteMedia(connection) {
+    if (!connection || typeof connection.addTrack !== 'function') {
+      console.error('[通话] 无效的连接对象');
+      return;
+    }
+
+    // 监听远程流
+    connection.ontrack = (e) => {
+      if (!this.remoteMedia.srcObject) {
+        const stream = new MediaStream();
+        e.streams[0].getTracks().forEach(track => stream.addTrack(track));
+        this.remoteMedia.srcObject = stream;
+        this.remoteMedia.play().catch(err => console.warn('[音频] 远程媒体播放失败'));
+        this.pauseRingback();
+      }
+    };
+
+    // 获取并添加本地流
+    navigator.mediaDevices.getUserMedia({ audio: true })
+      .then(stream => {
+        this.localMedia.srcObject = stream;
+        stream.getTracks().forEach(track => {
+          try {
+            connection.addTrack(track, stream);
+            console.log('[音频] 本地轨道添加成功');
+          } catch (err) {
+            console.error('[音频] 添加本地轨道失败:', err);
+          }
+        });
+      })
+      .catch(err => {
+        console.error('[音频] 获取麦克风失败:', err);
+        this.emit('OnStatusMessage', { type: 'error', text: '无法获取麦克风权限' });
+      });
+  }
+
+  sessionClosed(succeed, reason) {
+    if (!this.session) return;
+    this.session = null;
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    this.pauseRingback();
+
+    if (this.remoteMedia.srcObject) this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
+    if (this.localMedia.srcObject) this.localMedia.srcObject.getTracks().forEach(t => t.stop());
+    this.emit('OnSessionClosed', { succeeded: succeed, reason });
+  }
+
+  startCallTimer() {
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    let seconds = 0;
+    this.callTimerId = setInterval(() => {
+      seconds++;
+      const mins = Math.floor(seconds / 60);
+      const secs = seconds % 60;
+      this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`);
+    }, 1000);
+  }
+
+  destroy() {
+    console.log('[销毁] WebPhone实例');
+    this.reconnectEnabled = false;
+    [this.reconnectTimerId, this.callTimerId, this.sessionCloseTimerId, this.dtfmTimerId].forEach(timerId => {
+      if (timerId) clearTimeout(timerId);
+    });
+    this.UnRegister();
+    const cleanupAudio = (audio) => {
+      if (!audio) return;
+      try {
+        audio.pause();
+        audio.src = '';
+        if (audio.srcObject) {
+          audio.srcObject.getTracks().forEach(t => t.stop());
+          audio.srcObject = null;
+        }
+        audio.load();
+      } catch (e) {}
+    };
+    cleanupAudio(this.ringbackMedia);
+    cleanupAudio(this.remoteMedia);
+    cleanupAudio(this.localMedia);
+    this.ringbackMedia = null;
+    this.remoteMedia = null;
+    this.localMedia = null;
+    if (this.audioCtx) {
+      try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch (e) {}
+      this.audioCtx = null;
+    }
+    this.oscillatorLow = null;
+    this.oscillatorHigh = null;
+    this.gainNode = null;
+    this.events = {};
+    this.configuration = null;
+    this.profile = null;
+    this.settings = null;
+    this.session = null;
+    this.ua = null;
+    console.log('[销毁] WebPhone清理完成');
+  }
+}
+
+export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL };

+ 17 - 1
src/views/aiSipCall/aiSipCallOutboundCar.vue

@@ -67,6 +67,13 @@
                           value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>
         </div>
       </el-form-item>
+      <el-form-item label="外呼类型" prop="sourceType">
+        <el-select v-model="queryParams.sourceType" placeholder="全部" clearable size="small" style="width: 120px">
+          <el-option label="销售后台" value="0" />
+          <el-option label="总后台" value="1" />
+          <el-option label="APP" value="3" />
+        </el-select>
+      </el-form-item>
 
       <el-form-item>
         <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -118,6 +125,13 @@
       <el-table-column label="通话时长" align="center" prop="timeLenSec" />
       <el-table-column label="纯通时长" align="center" prop="timeLenValidStr" />
       <el-table-column label="挂断原因" align="center" prop="hangupCause" />
+      <el-table-column label="外呼类型" align="center" prop="sourceType" width="100">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.sourceType === '3'" type="success" size="mini">APP</el-tag>
+          <el-tag v-else-if="scope.row.sourceType === '1'" type="warning" size="mini">总后台</el-tag>
+          <el-tag v-else size="mini">销售后台</el-tag>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
 <!--        <template slot-scope="scope">-->
 <!--          <el-button-->
@@ -211,7 +225,8 @@ export default {
         timeLenValid: null,
         recordFilename: null,
         chatContent: null,
-        hangupCause: null
+        hangupCause: null,
+        sourceType: null
       },
       // 表单参数
       form: {},
@@ -292,6 +307,7 @@ export default {
       this.queryParams.uuid = null;
       this.queryParams.callee = null;
       this.queryParams.opnum = null;
+      this.queryParams.sourceType = null;
       this.handleQuery();
     },
     // 多选框选中数据

+ 22 - 1
src/views/aiSipCall/aiSipCallUser.vue

@@ -104,6 +104,20 @@
         </template>
       </el-table-column>
       <el-table-column label="绑定分机号" align="center" prop="extNum"/>
+      <el-table-column label="分机密码" align="center" prop="extPass">
+        <template slot-scope="scope">
+          <span v-if="scope.row.showPassword">{{ scope.row.extPass }}</span>
+          <span v-else>******</span>
+          <el-button
+            type="text"
+            size="mini"
+            @click="togglePasswordVisibility(scope.row)"
+            style="margin-left: 5px;"
+          >
+            {{ scope.row.showPassword ? '隐藏' : '查看' }}
+          </el-button>
+        </template>
+      </el-table-column>
       <el-table-column label="绑定网关" align="center" prop="gatewayIds">
         <template slot-scope="scope">
           <span>{{ getGatewayNames(scope.row.gatewayIds) }}</span>
@@ -367,7 +381,10 @@ export default {
         this.gateways = response.rows || [];
         // 再获取用户列表
         listAiSipCallUser(this.queryParams).then(response => {
-            this.aiSipCallUserList = response.rows;
+            this.aiSipCallUserList = response.rows.map(item => ({
+              ...item,
+              showPassword: false
+            }));
             this.total = response.total;
             this.loading = false;
         });
@@ -520,6 +537,10 @@ export default {
           })
           .filter(name => name);
       return names.join(', ');
+    },
+    // 切换分机密码显示/隐藏
+    togglePasswordVisibility(row) {
+      this.$set(row, 'showPassword', !row.showPassword);
     }
   }
 };

+ 2402 - 0
src/views/aiSipCall/softPhone.vue

@@ -0,0 +1,2402 @@
+<template>
+  <div class="webphone-container">
+    <div class="dialer">
+      <!-- 状态栏:左侧头像下拉,中间用户名,右侧音量及网络图标 -->
+      <div class="status-bar">
+        <!-- 左侧区域:头像下拉 + 呼叫状态图标 -->
+        <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="toggleDropdown"
+               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">
+            <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">
+            <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 ? '已注册' : '连接中')">signal_cellular_alt</i>
+        </div>
+      </div>
+
+      <!-- 拨号显示屏与删除按钮 -->
+      <div class="display-wrapper">
+        <!-- 明文/密文选择框(居中显示) -->
+        <div class="dial-mode-selector">
+          <label class="radio-label" title="使用明文号码直接拨号">
+            <input type="radio" value="plaintext" v-model="dialMode" @change="handleDialModeChange">
+            <span>明文</span>
+          </label>
+          <label class="radio-label" title="使用加密号码拨号(自动解密)">
+            <input type="radio" value="encrypted" v-model="dialMode" @change="handleDialModeChange">
+            <span>密文</span>
+          </label>
+        </div>
+        <input type="tel"
+               class="dialer-display"
+               :class="{ 'center-align': isContentFit, 'right-align': !isContentFit }"
+               id="display"
+               v-model="dialNumber"
+               @keypress="handleDisplayKeypress"
+               placeholder="输入电话号码"
+               ref="dialerInput"
+               title="输入要拨打的电话号码">
+        <i class="material-icons delete-icon"
+           @click="deleteCharByCursor"
+           style="cursor: default;"
+           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="container decrypting-tip" v-show="isDecrypting" title="正在解密号码">
+        <span>🔄 正在解密号码...</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()"
+                @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 id="phoneModal" class="modal" v-show="accountDialogVisible" @click.self="accountDialogVisible = false">
+      <div class="modal-header">
+        <i class="material-icons">{{ accountDialogTitle === '添加账号' ? 'add' : 'edit' }}</i>
+        <span id="modalTitle">{{ accountDialogTitle }}</span>
+      </div>
+      <div class="modal-content">
+        <form @submit.prevent="saveAccount">
+          <div class="form-group">
+            <input type="text" id="note" v-model="accountForm.note" placeholder="备注">
+          </div>
+          <div class="form-group">
+            <input type="text" id="server" v-model="accountForm.server" placeholder="服务(ws://129.28.164.235:5066)" required>
+          </div>
+          <div class="form-group">
+            <input type="text" id="username" v-model="accountForm.username" placeholder="用户名">
+          </div>
+          <div class="form-group">
+            <input type="text" id="domain" v-model="accountForm.domain" placeholder="域名" required>
+          </div>
+          <div class="form-group">
+            <input type="text" id="loginName" v-model="accountForm.loginName" placeholder="登录名" required>
+          </div>
+          <div class="form-group">
+            <input :type="showPassword ? 'text' : 'password'" id="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 id="transport" 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 id="settingModal" 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
+              <i class="material-icons info-icon" title="SIP 用户代理标识,用于服务器识别客户端类型。例如:JsSIP、Zoiper 等">info</i>
+            </label>
+            <input type="text" id="userAgent" v-model="settingsForm.userAgent" placeholder="例如: JsSIP" required>
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              Session Expires
+              <i class="material-icons info-icon" title="SIP 会话的有效期(单位:秒)。超时后需要重新注册。建议值:180-600 秒">info</i>
+            </label>
+            <input type="number" id="sessionExpires" v-model.number="settingsForm.sessionExpires" placeholder="例如: 180" required min="60">
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              Min Session Expires
+              <i class="material-icons info-icon" title="服务器可接受的最小会话有效期(单位:秒)。必须小于 Session Expires。建议值:90-180 秒">info</i>
+            </label>
+            <input type="number" id="minSessionExpires" v-model.number="settingsForm.minSessionExpires" placeholder="例如: 120" required min="30">
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              启用 STUN
+              <i class="material-icons info-icon" title="STUN 服务器用于 NAT 穿透,帮助在防火墙或复杂网络环境下建立连接。内网或直接连接可关闭">info</i>
+            </label>
+            <select id="stun" v-model="settingsForm.stun">
+              <option :value="false" selected>否</option>
+              <option :value="true">是</option>
+            </select>
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              ICE Server
+              <i class="material-icons info-icon" title="ICE 服务器地址,用于音视频媒体流穿透。格式:stun:server:port 或 turn:user:pass@server:port。例如:stun:stun.l.google.com:19302">info</i>
+            </label>
+            <input type="text" id="iceServer" v-model="settingsForm.iceServer" placeholder="例如: stun:stun.l.google.com:19302">
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              自动接听
+              <i class="material-icons info-icon" title="收到来电时是否自动接听,无需手动操作。开启后适合客服场景">info</i>
+            </label>
+            <select id="autoAnswer" v-model="settingsForm.autoAnswer">
+              <option :value="false" selected>否</option>
+              <option :value="true">是</option>
+            </select>
+          </div>
+          <div class="form-group">
+            <label class="form-label">
+              自动重连
+              <i class="material-icons info-icon" title="WebSocket 或 SIP 连接断开后是否尝试自动重连。建议开启以应对网络波动">info</i>
+            </label>
+            <select id="reconnect" v-model="settingsForm.reconnect">
+              <option :value="true" selected>是</option>
+              <option :value="false">否</option>
+            </select>
+          </div>
+          <div class="form-group" v-if="settingsForm.reconnect">
+            <label class="form-label">
+              重连间隔
+              <i class="material-icons info-icon" title="每次重连尝试之间的等待时间(单位:秒)。建议值:10-30 秒,过短会增加服务器压力">info</i>
+            </label>
+            <input type="number" id="reconnectInterval" 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>
+</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, encryptMobile } from '@/api/aiSipCall/aiSipCallOutboundCdr.js';
+
+// ==================== 全局配置常量 ====================
+/**
+ * IPCC服务器配置
+ */
+const IPCC_CONFIG = {
+  /** 生产环境IPCC服务器地址 */
+  SERVER_PROD: 'sip.ylrzcloud.com',
+  /** 本地调试IPCC服务器地址 */
+  SERVER_LOCAL: '129.28.164.235',
+  /** 本地调试端口 */
+  PORT_LOCAL: 1081,
+  /** WebSocket连接超时时间(毫秒) */
+  CONNECT_TIMEOUT: 15000
+};
+
+/**
+ * SIP默认账号配置
+ */
+const SIP_DEFAULT_CONFIG = {
+  /** 默认服务器地址 */
+  SERVER: 'ws://129.28.164.235:5066',
+  /** 默认域名 */
+  DOMAIN: '129.28.164.235',
+  /** 默认传输协议 */
+  TRANSPORT: 'ws'
+};
+
+/**
+ * 音量控制配置
+ */
+const VOLUME_CONFIG = {
+  /** 默认音量值 (0-1) */
+  DEFAULT: 0.8,
+  /** 滑块隐藏延迟时间(毫秒) */
+  HIDE_DELAY: 1000
+};
+
+/**
+ * UI状态常量
+ */
+const UI_STATE = {
+  /** 空闲状态 */
+  IDLE: 'idle',
+  /** 振铃状态 */
+  RINGING: 'ringing',
+  /** 通话中状态 */
+  TALKING: 'talking'
+};
+
+export default {
+  name: 'SoftPhone',
+  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 {
+      // 拨号与显示相关
+      dialNumber: '',
+      callDuration: '00:00',
+      province: '',
+      isContentFit: true,
+
+      // 网络与注册状态
+      isRegistered: false,
+      isConnected: false,
+      callStatus: UI_STATE.IDLE,
+
+      // 音频控制
+      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: true,
+        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,  // 状态消息定时器ID
+      isReconnecting: false,
+      reconnectFailed: false,
+
+      // 呼叫中心集成
+      ccPhoneBar: null,
+      ccSocketConnected: false,
+      ccSocketFailed: false,
+      ccConnectingPromise: null,
+      ccConnectingResolve: null,
+      ccConnectingReject: null,
+
+      // 坐席状态
+      isCallingReady: false,  // 是否可以外呼(坐席已置忙)
+      isOnHold: false,  // 通话是否处于保持状态
+
+      // 通话记录管理
+      currentCallUuid: '',  // 当前通话的UUID
+      callUuidMap: {},  // 存储所有通话的UUID映射,防止覆盖
+
+      // 明文/密文拨号模式
+      dialMode: 'plaintext',  // 拨号方式:plaintext-明文, encrypted-密文
+      decryptedPhoneNumber: '',  // 存储解密后的号码(用于拨号,不显示在输入框)
+      isDecrypting: false,  // 是否正在解密
+      encryptingLock: false,  // 加密锁,防止重复加密请求
+      encryptedNumberUnwatch: null  // 加密号码监听器的unwatch函数
+    };
+  },
+  computed: {
+    hangupButtonIcon() {
+      return this.callStatus !== 'idle' ? 'call_end' : 'phone';
+    },
+    leftButtonIcon() {
+      if (this.callStatus === 'ringing') return 'phone';
+      if (this.callStatus === 'talking') {
+        // 优先使用 IPCC 的保持状态,其次使用 JsSIP 的状态
+        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();
+    }
+  },
+  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;
+    await this.initCCAndStart();
+    // 设置明文/密文监听器
+    this.setupEncryptedNumberWatcher();
+    window.addEventListener('beforeunload', this.handleBeforeUnload);
+  },
+  beforeDestroy() {
+    console.log('[生命周期] 组件即将销毁');
+    this.destroyAllConnections();
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    // 清除所有定时器
+    this.clearAllTimers();
+    // 清理事件监听器
+    this.removeEventListeners();
+    console.log('[生命周期] 组件已销毁');
+  },
+  methods: {
+    // 获取左侧按钮标题
+    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 '发起外呼';
+      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';
+      }
+      // 其他情况显示灰色禁用样式
+      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;
+      }
+
+      // 其他消息显示10秒后恢复为“就绪”
+      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;
+      console.log('[SoftPhone] 开始初始化...');
+      try {
+        await this.ensureCCSocketConnect();
+        console.log('[SoftPhone] IPCC连接成功');
+        await this.startPhone();
+        console.log('[SoftPhone] SIP注册启动完成');
+      } catch (err) {
+        console.error('[SoftPhone] 初始化失败:', err.message);
+        this.ccSocketFailed = true;
+        this.showStatus(`初始化失败: ${err.message}`, 'error');
+        if (err.message.includes('分机号')) {
+          this.$message({
+            message: '未配置分机号,请联系管理员在【系统管理】-【员工管理】页面绑定 SIP 角色',
+            type: 'warning',
+            duration: 5000,
+            showClose: true
+          });
+        }
+      }
+    },
+    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() {
+      // WebSocket连接事件
+      this.ccPhoneBar.on(EventList.WS_CONNECTED, () => {
+        console.log('[IPCC] WebSocket已连接');
+        this.ccSocketConnected = true;
+        this.ccSocketFailed = false;
+        if (this.ccConnectingResolve) this.ccConnectingResolve();
+        this.ccConnectingPromise = null;
+      });
+
+      this.ccPhoneBar.on(EventList.WS_DISCONNECTED, () => {
+        console.warn('[IPCC] WebSocket断开');
+        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
+          ];
+          const wasReady = this.isCallingReady;
+          this.isCallingReady = busyStatuses.includes(statusCode);
+          if (wasReady !== this.isCallingReady) {
+            console.log(`[IPCC] 坐席状态变更: ${statusCode} -> ${this.isCallingReady ? '就绪' : '未就绪'}`);
+          }
+        }
+      });
+
+      // 错误处理
+      this.ccPhoneBar.on(EventList.REQUEST_ARGS_ERROR, () => {
+        console.error('[IPCC] 请求参数错误');
+        if (!this.ccSocketConnected && this.ccConnectingReject) {
+          this.ccConnectingReject(new Error('请求参数错误'));
+        }
+      });
+
+      this.ccPhoneBar.on(EventList.SERVER_ERROR, () => {
+        console.error('[IPCC] 服务器错误');
+        if (!this.ccSocketConnected && this.ccConnectingReject) {
+          this.ccConnectingReject(new Error('服务器错误'));
+        }
+      });
+
+      // 外呼开始 - 从消息中获取UUID
+      this.ccPhoneBar.on(EventList.OUTBOUND_START, (msg) => {
+        console.log('[IPCC] 外呼开始', msg);
+        // 显示拨号中状态
+        this.showStatus('拨号中...', 'info');
+        // 从消息中获取真实的UUID
+        if (msg?.object?.uuid) {
+          const outboundUuid = msg.object.uuid;
+          this.currentCallUuid = outboundUuid;
+          this.callUuidMap[outboundUuid] = {
+            startTime: Date.now(),
+            phoneNumber: this.dialNumber,
+            status: 'outbound_start'
+          };
+          console.log(`[UUID] 外呼开始获取UUID: ${outboundUuid}`);
+        } else {
+          console.warn('[UUID] 外呼开始消息中未获取到UUID');
+        }
+      });
+
+      // 通话事件
+      this.ccPhoneBar.on(EventList.CALLEE_RINGING, () => {
+        console.log('[IPCC] 被叫振铃');
+        this.callStatus = UI_STATE.RINGING;
+        // 显示振铃状态
+        this.showStatus('振铃中...', 'info');
+        // 更新UUID状态
+        if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
+          this.callUuidMap[this.currentCallUuid].status = 'ringing';
+        }
+      });
+
+      const handleCallAnswered = (msg) => {
+        console.log('[IPCC] 通话已接听', msg);
+        // 从消息中获取真实的UUID(优先使用)
+        if (msg?.object?.uuid) {
+          const realUuid = msg.object.uuid;
+          // 如果当前没有UUID或者UUID不同,更新为真实UUID
+          if (!this.currentCallUuid || this.currentCallUuid !== realUuid) {
+            if (this.currentCallUuid && this.currentCallUuid !== realUuid) {
+              console.log(`[UUID] 更新UUID: ${this.currentCallUuid} -> ${realUuid}`);
+              // 迁移数据到新的UUID
+              if (this.callUuidMap[this.currentCallUuid]) {
+                this.callUuidMap[realUuid] = this.callUuidMap[this.currentCallUuid];
+                delete this.callUuidMap[this.currentCallUuid];
+              }
+            } else if (!this.currentCallUuid) {
+              console.log(`[UUID] 首次设置UUID: ${realUuid}`);
+              this.callUuidMap[realUuid] = {
+                startTime: Date.now(),
+                phoneNumber: this.dialNumber,
+                status: 'answered'
+              };
+            }
+            this.currentCallUuid = realUuid;
+          }
+        } else {
+          console.warn('[UUID] 接听消息中未获取到UUID');
+        }
+
+        this.callStatus = UI_STATE.TALKING;
+        this.showLeftButton = true;
+        this.showRightButton = true;
+        this.leftButtonNormal = true;
+        this.rightButtonNormal = true;
+        // 显示通话中状态
+        this.showStatus('通话中', 'success');
+
+        // 更新UUID状态
+        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 = (msg) => {
+        console.log('[IPCC] 通话结束');
+        // 捕获当前UUID到局部变量,防止被新通话覆盖
+        const callUuid = this.currentCallUuid;
+
+        // 更新UUID状态
+        if (callUuid && this.callUuidMap[callUuid]) {
+          this.callUuidMap[callUuid].status = 'ended';
+          this.callUuidMap[callUuid].endTime = Date.now();
+        }
+
+        this._resetCallState();
+
+        // 显示已挂机状态
+        this.showStatus('已挂机', 'info');
+
+        // 通话结束后,确保坐席保持忙碌状态,以便可以继续外呼
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          console.log('[挂机] 设置坐席为忙碌状态,保持可外呼');
+          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, () => {
+        console.log('[IPCC] 通话已保持');
+        this.isOnHold = true;
+        // 显示暂停通话状态
+        this.showStatus('暂停通话中', 'warn');
+      });
+
+      this.ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
+        console.log('[IPCC] 通话已恢复');
+        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;
+
+      console.log(`[账号] 设置默认账号: ${extNum}`);
+
+      // 查找已存在的账号
+      let existingUserId = null;
+      for (const [id, user] of Object.entries(this.userList)) {
+        if (user.user === extNum) {
+          existingUserId = id;
+          break;
+        }
+      }
+
+      if (existingUserId) {
+        // 更新现有账号
+        console.log(`[账号] 更新已有账号: ${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 {
+        // 创建新账号
+        console.log(`[账号] 创建新账号: ${extNum}`);
+        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) {
+        console.warn('[SIP] 无法启动: IPCC未连接');
+        this.showStatus('未连接', 'error');
+        return;
+      }
+
+      const userProfile = this.profileManager.getCurrentUserProfile();
+      if (!userProfile) {
+        console.warn('[SIP] 无可用账号');
+        this.showStatus('无可用账号', 'warn');
+        return;
+      }
+
+      if (!userProfile.user || !userProfile.domain || !userProfile.password) {
+        console.error('[SIP] 账号配置不完整');
+        this.showStatus('账号配置错误', 'error');
+        return;
+      }
+
+      console.log(`[SIP] 准备启动: ${userProfile.user}@${userProfile.domain}`);
+
+      // 检查麦克风权限
+      const hasPermission = await checkMicrophonePermission();
+      if (!hasPermission) {
+        console.warn('[SIP] 麦克风权限未授权');
+        this.showStatus('请授权麦克风', 'error');
+      }
+
+      // 销毁旧实例
+      if (this.phone) {
+        console.log('[SIP] 销毁旧实例');
+        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);
+
+      // 启动
+      console.log('[SIP] 启动WebPhone');
+      this.phone.Start(settings.reconnect);
+    },
+    onReconnectStatus({ isReconnecting, failed }) {
+      this.isReconnecting = isReconnecting;
+      this.reconnectFailed = failed;
+      if (failed) {
+        console.error('[SIP] 重连超时');
+        this.showStatus('连接超时', 'error');
+      } else if (isReconnecting) {
+        console.log('[SIP] 正在重连...');
+        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 || '';
+        console.log(`[SIP] 连接成功: ${this.currentUserDisplay}`);
+        this.showStatus('就绪', 'success');
+        // SIP 注册成功后设置 IPCC 坐席为忙碌状态
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          console.log('[SIP] 设置坐席为忙碌状态');
+          this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
+        }
+      } else if (!this.isReconnecting) {
+        console.warn('[SIP] 未注册');
+        this.showStatus('未注册', 'warn');
+      }
+    },
+    onSessionCreated(event) {
+      // 来电时自动应答
+      if (!event.outgoing && this.phone && this.settingsForm.autoAnswer) {
+        this.phone.Answer();
+      }
+    },
+    onRing(event) {
+      if (!event.outgoing) {
+        this.callStatus = UI_STATE.RINGING;
+        this.province = `${event.province || ''}${event.city ? '-' + event.city : ''}`;
+        this.showLeftButton = true;
+        this.showRightButton = true;
+        this.rightButtonHangup = true;
+        // 显示来电振铃状态
+        this.showStatus('来电振铃中...', 'info');
+        // 自动接听
+        if (this.phone) this.phone.Answer();
+      }
+    },
+    onAnswered(outgoing) {
+      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) {
+        console.log(`[通话] 状态重置: ${this.callStatus} -> idle`);
+        this.callStatus = UI_STATE.IDLE;
+        this.isOnHold = false;
+        // 注意:不清空 dialNumber,保留用户输入的号码以便再次外呼
+        // this.dialNumber = '';
+        this.province = '';
+        this.callDuration = '00:00';
+        this.showLeftButton = false;
+        this.showRightButton = false;
+        // 注意:不清空 currentCallUuid,因为后续还需要用它来保存通话记录
+      }
+    },
+    onSessionClosed() {
+      // 显示已挂机状态
+      this.showStatus('已挂机', 'info');
+      this._resetCallState();
+    },
+    onCallTimer(time) { this.callDuration = time; },
+    // 外呼 - 使用呼叫中心工具条
+    async makeCall() {
+      if (!this.ccPhoneBar || !this.ccSocketConnected) {
+        console.warn('[外呼] IPCC未连接');
+        this.showStatus('未连接', 'error');
+        return;
+      }
+      if (!this.isCallingReady) {
+        console.warn('[外呼] 坐席未就绪');
+        this.showStatus('坐席未就绪', 'warn');
+        return;
+      }
+      if (!this.dialNumber || this.dialNumber.trim().length < 3) {
+        console.warn(`[外呼] 号码无效: ${this.dialNumber}`);
+        this.showStatus('请输入正确的号码', 'warn');
+        return;
+      }
+
+      // 检查是否有正在进行的通话
+      if (this.callStatus !== UI_STATE.IDLE) {
+        console.warn('[外呼] 当前有通话正在进行');
+        this.showStatus('请先结束当前通话', 'warn');
+        return;
+      }
+
+      let phoneNumber;
+      let dialModeStr = '明文';
+
+      if (this.dialMode === 'encrypted') {
+        dialModeStr = '密文';
+
+        // 检查是否正在解密
+        if (this.isDecrypting) {
+          console.warn('[外呼] 正在解密中');
+          this.showStatus('正在解密号码,请稍候...', 'warn');
+          return;
+        }
+
+        // 检查是否已解密
+        if (!this.decryptedPhoneNumber) {
+          console.warn('[外呼] 号码未解密或解密失败');
+          this.showStatus('号码未解密或解密失败,请重新输入', 'warn');
+          return;
+        }
+
+        // 使用解密后的号码
+        phoneNumber = this.decryptedPhoneNumber;
+        console.log('[外呼] 使用解密后的号码进行呼叫');
+      } else {
+        // 明文模式:直接使用输入的号码
+        phoneNumber = this.dialNumber.trim();
+      }
+
+      console.log(`[外呼] 呼叫: ${phoneNumber}, 拨号方式: ${dialModeStr}`);
+      this.ccPhoneBar.call(phoneNumber, 'audio', VideoLevels.HD.levelId);
+    },
+    // 挂机 - 使用呼叫中心工具条
+    endCall() {
+      // 严格的状态检查,防止误操作
+      if (this.callStatus === UI_STATE.IDLE) {
+        console.warn('[挂机] 当前无通话,无法挂机');
+        this.showStatus('当前无通话', 'warn');
+        return;
+      }
+
+      console.log('[挂机] 结束通话, UUID:', this.currentCallUuid);
+      if (this.ccPhoneBar && this.ccSocketConnected) {
+        try {
+          this.ccPhoneBar.hangup();
+          console.log('[挂机] 已调用hangup方法');
+        } catch (err) {
+          console.error('[挂机] 调用hangup失败:', err);
+          this.showStatus('挂机失败', 'error');
+        }
+      } else if (this.phone) {
+        try {
+          this.phone.Terminate();
+          console.log('[挂机] 已调用Terminate方法');
+        } catch (err) {
+          console.error('[挂机] 调用Terminate失败:', err);
+          this.showStatus('挂机失败', 'error');
+        }
+      } else {
+        console.warn('[挂机] IPCC和JsSIP均未连接');
+        this.showStatus('无法挂机:未连接', 'error');
+      }
+    },
+    // ==================== 原有UI事件(修改为使用呼叫中心) ====================
+    deleteLastChar() { this.dialNumber = this.dialNumber.slice(0, -1); },
+    onDigitClick(digit) {
+      // 通话中发送DTMF
+      if (this.callStatus === 'talking' && this.phone) {
+        this.phone.SendDTMF(digit);
+        this.phone.PlayDtmfTone(digit);
+      }
+      this.dialNumber += digit;
+      this.scrollInputToEnd();
+    },
+    handleDisplayKeypress(event) {
+      const key = event.key;
+      if ((key >= '0' && key <= '9') || key === '*' || key === '#') {
+        if (this.callStatus === 'talking' && this.phone) {
+          this.phone.SendDTMF(key);
+          this.phone.PlayDtmfTone(key);
+        }
+        this.dialNumber += key;
+        this.scrollInputToEnd();
+        event.preventDefault();
+      } else if (key === 'Enter') {
+        this.onHangupClick();
+        event.preventDefault();
+      } else if (key === 'Backspace') {
+        event.preventDefault();
+        this.deleteCharByCursor();
+      } else event.preventDefault();
+    },
+    onHangupClick() {
+      // 严格的状态检查,防止误操作
+      if (this.callStatus !== UI_STATE.IDLE) {
+        // 通话中或振铃中,执行挂机
+        console.log('[操作] 结束通话');
+        this.endCall();
+      } else {
+        // 空闲状态,执行外呼(makeCall内部已有详细检查)
+        console.log('[操作] 发起外呼');
+        this.makeCall();
+      }
+    },
+    onLeftButtonClick() {
+      // 严格的状态检查,防止误操作
+      if (this.callStatus === UI_STATE.RINGING) {
+        // 接听来电
+        console.log('[操作] 接听来电');
+        if (this.phone) this.phone.Answer();
+      } else if (this.callStatus === UI_STATE.TALKING) {
+        // 保持/取消保持(仅在通话中有效)
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          try {
+            if (this.isOnHold) {
+              console.log('[操作] 恢复通话');
+              this.ccPhoneBar.unHoldCall();
+            } else {
+              console.log('[操作] 保持通话');
+              this.ccPhoneBar.holdCall();
+            }
+          } catch (err) {
+            console.error('[操作] 保持/恢复失败:', err);
+            this.showStatus('操作失败', 'error');
+          }
+        } else if (this.phone) {
+          try {
+            console.log('[操作] JsSIP切换保持状态');
+            this.phone.ToggleHold();
+          } catch (err) {
+            console.error('[操作] 保持/恢复失败:', err);
+            this.showStatus('操作失败', 'error');
+          }
+        }
+      } else {
+        // 非振铃或通话状态,忽略操作
+        console.warn('[操作] 当前状态不允许此操作:', this.callStatus);
+      }
+    },
+    onRightButtonClick() {
+      // 严格的状态检查,防止误操作
+      if (this.callStatus === UI_STATE.RINGING) {
+        // 拒绝来电
+        console.log('[操作] 拒绝来电');
+        this.endCall();
+      } else if (this.callStatus === UI_STATE.TALKING) {
+        // 呼叫转移(仅在通话中有效)
+        console.log('[操作] 呼叫转移功能暂未实现');
+        this.showStatus('呼叫转移功能暂未实现', 'info');
+      } else {
+        // 非振铃或通话状态,忽略操作
+        console.warn('[操作] 当前状态不允许此操作:', this.callStatus);
+      }
+    },
+    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();
+    },
+    setStatus(type) {
+      if (!this.phone) return;
+      if (type === 'online') { this.phone.SetQueueIn(); this.showStatus('签入成功', 'success'); }
+      else if (type === 'logout') { this.phone.SetQueueOut(); this.showStatus('已签出', 'info'); }
+      else if (type === 'offline') { this.phone.UnRegister(); this.showStatus('已离线', 'info'); }
+      this.dropdownVisible = false;
+    },
+    openAddAccountDialog() {
+      this.isEditMode = false;
+      this.editingUserId = null;
+      this.accountDialogTitle = '添加账号';
+      this.accountForm = { note: '', server: 'ws://129.28.164.235:5066', username: '', domain: '129.28.164.235', loginName: '', password: '', transport: 'ws' };
+      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);
+        this.showStatus(err.message, 'error');
+      }
+    },
+    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(() => {});
+    },
+    openSettingsDialog() { this.settingsDialogVisible = true; },
+    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: this.settingsForm.autoAnswer,
+        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() {
+      console.log('[重置] 开始重置连接...');
+      this.showStatus('正在重置...', 'info');
+
+      // 清理定时器
+      if (this.volumeTimerId) {
+        clearTimeout(this.volumeTimerId);
+        this.volumeTimerId = null;
+      }
+      if (this.statusTimerId) {
+        clearTimeout(this.statusTimerId);
+        this.statusTimerId = null;
+      }
+
+      // 销毁 SIP 实例
+      if (this.phone) {
+        console.log('[重置] 销毁SIP实例');
+        this.phone.destroy();
+        this.phone = null;
+      }
+
+      // 断开 IPCC 连接
+      if (this.ccPhoneBar) {
+        console.log('[重置] 断开IPCC连接');
+        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;
+
+      // 清理连接 Promise
+      if (this.ccConnectingPromise) {
+        if (this.ccConnectingReject) {
+          try {
+            this.ccConnectingReject(new Error('用户主动重置'));
+          } catch(e) {}
+        }
+        this.ccConnectingPromise = null;
+        this.ccConnectingResolve = null;
+        this.ccConnectingReject = null;
+      }
+
+      // 重置 UI
+      this.dialNumber = '';
+      this.province = '';
+      this.callDuration = '00:00';
+      this.showLeftButton = false;
+      this.showRightButton = false;
+      this.leftButtonNormal = false;
+      this.rightButtonNormal = false;
+      this.rightButtonHangup = false;
+      this.dropdownVisible = false;
+
+      // 重新连接
+      console.log('[重置] 重新初始化...');
+      this.showStatus('重新连接中...', 'info');
+      try {
+        await this.initCCAndStart();
+        console.log('[重置] 重连成功');
+        this.showStatus('重连成功', 'success');
+      } catch (err) {
+        console.error('[重置] 重连失败:', err.message);
+        this.showStatus('重连失败', 'error');
+      }
+    },
+    destroyAllConnections() {
+      console.log('[销毁] 清理所有连接和资源');
+
+      // 清理定时器
+      this.clearAllTimers();
+
+      // 销毁 SIP 实例
+      if (this.phone) {
+        console.log('[销毁] 销毁SIP实例');
+        try {
+          this.phone.destroy();
+        } catch(e) {
+          console.error('[销毁] SIP实例销毁失败:', e);
+        }
+        this.phone = null;
+      }
+
+      // 断开 IPCC 连接
+      if (this.ccPhoneBar) {
+        console.log('[销毁] 断开IPCC连接');
+        try {
+          this.ccPhoneBar.disconnect();
+        } catch(e) {
+          console.error('[销毁] IPCC连接断开失败:', e);
+        }
+        this.ccPhoneBar = null;
+      }
+
+      // 清空通话记录映射
+      this.callUuidMap = {};
+      this.currentCallUuid = '';
+
+      // 重置所有状态
+      this.resetAllStates();
+
+      console.log('[销毁] 资源清理完成');
+    },
+    /**
+     * 清除所有定时器
+     */
+    clearAllTimers() {
+      if (this.volumeTimerId) {
+        clearTimeout(this.volumeTimerId);
+        this.volumeTimerId = null;
+      }
+      if (this.statusTimerId) {
+        clearTimeout(this.statusTimerId);
+        this.statusTimerId = null;
+      }
+      console.log('[销毁] 定时器已清理');
+    },
+    /**
+     * 重置所有状态
+     */
+    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;
+      console.log('[销毁] 状态已重置');
+    },
+    /**
+     * 移除事件监听器
+     */
+    removeEventListeners() {
+      // 如果phone实例存在,移除所有事件监听
+      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);
+          console.log('[销毁] SIP事件监听器已移除');
+        } catch(e) {
+          console.error('[销毁] 移除SIP事件监听器失败:', e);
+        }
+      }
+      // 如果ccPhoneBar存在,移除所有事件监听
+      if (this.ccPhoneBar) {
+        try {
+          // ccPhoneBar的事件监听由SDK内部管理,disconnect时会清理
+          console.log('[销毁] IPCC事件监听器将随disconnect清理');
+        } catch(e) {
+          console.error('[销毁] 移除IPCC事件监听器失败:', e);
+        }
+      }
+      // 移除加密号码监听器
+      if (this.encryptedNumberUnwatch) {
+        try {
+          this.encryptedNumberUnwatch();
+          this.encryptedNumberUnwatch = null;
+          console.log('[销毁] 加密号码监听器已移除');
+        } catch(e) {
+          console.error('[销毁] 移除加密号码监听器失败:', e);
+        }
+      }
+    },
+    handleBeforeUnload() { this.destroyAllConnections(); },
+
+    /**
+     * 生成通话UUID
+     */
+    _generateCallUuid() {
+      // 使用时间戳 + 随机数确保唯一性
+      const timestamp = Date.now().toString(36);
+      const randomPart = Math.random().toString(36).substring(2, 15);
+      const uuid = `call_${timestamp}_${randomPart}`;
+      console.log(`[UUID] 生成通话ID: ${uuid}`);
+      return uuid;
+    },
+
+    /**
+     * 处理通话结束,保存通话记录
+     */
+    _handleCallEnd(callUuid) {
+      if (!callUuid) {
+        console.warn('[通话结束] 未获取到通话 UUID');
+        return;
+      }
+
+      console.log('[通话结束] 准备保存通话记录, UUID:', callUuid);
+
+      // 延时10秒异步处理通话结束同步(与aiSipCallManualOutbound保持一致)
+      setTimeout(() => {
+        syncByUuid({ uuid: callUuid }).then(() => {
+          console.log('[通话结束] 后端同步通话记录成功, UUID:', callUuid);
+          // 清理已完成的通话记录(保留最近10条用于调试)
+          const uuidKeys = Object.keys(this.callUuidMap);
+          if (uuidKeys.length > 10) {
+            // 删除最早的记录
+            const oldestUuid = uuidKeys[0];
+            delete this.callUuidMap[oldestUuid];
+          }
+        }).catch(error => {
+          console.error('[通话结束] 后端同步通话记录失败, UUID:', callUuid, ', 错误:', error);
+        });
+      }, 10000);
+    },
+
+    /**
+     * 设置加密号码监听器
+     */
+    setupEncryptedNumberWatcher() {
+      // 保存watcher的unwatch函数,以便在组件销毁时清理
+      this.encryptedNumberUnwatch = this.$watch(
+        () => ({ dialMode: this.dialMode, dialNumber: this.dialNumber }),
+        async (newVal, oldVal) => {
+          // 当拨号方式为密文且电话号码不为空时,自动解密
+          if (newVal.dialMode === 'encrypted' && newVal.dialNumber && newVal.dialNumber.trim().length > 0) {
+            // 如果之前有正在进行的解密,先重置状态
+            if (this.isDecrypting || this.encryptingLock) {
+              console.log('[解密中断] 取消之前的解密请求,准备新解密');
+              this.isDecrypting = false;
+              this.encryptingLock = false;
+              // 等待一小段时间确保状态完全重置
+              await new Promise(resolve => setTimeout(resolve, 100));
+            }
+            await this.decryptPhoneNumber(newVal.dialNumber.trim());
+          } else {
+            // 如果切换到明文或清空号码,清除解密后的号码
+            this.decryptedPhoneNumber = '';
+            this.isDecrypting = false;
+            this.encryptingLock = false;
+          }
+        },
+        { deep: true }
+      );
+    },
+
+    /**
+     * 实时解密号码(返回 Promise)
+     */
+    async decryptPhoneNumber(phoneNumber) {
+      // 防止重复解密(增加锁检查)
+      if (this.isDecrypting || this.encryptingLock) {
+        console.log('[解密跳过] 已有解密请求进行中或锁定中');
+        return;
+      }
+
+      try {
+        this.isDecrypting = true;
+        this.encryptingLock = true;
+        console.log('[开始解密] 正在调用后端接口解密号码...');
+
+        const combined = phoneNumber + this.generateRandom();
+
+        const response = await encryptMobile({"data": combined});
+
+        if (response.code === 200) {
+          // 用密钥解密
+          const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+          // 去掉后六位数据
+          const resultData = response.data.slice(0, -6);
+          // 保存解密后的号码(用于拨号)
+          this.decryptedPhoneNumber = this.xorDecrypt(resultData, privateKey);
+          console.log('[解密成功] 号码已解密,可用于拨号');
+        } else {
+          this.decryptedPhoneNumber = '';
+          this.showStatus('号码解密失败 ', 'error');
+          console.error('[解密失败] 后端返回错误:', response);
+        }
+      } catch (error) {
+        this.decryptedPhoneNumber = '';
+        this.showStatus('号码解密异常', 'error');
+        console.error('[解密异常] 捕获到错误:', error);
+      } finally {
+        // 强制释放锁,确保可以再次拨号
+        this.isDecrypting = false;
+        this.encryptingLock = false;
+        console.log('[解密完成] 锁已释放,当前解密状态:', this.isDecrypting, '锁定状态:', this.encryptingLock);
+      }
+    },
+
+    /**
+     * 生成6位随机数
+     */
+    generateRandom() {
+      return Math.floor(100000 + Math.random() * 900000).toString();
+    },
+
+    /**
+     * XOR 解密(Base64 输入,私钥字符串)
+     */
+    xorDecrypt(base64Str, privateKey) {
+      // Base64 解码为二进制字符串
+      const binaryStr = atob(base64Str);
+      const keyBytes = privateKey.split('').map(ch => ch.charCodeAt(0));
+      let result = '';
+      for (let i = 0; i < binaryStr.length; i++) {
+        const dataByte = binaryStr.charCodeAt(i);
+        const keyByte = keyBytes[i % keyBytes.length];
+        result += String.fromCharCode(dataByte ^ keyByte);
+      }
+      return result;
+    },
+
+    /**
+     * 处理拨号模式切换
+     */
+    handleDialModeChange() {
+      console.log(`[拨号模式] 切换到: ${this.dialMode === 'plaintext' ? '明文' : '密文'}`);
+      // 如果切换到明文,清除解密后的号码
+      if (this.dialMode === 'plaintext') {
+        this.decryptedPhoneNumber = '';
+        this.isDecrypting = false;
+        this.encryptingLock = false;
+      }
+    }
+  }
+};
+</script>
+
+<style scoped>
+@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
+
+.webphone-container {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 9999;
+}
+
+.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: all 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;
+}
+.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;
+}
+
+/* 解密状态提示 */
+.decrypting-tip {
+  color: #ff9800;
+  font-size: 12px;
+  margin-top: 4px;
+  animation: pulse 1.5s ease-in-out infinite;
+  font-weight: 500;
+}
+.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: 1000;
+  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: 100;
+  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;
+}
+
+/* 明文/密文选择框 */
+.dial-mode-selector {
+  position: absolute;
+  top: -28px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: 16px;
+  z-index: 10;
+  white-space: nowrap;
+}
+
+.radio-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  cursor: pointer;
+  font-size: 13px;
+  color: #666;
+  user-select: none;
+  white-space: nowrap;
+  transition: color 0.2s ease;
+}
+.radio-label:hover {
+  color: #2196f3;
+}
+
+.radio-label input[type="radio"] {
+  margin: 0;
+  cursor: pointer;
+}
+
+.radio-label span {
+  font-weight: 500;
+}
+.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;
+}
+.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;
+  backdrop-filter: blur(8px);
+  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); color: white; }
+.status-bar-message.success { background: rgba(76, 175, 80, 0.9); color: white; }
+.status-bar-message.warn { background: rgba(255, 152, 0, 0.9); color: white; }
+.status-bar-message.info { background: rgba(33, 150, 243, 0.9); color: white; }
+.reconnect-failed {
+  font-size: 11px;
+  background: rgba(244, 67, 54, 0.95);
+  padding: 4px 10px;
+  border-radius: 16px;
+  color: white;
+  backdrop-filter: blur(8px);
+  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: 1000;
+  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;
+}
+.info-icon {
+  font-size: 16px;
+  color: #bbb;
+  cursor: help;
+  transition: all 0.2s ease;
+}
+.info-icon:hover {
+  color: #2196f3;
+  transform: scale(1.1);
+}
+.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 button i.material-icons {
+  font-size: 18px;
+  vertical-align: middle;
+  margin-right: 4px;
+}
+.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>