Selaa lähdekoodia

鸿德堂 SIP外呼相关

Long 6 päivää sitten
vanhempi
commit
51e60d60a3

+ 3 - 0
.env.development

@@ -51,3 +51,6 @@ VUE_APP_PROJECT_FROM=dev
 
 #1、正常搜索下拉框 2、查询出200条数据,然后搜索这200条以内的
 VUE_APP_COURSE_COMPANY_NAME = 2
+
+# 电话加密密钥
+VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY=hdt112233

+ 3 - 0
.env.prod-hdt

@@ -26,3 +26,6 @@ VUE_APP_LIVE_WS_URL = ws://live.hbhdt.top/ws
 
 # 路由懒加载
 VUE_CLI_BABEL_TRANSPILE_MODULES = true
+
+# 电话加密密钥
+VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY=hdt112233

+ 3 - 1
package.json

@@ -98,6 +98,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",
@@ -126,7 +127,8 @@
     "vue2-ace-editor": "0.0.15",
     "vuedraggable": "^2.20.0",
     "vuex": "3.1.0",
-    "wangeditor": "^4.6.13"
+    "wangeditor": "^4.6.13",
+    "worker-plugin": "^5.0.1"
   },
   "devDependencies": {
     "@vue/cli-plugin-babel": "4.4.4",

+ 2 - 0
public/index.html

@@ -9,6 +9,8 @@
     <title><%= webpackConfig.name %></title>
     <script charset="utf-8" src="https://map.qq.com/api/js?v=2.exp&key=ONIBZ-44LLJ-QHYFI-KGG6Y-5ADJT-A7BIO&libraries=drawing"></script>
     <script src="/sdk.js" charset="utf-8"></script>
+    <!-- JsSIP library for soft phone -->
+    <script src="https://jssip.net/download/releases/jssip-3.10.0.js"></script>
 
 	  <style>
     html,

+ 1 - 1
src/api/aiSipCall/aiSipCallGateway.js

@@ -10,7 +10,7 @@ export function listAiSipCallGateway(query) {
 }
 
 // 查询aiSIP外呼网关列表
-export function remoteList(data) {
+export function remoteGatewayList(data) {
     return request({
         url: '/company/aiSipCall/gateway/remoteList',
         method: 'post',

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

@@ -86,3 +86,20 @@ export function syncByUuid(data) {
     })
 }
 
+// 获取加密电话
+export function encryptMobile(data) {
+  return request({
+    url: '/company/aiSipCall/outboundCdr/encryptMobile',
+    method: 'post',
+    data: data
+  })
+}
+
+// 同步当前通话记录
+export function callEndSyncByUuid(data) {
+  return request({
+    url: '/company/aiSipCall/outboundCdr/callEndSyncByUuid',
+    method: 'post',
+    data: data
+  })
+}

+ 3 - 4
src/api/aiSipCall/aiSipCallUser.js

@@ -59,11 +59,10 @@ export function exportAiSipCallUser(query) {
   })
 }
 // 获取未绑定的分机列表
-export function getUnBindExtnum(query) {
+export function getUnBindExtnum(companyId) {
   return request({
-    url: '/company/aiSipCall/aiSipCallUser/getUnBindExtnum',
-    method: 'get',
-    params: query
+    url: '/company/aiSipCall/aiSipCallUser/getCompanyUnBindExtnum/' + companyId,
+    method: 'get'
   })
 }
 

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

@@ -0,0 +1,972 @@
+// softPhone.js - WebPhone核心逻辑与配置管理(使用 localStorage 存储,密码混淆)
+// 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
+
+import * as JsSIP from 'jssip';
+
+// ========== 回铃音 URL(使用本地音频文件) ==========
+export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
+
+// ========== 默认配置常量(IPCC 与 JsSIP 严格分离) ==========
+
+/** IPCC(呼叫中心)服务器默认配置 */
+export const IPCC_DEFAULTS = {
+  SERVER_PROD: 'sip.ylrzcloud.com',
+  SERVER_LOCAL: '129.28.164.235',
+  PORT_LOCAL: 1081,
+  CONNECT_TIMEOUT: 15000,
+  HEARTBEAT_INTERVAL: 16
+};
+
+/** JsSIP(软电话)SIP 默认配置 */
+export const JS_SIP_DEFAULTS = {
+  SERVER: 'wss://sip.ylrzcloud.com:8443',//线上环境
+  //SERVER: 'ws://sip.ylrzcloud.com:5066',//本地环境
+  DOMAIN: 'sip.ylrzcloud.com',
+  TRANSPORT: 'wss',
+  USER_AGENT: 'JsSIP',
+  SESSION_EXPIRES: 180,
+  MIN_SESSION_EXPIRES: 120,
+  SPEAKER_VOLUME: 0.8,
+  MIC_VOLUME: 0.8,
+  RECONNECT_INTERVAL: 15,
+  RECONNECT_TOTAL_DURATION: 60000
+};
+
+/**
+ * 将字符串转换为 Base64(安全,使用 TextEncoder 替代已弃用的 unescape)
+ */
+const toBase64 = (str) => {
+  const bytes = new TextEncoder().encode(str);
+  return btoa(String.fromCharCode(...bytes));
+};
+
+/**
+ * 从 Base64 解码为字符串(安全,使用 TextDecoder 替代已弃用的 escape)
+ */
+const fromBase64 = (b64) => {
+  const binary = atob(b64);
+  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
+  return new TextDecoder().decode(bytes);
+};
+
+/**
+ * 简单的密码混淆(非加密,仅避免明文暴露)
+ * 注意:生产环境应使用服务端认证token或OAuth
+ */
+const encodePassword = (pwd) => {
+  if (!pwd) return '';
+  try {
+    const timestamp = Date.now().toString(36);
+    const pwdStr = String(pwd);
+    const encoded = toBase64(pwdStr);
+    return `${timestamp}:${encoded}`;
+  } catch (e) {
+    try {
+      return btoa(String(pwd));
+    } catch (fallbackError) {
+      return '';
+    }
+  }
+};
+
+const decodePassword = (encoded) => {
+  if (!encoded) return '';
+  try {
+    let base64Part = encoded;
+    if (encoded.includes(':')) {
+      base64Part = encoded.split(':')[1];
+    }
+    return fromBase64(base64Part);
+  } catch (e) {
+    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: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
+      user_agent: JS_SIP_DEFAULTS.USER_AGENT,
+      session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
+      speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
+      mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME,
+      speaker_paused: false,
+      mic_paused: false,
+      auto_answer: false,
+      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 = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
+    this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
+    this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
+    this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
+    this.profile.auto_answer = false;
+    this.profile.stun = false;
+    this.profile.ice_server = '';
+    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 newUser = updatedProfile.user;
+      const newDomain = updatedProfile.domain;
+      const newUserId = `${newUser}@${newDomain}`;
+
+      if (newUserId !== userId) {
+        // user 或 domain 改变了,删除旧条目并用新 key 创建
+        const merged = {
+          ...this.profile.users[userId],
+          ...updatedProfile,
+          user: newUser,
+          domain: newDomain
+        };
+        delete this.profile.users[userId];
+        this.profile.users[newUserId] = merged;
+        if (this.profile.user === userId) {
+          this.profile.user = newUserId;
+        }
+      } else {
+        // Key 没变,直接更新
+        this.profile.users[userId] = {
+          ...this.profile.users[userId],
+          ...updatedProfile,
+          user: newUser,
+          domain: newDomain
+        };
+      }
+      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 = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
+    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.peerConnection = null;
+    this.localStream = null;
+
+    this.initUA();
+  }
+
+  randomUUID() {
+    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+      return crypto.randomUUID().replace(/-/g, '');
+    }
+    const arr = new Uint8Array(16);
+    if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
+      crypto.getRandomValues(arr);
+    } else {
+      for (let i = 0; i < 16; i++) {
+        arr[i] = Math.floor(Math.random() * 256);
+      }
+    }
+    arr[6] = (arr[6] & 0x0f) | 0x40;
+    arr[8] = (arr[8] & 0x3f) | 0x80;
+    const hex = Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
+    return hex;
+  }
+
+  initUA() {
+    if (!this.profile.server || !this.profile.user || !this.profile.domain) {
+      console.error('[jsSip] 配置不完整');
+      this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
+      return;
+    }
+    // JsSIP 3.x 底层创建 WebSocket 时已自动使用 'sip' 子协议(RFC 7118)
+    const socket = new JsSIP.WebSocketInterface(this.profile.server);
+
+    // 修复 JsSIP 3.x 的 via_transport 问题:
+    // 对于 WSS 连接,JsSIP 设置 via_transport = 'WSS'(WebSocketInterface.js:23)
+    // 但 RFC 3261/7118 中 Via header transport 应用 'WS'(不分是否 TLS)
+    // 若服务器不识别 'WSS' 会静默丢弃 REGISTER 请求,导致超时
+    if (String(this.profile.server || '').startsWith('wss://')) {
+      socket.via_transport = 'WS';
+      console.log('[jsSip] WSS 连接: 修正 via_transport WSS → WS(RFC 7118)');
+    }
+
+    const user = String(this.profile.user || '');
+    const 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 || '');
+    // SIP over WebSocket 的 transport 始终为 'ws'(RFC 7118),与是否 WSS/TLS 无关
+    // 用户填的 transport 只用于 UI 显示,实际 SIP 信令统一使用 'ws'
+    const transport = 'ws';
+
+    const domain = String(this.profile.domain || '');
+    if (!user || !domain || !password) {
+      console.error('[jsSip] 账号配置缺失:', { user, domain, hasPassword: !!password });
+      this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
+      return;
+    }
+
+    // 检测混合内容问题:HTTP 页面使用 WSS 连接会被浏览器阻止
+    if (server.startsWith('wss://') && window.location.protocol === 'http:') {
+      console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容,请使用 HTTPS 或改用 WS 协议');
+      this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
+    }
+
+    const uri = new JsSIP.URI('sip', user, domain);
+    const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
+
+    this.configuration = {
+      sockets: [socket],
+      authorization_user: user,
+      user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
+      display_name: displayName || undefined,
+      session_timers: true,
+      session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
+      no_answer_timeout: 60,
+      register: true,
+      uri: uri.toAor(),
+      contact_uri: contactUriStr,
+      // 始终使用 password 模式进行认证
+      // 移除旧的 ha1 启发式判断(password.length === 32),避免错误地将32位密码当成HA1
+      password: password
+    };
+
+    console.log(`[jsSip] UA配置完成: ${user}@${domain}, 服务器: ${server}, 认证方式: password`);
+  }
+
+  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));
+    this.ua.on('transportError', this.transportError.bind(this));
+  }
+
+  On(event, callback) {
+    this.events[event] = callback;
+  }
+
+  Off(event) {
+    if (this.events[event]) {
+      delete this.events[event];
+    }
+  }
+
+  emit(event, ...args) {
+    if (this.events[event]) {
+      try {
+        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(`[jsSip] 启动 ${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('[jsSip] 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('[jsSip] 重连超时(超过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(`[jsSip] ${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);
+  }
+
+  transportError(err) {
+    // 详细输出 WebSocket 错误信息,帮助排查连接问题
+    const errorCode = err?.code || '';
+    const errorMessage = err?.message || '';
+    const errorReason = err?.reason || '';
+    console.error('[SIP] WebSocket传输错误:', {
+      code: errorCode,
+      message: errorMessage,
+      reason: errorReason,
+      server: this.profile?.server || 'unknown',
+      fullError: err
+    });
+
+    // 针对常见错误给出中文提示
+    let detail = errorMessage || errorReason || JSON.stringify(err);
+    let errorText = 'WSS连接失败: ' + detail;
+
+    if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content') || errorMessage.includes('Mixed Content')) {
+      errorText = 'WSS连接被浏览器阻止: 页面是HTTP协议,无法连接安全的WSS服务。请使用HTTPS访问页面或改用WS协议';
+    } else if (errorCode === 1006 || errorMessage.includes('close with code 1006')) {
+      errorText = 'WSS连接异常关闭(code 1006): 服务器无响应或SSL证书错误, 服务地址:' + (this.profile?.server || 'unknown');
+    } else if (errorCode === 1005 || errorMessage.includes('close with code 1005')) {
+      errorText = 'WSS连接被拒绝(code 1005): 服务器不接受WebSocket连接, 请检查服务地址和端口';
+    }
+
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
+    if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
+
+  // ---- JsSIP 事件处理 ----
+  connecting() {
+    console.log('[jsSip] 连接中...');
+    this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' });
+  }
+  connected() {
+    console.log('[jsSip] 已连接,开始注册');
+    this.Register();
+    this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' });
+  }
+  disconnected(e) {
+    if (this._isHandlingDisconnect) return;
+    this._isHandlingDisconnect = true;
+    const reason = e?.cause || e?.message || '未知原因';
+    const code = e?.code || '';
+    console.error(`[SIP] 连接断开: ${reason}`, {
+      code,
+      cause: e?.cause,
+      message: e?.message,
+      server: this.profile?.server || 'unknown',
+      fullEvent: e
+    });
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'error', text: 'WSS断开: ' + reason });
+    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('[jsSip] 连接成功');
+    this.resetReconnectState();
+    this.emit('OnRegister', { registered: true });
+    this.emit('OnStatusMessage', { type: 'success', text: 'jsSip连接成功' });
+    this.SetQueueIn();
+  }
+  unregistered() {
+    console.log('[jsSip] 已注销');
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' });
+  }
+  registrationFailed(e) {
+    const cause = e?.cause || e?.message || '未知原因';
+    const statusCode = e?.response?.status_code || '';
+    console.error('[jsSip] 注册失败:', {
+      cause,
+      status_code: statusCode,
+      response: e?.response,
+      server: this.profile?.server || 'unknown',
+      user: this.profile?.user || 'unknown',
+      domain: this.profile?.domain || 'unknown'
+    });
+
+    // 根据失败原因给出更明确的中文提示
+    let errorText = '注册失败: ' + cause;
+    if (cause === 'Connection Error') {
+      errorText = '注册失败: 无法连接到SIP服务器,请检查服务器地址 ' + (this.profile?.server || '') + ' 是否可访问';
+    } else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401') || cause.includes('Unauthorized')) {
+      errorText = '注册失败: 认证失败(code:' + statusCode + '),请检查分机号和密码是否正确';
+    } else if (cause.includes('404') || cause.includes('Not Found')) {
+      errorText = '注册失败: 用户不存在(code:404),请检查分机号是否正确';
+    } else if (cause.includes('408') || cause.includes('Timeout') || cause.includes('timeout')) {
+      errorText = '注册失败: 注册请求超时,服务器无响应';
+    }
+
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
+    if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
+  registrationExpiring() {
+    console.log('[jsSip] 注册即将过期,重新注册');
+    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();
+    }
+  }
+
+  isMicMuted() {
+    return this.session ? this.session.isMuted().audio : false;
+  }
+
+  SetSpeaker(paused, volume) {
+    this.remoteMedia.volume = paused ? 0 : volume;
+    this.ringbackMedia.volume = paused ? 0 : volume;
+  }
+
+  SetMicPhone(paused, volume) {
+    this.localMedia.volume = paused ? 0 : volume;
+    if (this.localStream) {
+      this.localStream.getAudioTracks().forEach((track) => {
+        track.enabled = !paused;
+      });
+    }
+  }
+
+  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;
+    }
+
+    // 保存 peerConnection 引用
+    this.peerConnection = connection;
+
+    // 监听远程流
+    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();
+      }
+    };
+
+    // 如果已有本地流,先清理
+    if (this.localStream) {
+      console.log('[音频] 清理旧的本地流');
+      this.localStream.getTracks().forEach(track => {
+        track.stop();
+        if (this.peerConnection) {
+          try {
+            this.peerConnection.removeTrack(track, this.localStream);
+          } catch (err) {
+            console.warn('[音频] 移除旧轨道:', err.message);
+          }
+        }
+      });
+      this.localStream = null;
+    }
+
+    // 清理 localMedia 的旧 srcObject
+    if (this.localMedia.srcObject) {
+      this.localMedia.srcObject = null;
+    }
+
+    // 获取并添加本地流
+    navigator.mediaDevices.getUserMedia({ audio: true })
+      .then(stream => {
+        this.localStream = stream;
+        this.localMedia.srcObject = stream;
+        stream.getTracks().forEach(track => {
+          try {
+            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;
+
+    console.log('[通话] 会话关闭,开始清理资源');
+    this.session = null;
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    this.pauseRingback();
+
+    // 清理远程媒体
+    if (this.remoteMedia.srcObject) {
+      this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
+      this.remoteMedia.srcObject = null;
+      console.log('[音频] 远程媒体已清理');
+    }
+
+    // 清理本地媒体
+    if (this.localMedia.srcObject) {
+      this.localMedia.srcObject.getTracks().forEach(t => t.stop());
+      this.localMedia.srcObject = null;
+      console.log('[音频] 本地媒体已清理');
+    }
+
+    // 清理本地流引用
+    if (this.localStream) {
+      this.localStream.getTracks().forEach(t => t.stop());
+      this.localStream = null;
+      console.log('[音频] 本地流已清理');
+    }
+
+    // 清理 peerConnection
+    if (this.peerConnection) {
+      try {
+        this.peerConnection.close();
+        console.log('[音频] PeerConnection 已关闭');
+      } catch (err) {
+        console.warn('[音频] 关闭PeerConnection失败:', err.message);
+      }
+      this.peerConnection = null;
+    }
+
+    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, IPCC_DEFAULTS, JS_SIP_DEFAULTS };

+ 11 - 0
src/api/crm/customer.js

@@ -196,3 +196,14 @@ export function getMyAssistList(query) {
     params: query
   })
 }
+
+/**
+ * 获取SIP手机号
+ */
+export function getSipPhoneNumber(query) {
+  return request({
+    url: '/crm/customer/getSipPhoneNumber',
+    method: 'get',
+    params: query
+  })
+}

+ 11 - 0
src/api/user/fsUser.js

@@ -120,3 +120,14 @@ export function batchSendCourse(data) {
     data: data
   })
 }
+
+/**
+ * 获取SIP手机号
+ */
+export function getSipPhoneNumber(query) {
+  return request({
+    url: '/user/fsUser/getSipPhoneNumber',
+    method: 'get',
+    params: query
+  })
+}

BIN
src/assets/voice/ringback.wav


+ 34 - 8
src/layout/index.vue

@@ -55,6 +55,7 @@ import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
 import ResizeMixin from './mixin/ResizeHandler'
 import { mapState } from 'vuex'
 import variables from '@/assets/styles/variables.scss'
+import softPhone from '@/utils/SoftPhoneService'
 
 export default {
   name: 'Layout',
@@ -100,20 +101,40 @@ export default {
     }
   },
   watch: {
-    callTitle(newValue, oldValue) {
-      if(newValue.indexOf("正在呼叫")!==-1){
-        this.playAudio();
-      }else{
-        this.pauseAudio();
+  },
+  mounted() {
+    softPhone.ensureConnected().catch(() => {})
+    // 监听通话状态变化,联动 Vuex + 音频
+    this._unregCallStatus = softPhone.on('call-status-changed', (status) => {
+      if (status === 'ringing') {
+        this._unregCallDuration?.()
+        this._unregCallDuration = null
+        this.$store.dispatch('Call', { mobile: softPhone.displayNumber })
+      } else if (status === 'talking') {
+        this.$store.commit('SET_CALL', true)
+        // 立即显示当前通话时长
+        this.$store.dispatch('CallStatus', { title: `正在通话 ${softPhone.callDuration}` })
+        // 持续监听通话时长更新
+        this._unregCallDuration = softPhone.on('call-duration', (duration) => {
+          this.$store.dispatch('CallStatus', { title: `正在通话 ${duration}` })
+        })
+      } else if (status === 'idle') {
+        this._unregCallDuration?.()
+        this._unregCallDuration = null
+        this.$store.dispatch('CallOff')
       }
-    }
+    })
+  },
+  beforeDestroy() {
+    if (this._unregCallStatus) this._unregCallStatus()
+    if (this._unregCallDuration) this._unregCallDuration()
   },
   methods: {
     handleClickOutside() {
       this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
     },
     callClose(){
-      this.callOff()
+      softPhone.hangup()
       this.$store.dispatch('CallOff')
     },
     playAudio() {
@@ -135,7 +156,12 @@ export default {
       this.mobile= this.mobile.substring(0,this.mobile.length-1);
     },
     callPhone(){
-      this.playAudio();
+      if (!this.mobile || this.mobile.trim().length < 3) {
+        this.$message.warning('请输入正确的号码')
+        return
+      }
+      softPhone.call(this.mobile.trim())
+      this.call.open = false
     },
     closeDialog(){
       this.clearNumber();

+ 0 - 2
src/main.js

@@ -15,7 +15,6 @@ import elementDirective from './directive/select'
 import './assets/icons' // icon
 import './permission' // permission control
 import { callNumber,callOff } from "@/utils/call";
-import { callMobile } from "@/api/company/companyVoiceApi"
 import { getDicts } from "@/api/system/dict/data";
 import { getConfigKey } from "@/api/system/config";
 import {cloneObject, parseTime, resetForm, addDateRange, selectDictLabel, selectDictLabels, download, handleTree } from "@/utils/common";
@@ -70,7 +69,6 @@ import audio from 'vue-mobile-audio'
 Vue.use(audio)
 Vue.prototype.callNumber = callNumber
 Vue.prototype.callOff = callOff
-Vue.prototype.callMobile = callMobile
 
 // 全局组件挂载
 Vue.component('DictTag', DictTag)

+ 1 - 1
src/permission.js

@@ -32,7 +32,7 @@ router.beforeEach((to, from, next) => {
             next({ path: '/' })
           })
         })
-        store.dispatch('GetSipAccount').then(() => {}).catch(() => {})
+        // store.dispatch('GetSipAccount').then(() => {}).catch(() => {})
       } else {
         next()
       }

+ 685 - 0
src/utils/SoftPhoneService.js

@@ -0,0 +1,685 @@
+import { checkMicrophonePermission, ProfileManager, WebPhone, IPCC_DEFAULTS, JS_SIP_DEFAULTS } from '@/api/aiSipCall/softPhone'
+import { getToolbarBasicParam, myCallUser } from '@/api/aiSipCall/aiSipCallUser'
+import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket'
+import { AgentStatusEnum, EventList, VideoLevels } from '@/assets/callCenterPhoneBarSdk/constants'
+import { callEndSyncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr'
+import { Notification } from 'element-ui'
+
+// ==================== 配置常量 ====================
+const IPCC_CONFIG = IPCC_DEFAULTS
+
+const SIP_DEFAULT_CONFIG = JS_SIP_DEFAULTS
+
+const UI_STATE = {
+  IDLE: 'idle',
+  RINGING: 'ringing',
+  TALKING: 'talking'
+}
+
+/**
+ * 软电话单例服务
+ *
+ * 管理 SIP/IPCC 连接生命周期,提供 call/hangup 能力。
+ * 使用前必须调用 ensureConnected() 完成初始化。
+ */
+class SoftPhoneService {
+  // ==================== 状态 ====================
+  _ccPhoneBar = null
+  _phone = null
+  _profileManager = null
+  _userList = {}
+  _currentUserId = ''
+  _settingsForm = {}
+  _extNum = null
+
+  // 连接状态
+  _ccSocketConnected = false
+  _isRegistered = false
+  _isConnected = false
+  _isCallingReady = false
+  _callStatus = UI_STATE.IDLE
+  _isOnHold = false
+  _isReconnecting = false
+  _reconnectFailed = false
+
+  // 通话管理
+  _currentCallUuid = ''
+  _callUuidMap = {}
+  _callDuration = '00:00'
+  _dialNumber = ''
+  /** UI 展示用号码(掩码/密文),优先于 _dialNumber 显示 */
+  _displayNumber = ''
+  /** 被叫接听时刻的时间戳,用于从接听开始计算时长 */
+  _answeredAt = 0
+
+  /** 展示号码 */
+  get displayNumber() {
+    return this._displayNumber
+  }
+
+  /** 将明文手机号掩码为 138******** 格式 */
+  _maskPhone(phone) {
+    const s = String(phone || '')
+    if (s.length <= 4) return s
+    return s.slice(0, 3) + '*'.repeat(s.length - 3)
+  }
+
+  // 连接 promise(防重复连接)
+  _ccConnectingPromise = null
+  _ccConnectingResolve = null
+  _ccConnectingReject = null
+
+  // 初始化状态
+  _initialized = false
+  _initializing = false
+  _initPromise = null
+
+  // 事件监听器
+  _listeners = {}
+
+  constructor() {
+    // 页面关闭时清理连接
+    if (typeof window !== 'undefined') {
+      window.addEventListener('beforeunload', () => this.destroy())
+    }
+  }
+
+  // ==================== 事件系统 ====================
+
+  on(event, callback) {
+    if (!this._listeners[event]) this._listeners[event] = []
+    this._listeners[event].push(callback)
+    return () => this.off(event, callback)
+  }
+
+  off(event, callback) {
+    const cbs = this._listeners[event]
+    if (!cbs) return
+    const idx = cbs.indexOf(callback)
+    if (idx !== -1) cbs.splice(idx, 1)
+  }
+
+  _emit(event, ...args) {
+    const cbs = this._listeners[event]
+    if (cbs) cbs.forEach(cb => cb(...args))
+  }
+
+  // ==================== 公共 API ====================
+
+  /** 当前通话状态 */
+  get callStatus() { return this._callStatus }
+
+  /** 是否已连接就绪 */
+  get isReady() { return this._ccSocketConnected && this._isRegistered && this._isCallingReady }
+
+  /** 确保连接已建立(幂等,可重复调用) */
+  async ensureConnected() {
+    if (this._initialized) return
+    if (this._initializing) return this._initPromise
+    this._initializing = true
+    this._initPromise = this._doInit()
+    try {
+      await this._initPromise
+      this._initialized = true
+    } finally {
+      this._initializing = false
+      this._initPromise = null
+    }
+  }
+
+  /**
+   * 拨号/挂机切换
+   * - 空闲状态 → 发起外呼
+   * - 通话/振铃 → 挂机
+   */
+  async call(phoneNumber) {
+    if (this._callStatus !== UI_STATE.IDLE) {
+      this._endCall()
+      return
+    }
+    this._dialNumber = String(phoneNumber || '')
+    this._displayNumber = this._maskPhone(phoneNumber)
+    await this._makeCall()
+  }
+
+  /** 强制挂机 */
+  hangup() {
+    this._endCall()
+  }
+
+  /** 销毁所有连接 */
+  destroy() {
+    if (this._phone) {
+      // 先解绑 WebPhone 事件,再销毁实例
+      const events = ['OnRegister', 'OnSessionCreated', 'OnRing', 'OnAnswered', 'OnSessionClosed', 'OnCallTimer', 'OnStatusMessage', 'OnReconnectStatus']
+      events.forEach(evt => { try { this._phone.Off(evt) } catch (e) { /* ignore */ } })
+      try { this._phone.destroy() } catch (e) { /* ignore */ }
+      this._phone = null
+    }
+    if (this._ccPhoneBar) {
+      try { this._ccPhoneBar.disconnect() } catch (e) { /* ignore */ }
+      this._ccPhoneBar = null
+    }
+    this._callUuidMap = {}
+    this._currentCallUuid = ''
+    this._resetAllStates()
+  }
+
+  // ==================== 初始化流程 ====================
+
+  async _doInit() {
+    this._profileManager = new ProfileManager()
+    const profile = this._profileManager.getProfile()
+    this._userList = profile.users || {}
+    this._currentUserId = profile.user || ''
+    this._settingsForm = { ...(this._profileManager.getSettings()) }
+
+    try {
+      await this._initCCAndStart()
+      console.log('[SoftPhone] 初始化完成')
+      Notification.success({ title: '坐席已就绪',showClose: false })
+    } catch (err) {
+      console.error('[SoftPhone] 初始化失败:', err.message)
+      throw err
+    }
+  }
+
+  async _initCCAndStart() {
+    this._ccSocketConnected = false
+    this._isCallingReady = false
+    await this._ensureCCSocketConnect()
+    await this._startPhone()
+  }
+
+  // ==================== IPCC WebSocket 连接 ====================
+
+  _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._extNum = extNum
+
+      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: IPCC_CONFIG.HEARTBEAT_INTERVAL
+      }
+
+      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
+    }
+  }
+
+  _setupDefaultAccount(extNum, extPass) {
+    if (!extNum || !extPass) return
+    extNum = String(extNum).trim()
+    extPass = String(extPass).trim()
+    if (!extNum || !extPass) return
+
+    let existingUserId = null
+    for (const [id, user] of Object.entries(this._userList)) {
+      if (user.user === extNum) {
+        existingUserId = id
+        break
+      }
+    }
+
+    if (existingUserId) {
+      const updatedProfile = {
+        ...this._userList[existingUserId],
+        note: extNum,
+        display_name: extNum,
+        password: extPass,
+        user: extNum,
+        domain: this._userList[existingUserId].domain || IPCC_CONFIG.SERVER_PROD,
+        server: this._userList[existingUserId].server || `wss://${IPCC_CONFIG.SERVER_PROD}`,
+        transport: this._userList[existingUserId].transport || 'wss'
+      }
+      this._profileManager.updateUser(existingUserId, updatedProfile)
+      if (this._currentUserId !== existingUserId) {
+        this._profileManager.switchUser(existingUserId)
+        this._currentUserId = existingUserId
+      }
+    } else {
+      const newProfile = {
+        note: extNum,
+        user: extNum,
+        domain: SIP_DEFAULT_CONFIG.DOMAIN,
+        password: extPass,
+        display_name: extNum,
+        server: SIP_DEFAULT_CONFIG.SERVER,
+        transport: SIP_DEFAULT_CONFIG.TRANSPORT
+      }
+      this._profileManager.addUser(newProfile)
+      const updatedProfile = this._profileManager.getProfile()
+      this._userList = updatedProfile.users
+      for (const [id, user] of Object.entries(this._userList)) {
+        if (user.user === extNum) {
+          this._currentUserId = id
+          this._profileManager.switchUser(id)
+          break
+        }
+      }
+    }
+  }
+
+  // ==================== SIP Phone 启动 ====================
+
+  async _startPhone() {
+    if (!this._ccSocketConnected) {
+      console.warn('[SIP] 无法启动: IPCC未连接')
+      return
+    }
+
+    const userProfile = this._profileManager.getCurrentUserProfile()
+    if (!userProfile) {
+      console.warn('[SIP] 无可用账号')
+      return
+    }
+    if (!userProfile.user || !userProfile.domain || !userProfile.password) {
+      console.warn('[SIP] 账号配置不完整')
+      return
+    }
+
+    const hasPermission = await checkMicrophonePermission()
+    if (!hasPermission) {
+      console.warn('[SIP] 麦克风权限未授权')
+    }
+
+    if (this._phone) {
+      this._phone.destroy()
+      this._phone = null
+    }
+
+    const settings = this._profileManager.getSettings()
+    this._phone = new WebPhone(userProfile, settings)
+
+    this._phone.On('OnRegister', this._onRegisterEvent)
+    this._phone.On('OnSessionCreated', this._onSessionCreated)
+    this._phone.On('OnRing', this._onRing)
+    this._phone.On('OnAnswered', this._onAnswered)
+    this._phone.On('OnSessionClosed', this._onSessionClosed)
+    this._phone.On('OnCallTimer', this._onCallTimer)
+    this._phone.On('OnStatusMessage', this._onStatusMessage)
+    this._phone.On('OnReconnectStatus', this._onReconnectStatus)
+
+    this._phone.Start(settings.reconnect)
+  }
+
+  // ==================== IPCC 事件绑定 ====================
+
+  _bindCCEvents() {
+    this._ccPhoneBar.on(EventList.WS_CONNECTED, () => {
+      console.log('[IPCC] WebSocket已连接')
+      this._ccSocketConnected = true
+      if (this._ccConnectingResolve) this._ccConnectingResolve()
+      this._ccConnectingPromise = null
+      this._emit('connection-changed', true)
+    })
+
+    this._ccPhoneBar.on(EventList.WS_DISCONNECTED, () => {
+      console.warn('[IPCC] WebSocket断开')
+      this._ccSocketConnected = false
+      this._isCallingReady = false
+      this._emit('connection-changed', false)
+    })
+
+    this._ccPhoneBar.on(EventList.STATUS_CHANGED, (msg) => {
+      if (msg?.object) {
+        const statusCode = msg.object.status
+        const busyStatuses = [
+          AgentStatusEnum.BUSY,
+          AgentStatusEnum.BUSY_REST,
+          AgentStatusEnum.BUSY_MEETING,
+          AgentStatusEnum.BUSY_TRAINING
+        ]
+        this._isCallingReady = busyStatuses.includes(statusCode)
+      }
+    })
+
+    this._ccPhoneBar.on(EventList.REQUEST_ARGS_ERROR, () => {
+      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('服务器错误'))
+      }
+    })
+
+    this._ccPhoneBar.on(EventList.OUTBOUND_START, (msg) => {
+      console.log('[IPCC] 外呼开始', msg)
+      if (msg?.object?.uuid) {
+        const outboundUuid = msg.object.uuid
+        this._currentCallUuid = outboundUuid
+        this._callUuidMap[outboundUuid] = {
+          startTime: Date.now(),
+          phoneNumber: this._dialNumber,
+          status: 'outbound_start'
+        }
+      }
+    })
+
+    this._ccPhoneBar.on(EventList.CALLEE_RINGING, () => {
+      console.log('[IPCC] 被叫振铃')
+      this._callStatus = UI_STATE.RINGING
+      this._emit('call-status-changed', this._callStatus)
+    })
+
+    /** 主叫应答 = 坐席侧音频接通,不表示被叫已接听,保持 ring 状态 */
+    const handleCallerAnswered = (msg) => {
+      console.log('[IPCC] 主叫应答(坐席音频已通)', msg)
+      if (msg?.object?.uuid) {
+        this._updateCallUuid(msg.object.uuid)
+      }
+      // 不设置为 talking,保持 ringing 或 ringback 状态
+      if (this._callStatus === UI_STATE.IDLE) {
+        this._callStatus = UI_STATE.RINGING
+        this._emit('call-status-changed', this._callStatus)
+      }
+    }
+
+    /** 被叫应答 = 对方真正接听 → talking */
+    const handleCalleeAnswered = (msg) => {
+      console.log('[IPCC] 被叫应答(通话已接通)', msg)
+      if (msg?.object?.uuid) {
+        this._updateCallUuid(msg.object.uuid)
+      }
+
+      this._callStatus = UI_STATE.TALKING
+      this._answeredAt = Date.now()
+      this._callDuration = '00:00'
+      this._emit('call-status-changed', this._callStatus)
+
+      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, handleCallerAnswered)
+    this._ccPhoneBar.on(EventList.CALLEE_ANSWERED, handleCalleeAnswered)
+
+    const handleCallHangup = (msg) => {
+      console.log('[IPCC] 通话结束')
+      const callUuid = this._currentCallUuid
+
+      if (callUuid && this._callUuidMap[callUuid]) {
+        this._callUuidMap[callUuid].status = 'ended'
+        this._callUuidMap[callUuid].endTime = Date.now()
+      }
+
+      this._resetCallState()
+
+      // 通话结束后保持坐席忙碌,以便继续外呼
+      if (this._ccPhoneBar && this._ccSocketConnected) {
+        this._ccPhoneBar.setStatus(AgentStatusEnum.BUSY)
+      }
+
+      this._handleCallEnd(callUuid)
+    }
+
+    this._ccPhoneBar.on(EventList.CALLER_HANGUP, handleCallHangup)
+    this._ccPhoneBar.on(EventList.CALLEE_HANGUP, handleCallHangup)
+    this._ccPhoneBar.on(EventList.OUTBOUND_FINISHED, handleCallHangup)
+
+    this._ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
+      console.log('[IPCC] 通话已保持')
+      this._isOnHold = true
+    })
+
+    this._ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
+      console.log('[IPCC] 通话已恢复')
+      this._isOnHold = false
+    })
+  }
+
+  // ==================== SIP 事件处理 ====================
+
+  _onRegisterEvent = (event) => {
+    this._isRegistered = event.registered
+    this._isConnected = event.registered
+    if (event.registered) {
+      if (this._ccPhoneBar && this._ccSocketConnected) {
+        this._ccPhoneBar.setStatus(AgentStatusEnum.BUSY)
+      }
+      this._emit('connection-changed', true)
+    } else if (!this._isReconnecting) {
+      console.warn('[SIP] 未注册')
+      this._emit('connection-changed', false)
+    }
+  }
+
+  _onSessionCreated = (event) => {
+    if (!event.outgoing && this._phone && this._settingsForm.autoAnswer) {
+      this._phone.Answer()
+    }
+  }
+
+  _onRing = (event) => {
+    if (!event.outgoing) {
+      this._callStatus = UI_STATE.RINGING
+      this._emit('call-status-changed', this._callStatus)
+      if (this._phone) this._phone.Answer()
+    }
+  }
+
+  _onAnswered = (outgoing) => {
+    // 外呼:SIP 接通仅表示坐席侧媒体流建立,不代表被叫应答,由 IPCC CALLEE_ANSWERED 控制
+  }
+
+  _onSessionClosed = () => {
+    this._resetCallState()
+  }
+
+  get callDuration() {
+    return this._callDuration
+  }
+
+  /** 将毫秒格式化为 MM:SS */
+  _formatDuration(ms) {
+    const totalSec = Math.floor(ms / 1000)
+    const m = String(Math.floor(totalSec / 60)).padStart(2, '0')
+    const s = String(totalSec % 60).padStart(2, '0')
+    return `${m}:${s}`
+  }
+
+  _onCallTimer = (time) => {
+    // 如果已记录接听时刻,以接听开始计算实时时长
+    if (this._answeredAt) {
+      const elapsed = Date.now() - this._answeredAt
+      const display = this._formatDuration(elapsed)
+      this._callDuration = display
+      this._emit('call-duration', display)
+    } else {
+      this._callDuration = time
+      this._emit('call-duration', time)
+    }
+  }
+
+  _onStatusMessage = ({ type, text }) => {
+  }
+
+  _onReconnectStatus = ({ isReconnecting, failed }) => {
+    this._isReconnecting = isReconnecting
+    this._reconnectFailed = failed
+  }
+
+  // ==================== 通话操作 ====================
+
+  async _makeCall() {
+    if (!this._extNum) {
+      Notification.warning({ title: '未绑定坐席号' })
+      return
+    }
+    if (!this._ccPhoneBar || !this._ccSocketConnected) {
+      Notification.warning({ title: 'IPCC未连接,请稍后再试' })
+      return
+    }
+    if (!this._isCallingReady) {
+      Notification.warning({ title: '坐席未就绪' })
+      return
+    }
+    if (!this._dialNumber || this._dialNumber.trim().length < 3) {
+      Notification.warning({ title: '请输入正确的号码' })
+      return
+    }
+    if (this._callStatus !== UI_STATE.IDLE) {
+      Notification.warning({ title: '请先结束当前通话' })
+      return
+    }
+
+    const phoneNumber = this._dialNumber.trim()
+    console.log(`[外呼] 呼叫: ${phoneNumber}`)
+    this._ccPhoneBar.call(phoneNumber, 'audio', VideoLevels.HD.levelId)
+  }
+
+  _endCall() {
+    if (this._callStatus === UI_STATE.IDLE) {
+      console.warn('[挂机] 当前无通话')
+      return
+    }
+
+    console.log('[挂机] 结束通话, UUID:', this._currentCallUuid)
+    if (this._ccPhoneBar && this._ccSocketConnected) {
+      try {
+        this._ccPhoneBar.hangup()
+      } catch (err) {
+        console.error('[挂机] 调用hangup失败:', err)
+        Notification.error({ title: '挂机失败' })
+      }
+    } else if (this._phone) {
+      try {
+        this._phone.Terminate()
+      } catch (err) {
+        console.error('[挂机] 调用Terminate失败:', err)
+        Notification.error({ title: '挂机失败' })
+      }
+    } else {
+      Notification.error({ title: '无法挂机:未连接' })
+    }
+  }
+
+  /** 同步通话 UUID 映射,处理 IPCC 事件中 UUID 可能变化的情况 */
+  _updateCallUuid(uuid) {
+    if (!uuid) return
+    const realUuid = uuid
+    if (!this._currentCallUuid || this._currentCallUuid !== realUuid) {
+      if (this._currentCallUuid && this._currentCallUuid !== realUuid) {
+        if (this._callUuidMap[this._currentCallUuid]) {
+          this._callUuidMap[realUuid] = this._callUuidMap[this._currentCallUuid]
+          delete this._callUuidMap[this._currentCallUuid]
+        }
+      } else if (!this._currentCallUuid) {
+        this._callUuidMap[realUuid] = {
+          startTime: Date.now(),
+          phoneNumber: this._dialNumber,
+          status: 'connected'
+        }
+      }
+      this._currentCallUuid = realUuid
+    }
+  }
+
+  _resetCallState() {
+    if (this._callStatus !== UI_STATE.IDLE) {
+      console.log(`[通话] 状态重置: ${this._callStatus} -> idle`)
+      this._callStatus = UI_STATE.IDLE
+      this._isOnHold = false
+      this._callDuration = '00:00'
+      this._answeredAt = 0
+      this._displayNumber = ''
+      this._emit('call-status-changed', this._callStatus)
+    }
+  }
+
+  _handleCallEnd(callUuid) {
+    if (!callUuid) {
+      console.warn('[通话结束] 未获取到通话 UUID')
+      return
+    }
+
+    setTimeout(() => {
+      callEndSyncByUuid({ uuid: callUuid })
+        .then(() => {
+          console.log('[通话结束] 后端同步成功, UUID:', callUuid)
+          const keys = Object.keys(this._callUuidMap)
+          if (keys.length > 10) {
+            delete this._callUuidMap[keys[0]]
+          }
+        })
+        .catch(err => {
+          console.error('[通话结束] 后端同步失败:', err)
+        })
+    }, 10000)
+  }
+
+  _resetAllStates() {
+    this._ccSocketConnected = false
+    this._isCallingReady = false
+    this._isReconnecting = false
+    this._reconnectFailed = false
+    this._isConnected = false
+    this._isRegistered = false
+    this._extNum = null
+    this._callStatus = UI_STATE.IDLE
+    this._isOnHold = false
+    this._initialized = false
+    this._initializing = false
+  }
+}
+
+// 导出单例
+const softPhoneService = new SoftPhoneService()
+export default softPhoneService

+ 16 - 0
src/utils/util.js

@@ -33,4 +33,20 @@ export function randomString(len) {
     return pwd
 }
 
+/**
+ * XOR 解密(Base64 输入,私钥字符串)
+ */
+export function 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;
+}
+
 

+ 116 - 123
src/views/aiSipCall/aiSipCallOutboundCar.vue

@@ -1,20 +1,10 @@
 音视频类型<template>
   <div class="app-container">
     <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="86px" class="search-form">
-      <el-form-item label="通话 UUID" prop="uuid">
-        <el-input
-          v-model="queryParams.uuid"
-          placeholder="请输入通话 UUID"
-          clearable
-          size="small"
-          @keyup.enter.native="handleQuery"
-          style="width: 200px"
-        />
-      </el-form-item>
-      <el-form-item label="被叫号码" prop="callee">
+      <el-form-item label="加密号码" prop="callee">
         <el-input
           v-model="queryParams.callee"
-          placeholder="请输入被叫号码"
+          placeholder="请输入加密号码"
           clearable
           size="small"
           @keyup.enter.native="handleQuery"
@@ -31,7 +21,7 @@
           style="width: 150px"
         />
       </el-form-item>
-      <el-form-item label="通话时长">
+      <el-form-item label="通话分钟">
         <div class="time-range">
           <el-input-number v-model="queryParams.timeLenStart" :min="0" placeholder="最小值" size="small"
                            style="width: 130px"/>
@@ -49,24 +39,24 @@
                           value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>
         </div>
       </el-form-item>
-      <el-form-item label="接听时间">
-        <div class="time-range">
-          <el-date-picker v-model="queryParams.answeredTimeStart" size="small" style="width: 190px"
-                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="开始"/>
-          <span class="range-separator">-</span>
-          <el-date-picker v-model="queryParams.answeredTimeEnd" size="small" style="width: 190px"
-                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>
-        </div>
-      </el-form-item>
-      <el-form-item label="挂机时间">
-        <div class="time-range">
-          <el-date-picker v-model="queryParams.endTimeStart" size="small" style="width: 190px"
-                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="开始"/>
-          <span class="range-separator">-</span>
-          <el-date-picker v-model="queryParams.endTimeEnd" size="small" style="width: 190px"
-                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>
-        </div>
-      </el-form-item>
+      <!--      <el-form-item label="接听时间">-->
+      <!--        <div class="time-range">-->
+      <!--          <el-date-picker v-model="queryParams.answeredTimeStart" size="small" style="width: 190px"-->
+      <!--                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="开始"/>-->
+      <!--          <span class="range-separator">-</span>-->
+      <!--          <el-date-picker v-model="queryParams.answeredTimeEnd" size="small" style="width: 190px"-->
+      <!--                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>-->
+      <!--        </div>-->
+      <!--      </el-form-item>-->
+      <!--      <el-form-item label="挂机时间">-->
+      <!--        <div class="time-range">-->
+      <!--          <el-date-picker v-model="queryParams.endTimeStart" size="small" style="width: 190px"-->
+      <!--                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="开始"/>-->
+      <!--          <span class="range-separator">-</span>-->
+      <!--          <el-date-picker v-model="queryParams.endTimeEnd" size="small" style="width: 190px"-->
+      <!--                          value-format="yyyy-MM-dd HH:mm:ss" type="datetime" placeholder="结束"/>-->
+      <!--        </div>-->
+      <!--      </el-form-item>-->
 
       <el-form-item>
         <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -86,30 +76,20 @@
           v-hasPermi="['company:aiSipCall:outboundCdr:export']"
         >导出</el-button>
       </el-col>
-      <el-col :span="1.5">
-        <el-button
-          type="warning"
-          plain
-          icon="el-icon-download"
-          size="mini"
-          @click="handleManualPull"
-          v-hasPermi="['company:aiSipCall:outboundCdr:manualPull']"
-        >同步今天数据
-        </el-button>
-      </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
 
     <el-table border v-loading="loading" :data="outboundCdrList" @selection-change="handleSelectionChange">
-      <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="通话UUID" align="center" prop="uuid" />
-      <el-table-column label="音视频类型" align="center" prop="callType">
-        <template slot-scope="scope">
-          <span v-if="scope.row.callType === 'audio'">音频</span>
-          <span v-else-if="scope.row.callType === 'video'">视频</span>
-          <span v-else>{{ scope.row.callType }}</span>
-        </template>
-      </el-table-column>
+      <!--      <el-table-column type="selection" width="55" align="center" />-->
+      <el-table-column label="销售公司" align="center" prop="companyName"/>
+      <!--      <el-table-column label="通话UUID" align="center" prop="uuid" />-->
+      <!--      <el-table-column label="音视频类型" align="center" prop="callType">-->
+      <!--        <template slot-scope="scope">-->
+      <!--          <span v-if="scope.row.callType === 'audio'">音频</span>-->
+      <!--          <span v-else-if="scope.row.callType === 'video'">视频</span>-->
+      <!--          <span v-else>{{ scope.row.callType }}</span>-->
+      <!--        </template>-->
+      <!--      </el-table-column>-->
       <el-table-column label="工号" align="center" prop="opnum" />
       <el-table-column label="被叫号码" align="center" prop="callee" />
       <el-table-column label="外呼时间" align="center" prop="startTimeStr" />
@@ -119,28 +99,28 @@
       <el-table-column label="纯通时长" align="center" prop="timeLenValidStr" />
       <el-table-column label="挂断原因" align="center" prop="hangupCause" />
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
-<!--        <template slot-scope="scope">-->
-<!--          <el-button-->
-<!--            size="mini"-->
-<!--            type="text"-->
-<!--            icon="el-icon-video-play"-->
-<!--            @click="playVoice(scope.row.recordFilename)"-->
-<!--            v-hasPermi="['company:aiSipCall:phone:downloadVoice']"-->
-<!--            v-if="scope.row.recordFilename"-->
-<!--            style="color: #409EFF;"-->
-<!--          >播放-->
-<!--          </el-button>-->
-<!--          <el-button-->
-<!--            size="mini"-->
-<!--            type="text"-->
-<!--            icon="el-icon-download"-->
-<!--            @click="downloadVoice(scope.row.recordFilename)"-->
-<!--            v-hasPermi="['company:aiSipCall:phone:downloadVoice']"-->
-<!--            v-if="scope.row.recordFilename"-->
-<!--            style="color: #67C23A;"-->
-<!--          >下载-->
-<!--          </el-button>-->
-<!--        </template>-->
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-video-play"
+            @click="playVoice(scope.row.wavfile)"
+            v-hasPermi="['company:aiSipCall:phone:downloadVoice']"
+            v-if="scope.row.wavfile && scope.row.timeLenValid && scope.row.timeLenValid != 0"
+            style="color: #409EFF;"
+          >播放
+          </el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-download"
+            @click="downloadVoice(scope.row.wavfile)"
+            v-hasPermi="['company:aiSipCall:phone:downloadVoice']"
+            v-if="scope.row.wavfile && scope.row.timeLenValid && scope.row.timeLenValid != 0"
+            style="color: #67C23A;"
+          >下载
+          </el-button>
+        </template>
       </el-table-column>
     </el-table>
 
@@ -156,7 +136,7 @@
 </template>
 
 <script>
-import { listOutboundCdr, getOutboundCdr, delOutboundCdr, addOutboundCdr, updateOutboundCdr, exportOutboundCdr,manualPull } from "@/api/aiSipCall/aiSipCallOutboundCdr";
+import { listOutboundCdr, getOutboundCdr, delOutboundCdr, addOutboundCdr, updateOutboundCdr, exportOutboundCdr } from "@/api/aiSipCall/aiSipCallOutboundCdr";
 
 export default {
   name: "OutboundCdr",
@@ -190,28 +170,29 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
+        timeLenStart: undefined,
+        timeLenEnd: undefined,
         caller: null,
         opnum: null,
-        timeLenStart: null,
-        timeLenEnd: null,
-
-        startTimeStart: null,
-        startTimeEnd: null,
+        startTimeStart: this.getTodayStart(),
+        startTimeEnd: this.getTodayEnd(),
         answeredTimeStart: null,
         answeredTimeEnd: null,
-        endTimeStart: this.getTodayStart(),
+        endTimeStart: null,
         endTimeEnd: null,
         callee: null,
         startTime: null,
         answeredTime: null,
         endTime: null,
         uuid: null,
-        callType: null,
         timeLen: null,
         timeLenValid: null,
         recordFilename: null,
         chatContent: null,
-        hangupCause: null
+        hangupCause: null,
+        company_id: null,
+        source_type: '0',
+        status: 0
       },
       // 表单参数
       form: {},
@@ -227,18 +208,15 @@ export default {
     getTodayStart() {
       const now = new Date();
       return now.getFullYear() + '-' +
-             String(now.getMonth() + 1).padStart(2, '0') + '-' +
-             String(now.getDate()).padStart(2, '0') + ' 00:00:00';
+        String(now.getMonth() + 1).padStart(2, '0') + '-' +
+        String(now.getDate()).padStart(2, '0') + ' 00:00:00';
     },
-    handleManualPull(){
-      manualPull().then(response => {
-        if(response.code === 200){
-          this.msgSuccess(response.msg);
-        }else{
-          this.msgError(response.msg);
-        }
-        this.getList();
-      })
+    // 获取今天结束时间(yyyy-MM-dd 23:59:59)
+    getTodayEnd() {
+      const now = new Date();
+      return now.getFullYear() + '-' +
+        String(now.getMonth() + 1).padStart(2, '0') + '-' +
+        String(now.getDate()).padStart(2, '0') + ' 23:59:59';
     },
     /** 查询aiSIP手动外呼通话记录列表 */
     getList() {
@@ -257,20 +235,31 @@ export default {
     // 表单重置
     reset() {
       this.form = {
-        id: null,
+        pageNum: 1,
+        pageSize: 10,
         caller: null,
         opnum: null,
+        timeLenStart: null,
+        timeLenEnd: null,
+        startTimeStart: this.getTodayStart(),
+        startTimeEnd: this.getTodayEnd(),
+        answeredTimeStart: null,
+        answeredTimeEnd: null,
+        endTimeStart: null,
+        endTimeEnd: null,
         callee: null,
         startTime: null,
         answeredTime: null,
         endTime: null,
         uuid: null,
-        callType: null,
         timeLen: null,
         timeLenValid: null,
         recordFilename: null,
         chatContent: null,
-        hangupCause: null
+        hangupCause: null,
+        company_id: null,
+        source_type: '0',
+        status: 0
       };
       this.resetForm("form");
     },
@@ -281,13 +270,13 @@ export default {
     },
     /** 重置按钮操作 */
     resetQuery() {
-      this.queryParams.startTimeStart = null;
-      this.queryParams.startTimeEnd = null;
-      this.queryParams.timeLenStart = null;
-      this.queryParams.timeLenEnd = null;
+      this.queryParams.startTimeStart = this.getTodayStart();
+      this.queryParams.startTimeEnd = this.getTodayEnd();
+      this.queryParams.timeLenStart = undefined;
+      this.queryParams.timeLenEnd = undefined;
       this.queryParams.answeredTimeStart = null;
       this.queryParams.answeredTimeEnd = null;
-      this.queryParams.endTimeStart = this.getTodayStart();
+      this.queryParams.endTimeStart = null;
       this.queryParams.endTimeEnd = null;
       this.queryParams.uuid = null;
       this.queryParams.callee = null;
@@ -321,13 +310,13 @@ export default {
       this.$refs["form"].validate(valid => {
         if (valid) {
           if (this.form.id != null) {
-            updateOutboundCdr(this.form).then(response => {
+            updateOutboundCdr(this.form).then(() => {
               this.msgSuccess("修改成功");
               this.open = false;
               this.getList();
             });
           } else {
-            addOutboundCdr(this.form).then(response => {
+            addOutboundCdr(this.form).then(() => {
               this.msgSuccess("新增成功");
               this.open = false;
               this.getList();
@@ -340,30 +329,34 @@ export default {
     handleDelete(row) {
       const ids = row.id || this.ids;
       this.$confirm('是否确认删除aiSIP手动外呼通话记录编号为"' + ids + '"的数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(function() {
-          return delOutboundCdr(ids);
-        }).then(() => {
-          this.getList();
-          this.msgSuccess("删除成功");
-        }).catch(() => {});
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delOutboundCdr(ids);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
     },
     /** 导出按钮操作 */
     handleExport() {
-      const queryParams = this.queryParams;
+      // 创建查询参数的副本,避免修改原始参数
+      const queryParams = { ...this.queryParams };
+      // 移除分页参数,导出全部数据
+      delete queryParams.pageNum;
+      delete queryParams.pageSize;
       this.$confirm('是否确认导出所有aiSIP手动外呼通话记录数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(() => {
-          this.exportLoading = true;
-          return exportOutboundCdr(queryParams);
-        }).then(response => {
-          this.download(response.msg);
-          this.exportLoading = false;
-        }).catch(() => {});
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportOutboundCdr(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
     },
 
 

+ 80 - 63
src/views/aiSipCall/aiSipCallUser.vue

@@ -83,17 +83,37 @@
 
     <el-table border v-loading="loading" :data="aiSipCallUserList" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center"/>
-<!--      <el-table-column label="用户ID" align="center" prop="userId"/>-->
+      <el-table-column label="用户ID" align="center" prop="userId"/>
       <el-table-column label="登录账号" align="center" prop="loginName"/>
-      <el-table-column label="用户姓名" align="center" prop="userName"/>
-      <el-table-column label="手机号码" align="center" prop="phonenumber"/>
-<!--      <el-table-column label="用户邮箱" align="center" prop="email"/>-->
-      <el-table-column label="用户性别" align="center" prop="sex">
+      <!--      <el-table-column label="用户姓名" align="center" prop="userName"/>-->
+      <!--      <el-table-column label="手机号码" align="center" prop="phonenumber"/>-->
+      <!--      <el-table-column label="用户邮箱" align="center" prop="email"/>-->
+      <!--      <el-table-column label="用户性别" align="center" prop="sex">-->
+      <!--        <template slot-scope="scope">-->
+      <!--          <span v-if="scope.row.sex == '0'">男</span>-->
+      <!--          <span v-else-if="scope.row.sex == '1'">女</span>-->
+      <!--          <span v-else-if="scope.row.sex == '2'">未知</span>-->
+      <!--          <span v-else>{{ scope.row.sex }}</span>-->
+      <!--        </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.sex == '0'">男</span>
-          <span v-else-if="scope.row.sex == '1'">女</span>
-          <span v-else-if="scope.row.sex == '2'">未知</span>
-          <span v-else>{{ scope.row.sex }}</span>
+          <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>
         </template>
       </el-table-column>
       <el-table-column label="用户状态" align="center" prop="status">
@@ -103,12 +123,6 @@
           <span v-else>{{ scope.row.status }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="绑定分机号" align="center" prop="extNum"/>
-      <el-table-column label="绑定网关" align="center" prop="gatewayIds">
-        <template slot-scope="scope">
-          <span>{{ getGatewayNames(scope.row.gatewayIds) }}</span>
-        </template>
-      </el-table-column>
       <el-table-column label="备注" align="center" prop="remark"/>
       <el-table-column label="创建时间" align="center" prop="createTime" width="180"/>
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
@@ -126,7 +140,7 @@
           <!--            type="text"-->
           <!--            icon="el-icon-delete"-->
           <!--            @click="handleDelete(scope.row)"-->
-          <!--            v-hasPermi="['company:aiSipCallUser:aiSipCallUser:remove']"-->
+          <!--            v-hasPermi="['company:aiSipCall:aiSipCallUser:remove']"-->
           <!--          >删除</el-button>-->
         </template>
       </el-table-column>
@@ -142,7 +156,7 @@
 
     <!-- 添加或修改 sip用户信息对话框 -->
     <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
         <el-form-item label="登录账号" prop="loginName">
           <el-input v-model="form.loginName" placeholder="请输入登录账号" disabled/>
         </el-form-item>
@@ -154,25 +168,6 @@
             <i slot="suffix" class="el-icon-view" style="cursor: pointer;" @click="togglePasswordVisible"></i>
           </el-input>
         </el-form-item>
-        <el-form-item label="手机号码" prop="phonenumber">
-          <el-input v-model="form.phonenumber" placeholder="请输入手机号码"/>
-        </el-form-item>
-        <el-form-item label="用户邮箱" prop="email">
-          <el-input v-model="form.email" placeholder="请输入用户邮箱"/>
-        </el-form-item>
-        <el-form-item label="用户性别" prop="sex">
-          <el-select v-model="form.sex" placeholder="请选择用户性别">
-            <el-option label="男" :value="0"/>
-            <el-option label="女" :value="1"/>
-            <el-option label="未知" :value="2"/>
-          </el-select>
-        </el-form-item>
-        <el-form-item label="用户状态" prop="status">
-          <el-select v-model="form.status" placeholder="请选择用户状态">
-            <el-option label="正常" :value="0"/>
-            <el-option label="停用" :value="1"/>
-          </el-select>
-        </el-form-item>
         <el-form-item label="绑定分机号" prop="extNum">
           <el-select v-model="form.extNum" placeholder="请选择绑定的分机号" clearable filterable>
             <el-option
@@ -186,13 +181,26 @@
         <el-form-item label="网关" prop="gatewayIds">
           <el-select v-model="selectedGateways" placeholder="请选择网关" multiple collapse-tags>
             <el-option
-                      v-for="item in gateways"
-                      :key="item.id"
-                      :label="item.gwDesc"
-                      :value="item.id"
+              v-for="item in gateways"
+              :key="item.id"
+              :label="item.gwDesc"
+              :value="item.id"
             />
           </el-select>
         </el-form-item>
+        <el-form-item label="用户性别" prop="sex">
+          <el-select v-model="form.sex" placeholder="请选择用户性别">
+            <el-option label="男" :value="0"/>
+            <el-option label="女" :value="1"/>
+            <el-option label="未知" :value="2"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="用户状态" prop="status">
+          <el-select v-model="form.status" placeholder="请选择用户状态">
+            <el-option label="正常" :value="0"/>
+            <el-option label="停用" :value="1"/>
+          </el-select>
+        </el-form-item>
         <el-form-item label="备注" prop="remark">
           <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"/>
         </el-form-item>
@@ -215,7 +223,7 @@ import {
   exportAiSipCallUser,
   getUnBindExtnum
 } from "@/api/aiSipCall/aiSipCallUser";
-import {remoteList} from "../../api/aiSipCall/aiSipCallGateway";
+import {remoteGatewayList} from '@/api/aiSipCall/aiSipCallGateway';
 
 export default {
   name: "AiSipCallUser",
@@ -311,11 +319,11 @@ export default {
             this.selectedGateways = this.form.gatewayIds.split(',').map(id => Number(id));
           }
           // 获取最新的未绑定分机号列表
-          getUnBindExtnum().then(res => {
+          getUnBindExtnum(this.form.companyId).then(res => {
             this.extNumOptions = res.data || [];
           });
           // 获取网关列表
-          remoteList({ pageNum:1, pageSize: 500,params:{purposes: [1, 3]} }).then(response => {
+          remoteGatewayList({ pageNum:1, pageSize: 50,isAICall:false,params:{purposes: [1, 3]} }).then(response => {
             this.gateways = response.rows || [];
           });
           this.open = true;
@@ -331,23 +339,25 @@ export default {
       });
     },
     /** 添加公司用户 */
-    handleAddCompanyUserOnSip(userName, nickName, userId) {
+    handleAddCompanyUserOnSip(userName, nickName, userId,companyId) {
       this.reset();
       this.form.userName = nickName;
-      this.form.loginName = userName.startsWith('hdt_') ? userName : 'hdt_' + userName;
+      this.form.loginName = userName;
       this.form.companyUserId = userId;
+      this.form.companyId = companyId;
+
 
       // 设置默认启用状态为 0(启用)
       this.form.status = 0;
       this.form.sex = 0;
 
       // 获取最新的未绑定分机号列表
-      getUnBindExtnum().then(response => {
+      getUnBindExtnum(this.form.companyId).then(response => {
         this.extNumOptions = response.data || [];
       });
 
       // 获取网关列表
-      remoteList({ pageNum:1, pageSize: 500,params:{purposes: [1, 3]} }).then(response => {
+      remoteGatewayList({ pageNum:1, pageSize: 50,isAICall:false,params:{purposes: [1, 3]} }).then(response => {
         this.gateways = response.rows || [];
       });
 
@@ -356,17 +366,20 @@ export default {
       const employeeName = nickName || '未知员工';
       this.title = `添加 sip 外呼角色 - ${employeeName}`;
     },
-    /** 查询sip用户信息列表 */
+    /** 查询 sip 用户信息列表 */
     getList() {
       this.loading = true;
       // 先获取网关列表
-      remoteList({ pageNum:1, pageSize: 500,params:{purposes: [1, 3]} }).then(response => {
+      remoteGatewayList({ pageNum:1, pageSize: 50,isAICall:false,params:{purposes: [1, 3]} }).then(response => {
         this.gateways = response.rows || [];
         // 再获取用户列表
         listAiSipCallUser(this.queryParams).then(response => {
-            this.aiSipCallUserList = response.rows;
-            this.total = response.total;
-            this.loading = false;
+          this.aiSipCallUserList = response.rows.map(item => ({
+            ...item,
+            showPassword: false // 默认隐藏密码
+          }));
+          this.total = response.total;
+          this.loading = false;
         });
       }).catch(() => {
         this.loading = false;
@@ -424,11 +437,11 @@ export default {
           this.selectedGateways = this.form.gatewayIds.split(',').map(id => Number(id));
         }
         // 获取最新的未绑定分机号列表
-        getUnBindExtnum().then(res => {
+        getUnBindExtnum(this.form.companyId).then(res => {
           this.extNumOptions = res.data || [];
         });
         // 获取网关列表
-        remoteList({ pageNum:1, pageSize: 500,params:{purposes: [1, 3]} }).then(response => {
+        remoteGatewayList({ pageNum:1, pageSize: 50,isAICall:false,params:{purposes: [1, 3]} }).then(response => {
           this.gateways = response.rows || [];
           this.open = true;
           this.title = "修改 sip 用户信息";
@@ -443,10 +456,10 @@ export default {
     submitForm() {
       this.$refs["form"].validate(valid => {
         if (valid) {
-            // 将选中的网关数组用逗号拼接成字符串
-            this.form.gatewayIds = this.selectedGateways.join(',');
+          // 将选中的网关数组用逗号拼接成字符串
+          this.form.gatewayIds = this.selectedGateways.join(',');
 
-            if (this.form.userId != null) {
+          if (this.form.userId != null) {
             updateAiSipCallUser(this.form).then(response => {
               this.msgSuccess("修改成功");
               this.open = false;
@@ -500,16 +513,20 @@ export default {
     /** 根据网关 IDs 获取网关名称 */
     getGatewayNames(gatewayIds) {
       if (!gatewayIds) {
-          return '';
+        return '';
       }
       const ids = gatewayIds.split(',').map(id => Number(id));
       const names = ids
-          .map(id => {
-              const gateway = this.gateways.find(gw => gw.id === id);
-              return gateway ? gateway.gwDesc : '';
-          })
-          .filter(name => name);
+        .map(id => {
+          const gateway = this.gateways.find(gw => gw.id === id);
+          return gateway ? gateway.gwDesc : '';
+        })
+        .filter(name => name);
       return names.join(', ');
+    },
+    // 切换分机密码显示/隐藏
+    togglePasswordVisibility(row) {
+      row.showPassword = !row.showPassword;
     }
   }
 };

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

@@ -0,0 +1,2421 @@
+<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>
+        </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, IPCC_DEFAULTS, JS_SIP_DEFAULTS } 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 { callEndSyncByUuid, encryptMobile } from '@/api/aiSipCall/aiSipCallOutboundCdr.js';
+
+// ==================== 全局配置常量 ====================
+// IPCC 和 JsSIP 默认配置已从 softPhone.js 统一导入,此处仅作别名引用
+
+/** IPCC(呼叫中心)服务器配置(来自统一常量) */
+const IPCC_CONFIG = IPCC_DEFAULTS;
+
+/** JsSIP(软电话)SIP 默认账号配置(来自统一常量) */
+const JS_SIP_CONFIG = JS_SIP_DEFAULTS;
+
+/**
+ * 音量控制配置
+ */
+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,
+      savedSpeakerVolume: VOLUME_CONFIG.DEFAULT,  // 静音前保存的音量
+      savedMicVolume: VOLUME_CONFIG.DEFAULT,  // 静音前保存的音量
+      speakerSliderVisible: false,
+      micSliderVisible: false,
+      volumeTimerId: null,
+
+      // UI按钮状态
+      showLeftButton: false,
+      showRightButton: false,
+      leftButtonNormal: false,
+      rightButtonNormal: false,
+      rightButtonHangup: false,
+      callDirection: '',  // 'inbound' | 'outbound' | ''
+
+      // 用户账号管理
+      dropdownVisible: false,
+      userList: {},
+      currentUserId: '',
+      currentUserDisplay: '',
+
+      // 账号对话框
+      accountDialogVisible: false,
+      accountDialogTitle: '添加账号',
+      isEditMode: false,
+      editingUserId: null,
+      accountForm: {
+        note: '',
+        server: JS_SIP_CONFIG.SERVER,
+        username: '',
+        domain: JS_SIP_CONFIG.DOMAIN,
+        loginName: '',
+        password: '',
+        transport: JS_SIP_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;
+      try {
+        await this.ensureCCSocketConnect();
+        console.log('[jsSip] 开始初始化...');
+        await this.startPhone();
+        console.log('[jsSip] SIP注册启动完成');
+      } catch (err) {
+        console.error('[jsSip] 初始化失败:', 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('IPCC连接断开', '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.callDirection = 'outbound';
+        this.showLeftButton = false;
+        this.showRightButton = false;
+        // 显示拨号中状态
+        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;
+        if (this.callDirection === 'inbound') {
+          this.showLeftButton = true;
+          this.showRightButton = true;
+          this.rightButtonHangup = true;
+        }
+        // 显示振铃状态
+        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: JS_SIP_CONFIG.DOMAIN,
+          server: JS_SIP_CONFIG.SERVER,
+          transport: JS_SIP_CONFIG.TRANSPORT
+        };
+        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: JS_SIP_CONFIG.DOMAIN,
+          password: extPass,
+          display_name: extNum,
+          server: JS_SIP_CONFIG.SERVER,
+          transport: JS_SIP_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('[jsSip] 无法启动: IPCC未连接');
+        this.showStatus('jsSip未连接', 'error');
+        return;
+      }
+
+      const userProfile = this.profileManager.getCurrentUserProfile();
+      if (!userProfile) {
+        console.warn('[jsSip] 无可用账号');
+        this.showStatus('jsSip无可用账号', 'warn');
+        return;
+      }
+
+      if (!userProfile.user || !userProfile.domain || !userProfile.password) {
+        console.error('[jsSip] 账号配置不完整');
+        this.showStatus('jsSip账号配置错误', 'error');
+        return;
+      }
+
+      console.log(`[jsSip] 准备启动: ${userProfile.user}@${userProfile.domain}`);
+
+      // 检查麦克风权限
+      const hasPermission = await checkMicrophonePermission();
+      if (!hasPermission) {
+        console.warn('[jsSip] 麦克风权限未授权');
+        this.showStatus('jsSip请授权麦克风', 'error');
+      }
+
+      // 销毁旧实例
+      if (this.phone) {
+        console.log('[jsSip] 销毁旧实例');
+        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('[jsSip] 启动WebPhone');
+      this.phone.Start(settings.reconnect);
+    },
+    onReconnectStatus({ isReconnecting, failed }) {
+      this.isReconnecting = isReconnecting;
+      this.reconnectFailed = failed;
+      if (failed) {
+        console.error('[jsSip] 重连超时');
+        this.showStatus('jsSip连接超时', 'error');
+      } else if (isReconnecting) {
+        console.log('[jsSip] 正在重连...');
+        this.showStatus('jsSip重连中...', '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(`[jsSip] 连接成功: ${this.currentUserDisplay}`);
+        this.showStatus('就绪', 'success');
+        // SIP 注册成功后设置 IPCC 坐席为忙碌状态
+        if (this.ccPhoneBar && this.ccSocketConnected) {
+          console.log('[IPCC] 设置坐席为忙碌状态');
+          this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
+        }
+      } else if (!this.isReconnecting) {
+        console.warn('[jsSip] 未注册');
+        this.showStatus('jsSip未注册', 'warn');
+      }
+    },
+    onSessionCreated(event) {
+      // 来电时自动应答
+      if (!event.outgoing && this.phone && this.settingsForm.autoAnswer) {
+        this.phone.Answer();
+      }
+    },
+    onRing(event) {
+      if (!event.outgoing) {
+        this.callDirection = 'inbound';
+        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;
+        this.callDirection = '';
+        // 注意:不清空 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.callDirection = 'outbound';
+      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) {
+        // 静音:保存当前音量后设为 0
+        this.savedMicVolume = this.micVolume;
+        this.micVolume = 0;
+      } else {
+        // 解除静音:恢复之前的音量
+        this.micVolume = this.savedMicVolume > 0
+          ? this.savedMicVolume
+          : (this.profileManager.getProfile().mic_volume || JS_SIP_CONFIG.MIC_VOLUME);
+      }
+      this.phone.SetMicPhone(this.isMicMuted, this.micVolume);
+      const profile = this.profileManager.getProfile();
+      profile.mic_paused = this.isMicMuted;
+      this.profileManager.updateSettings({ mic_paused: this.isMicMuted });
+    },
+    toggleMuteSpeaker() {
+      if (!this.phone) return;
+      this.isSpeakerMuted = !this.isSpeakerMuted;
+      if (this.isSpeakerMuted) {
+        // 静音:保存当前音量后设为 0
+        this.savedSpeakerVolume = this.speakerVolume;
+        this.speakerVolume = 0;
+      } else {
+        // 解除静音:恢复之前的音量
+        this.speakerVolume = this.savedSpeakerVolume > 0
+          ? this.savedSpeakerVolume
+          : (this.profileManager.getProfile().speaker_volume || JS_SIP_CONFIG.SPEAKER_VOLUME);
+      }
+      this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume);
+      const profile = this.profileManager.getProfile();
+      profile.speaker_paused = this.isSpeakerMuted;
+      this.profileManager.updateSettings({ speaker_paused: this.isSpeakerMuted });
+    },
+    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: JS_SIP_CONFIG.SERVER, username: '', domain: JS_SIP_CONFIG.DOMAIN, loginName: '', password: '', transport: JS_SIP_CONFIG.TRANSPORT };
+      this.showPassword = false;
+      this.accountDialogVisible = true;
+    },
+    openEditAccountDialog() {
+      const profile = this.profileManager.getCurrentUserProfile();
+      if (!profile) return;
+      this.isEditMode = true;
+      this.editingUserId = this.currentUserId;
+      this.accountDialogTitle = '编辑账号';
+      this.accountForm = {
+        note: profile.note || profile.display_name,
+        server: profile.server,
+        username: profile.display_name,
+        domain: profile.domain,
+        loginName: profile.user,
+        password: profile.password,
+        transport: profile.transport || 'wss'
+      };
+      this.showPassword = false;
+      this.accountDialogVisible = true;
+    },
+    async 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.accountForm.loginName,
+            domain: this.accountForm.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) {
+          await this.startPhone();
+        } else {
+          await this.initCCAndStart();
+        }
+      } catch (err) {
+        this.$message.error(err.message);
+        this.showStatus(err.message, 'error');
+      }
+    },
+    async confirmDeleteAccount() {
+      this.$confirm('删除账号将清除本地配置,确认删除?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(async () => {
+        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.currentUserId) {
+          await this.startPhone();
+        }
+        this.showStatus('账号已删除', 'info');
+      }).catch(() => {});
+    },
+    openSettingsDialog() { this.settingsDialogVisible = true; },
+    async 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; }
+      if (this.ccSocketConnected) {
+        await 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.callDirection = '';
+      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;
+      this.decryptedPhoneNumber = '';
+      this.isDecrypting = false;
+      this.encryptingLock = false;
+      this.dialNumber = '';
+      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() {
+      try {
+        this.destroyAllConnections();
+      } catch (e) {
+        // 页面关闭时静默处理错误
+      }
+    },
+
+    /**
+     * 生成通话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(() => {
+        callEndSyncByUuid({ 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: 1000;
+}
+
+.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: #f44336 !important;
+}
+/* 连接成功时显示绿色(非静音时) */
+.microphone-icon.connection-success:not(.muted), .speaker-icon.connection-success:not(.muted) {
+  color: #4caf50;
+}
+/* 连接失败时显示灰色(非静音时) */
+.microphone-icon.connection-failed:not(.muted), .speaker-icon.connection-failed:not(.muted) {
+  color: #999;
+}
+.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: 10001;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  width: 340px;
+  max-height: 80vh;
+  overflow-y: auto;
+  background-color: #fff;
+  border-radius: 12px;
+  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
+  animation: modalFadeIn 0.3s ease;
+}
+@keyframes modalFadeIn {
+  from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
+  to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+}
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 14px;
+  border-radius: 12px 12px 0 0;
+  background-color: #f8f8f8;
+  gap: 8px;
+  border-bottom: 1px solid #e8e8e8;
+}
+.modal-header i.material-icons {
+  font-size: 24px;
+  color: #2196f3;
+}
+.modal-header span {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+}
+.modal-content {
+  background-color: #fff;
+  padding: 16px;
+}
+.form-group {
+  margin-bottom: 16px;
+  position: relative;
+}
+.form-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #555;
+  margin-bottom: 6px;
+}
+.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>

+ 1 - 21
src/views/company/companyUser/index.vue

@@ -286,26 +286,6 @@
                 @click="handleBindMember(scope.row)"
                 v-hasPermi="['company:user:bindUser']"
               >绑定会员</el-button>
-
-              <el-button
-                v-if="!!scope.row.cidServerId"
-                size="mini"
-                type="text"
-                icon="el-icon-s-platform"
-                plain
-                :loading="bindCidServerLoading"
-                @click="handleUnbindCidServer(scope.row)"
-              >取消绑定cid服务</el-button>
-              <el-button
-                v-else
-                size="mini"
-                type="text"
-                icon="el-icon-s-platform"
-                plain
-                :loading="bindCidServerLoading"
-                @click="handleBindCidServer(scope.row)"
-              >绑定cid服务</el-button>
-
               <el-button v-if="scope.row.userType !== '00'" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['company:user:edit']">修改</el-button>
               <el-button v-if="scope.row.userType !== '00'" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['company:user:remove']">删除</el-button>
               <el-button size="mini" type="text" icon="el-icon-key" @click="handleResetPwd(scope.row)" v-hasPermi="['company:user:resetPwd']">重置密码</el-button>
@@ -1961,7 +1941,7 @@ export default {
       });
     },
     checkBindSipCallUser(row){
-      this.$refs.aiSipCallUser.handleAddCompanyUserOnSip(row.userName,row.nickName,row.userId);
+      this.$refs.aiSipCallUser.handleAddCompanyUserOnSip(row.userName,row.nickName,row.userId,row.companyId);
     },
     checkChangeSipCallUser(row){
       this.$refs.aiSipCallUser.handleUpdateById(row.aiSipCallUserId);

+ 12 - 2
src/views/crm/customer/my.vue

@@ -166,7 +166,7 @@
       <el-table-column  label="手机" width="120px"  align="center" prop="mobile"   >
         <template slot-scope="scope">
           {{scope.row.mobile}}
-          <el-button type="text"    size="mini" @click="callNumber(scope.row.customerId,null,null,null)">拨号</el-button>
+          <el-button type="text" size="mini" @click="handleCall(scope.row.customerId)">拨号</el-button>
           <el-button v-hasPermi="['crm:customer:addVisit']"  type="text" size="mini" @click="handleAddVisit(scope.row)">写跟进</el-button>
         </template>
       </el-table-column>
@@ -273,7 +273,7 @@
 </template>
 
 <script>
-import { getMyCustomerList,recover,exportCustomer  } from "@/api/crm/customer";
+import { getMyCustomerList, recover, exportCustomer, getSipPhoneNumber } from '@/api/crm/customer'
 import customerDetails from '../components/customerDetails.vue';
 import addVisit from '../components/addVisit.vue';
 import {getCitys} from "@/api/store/city";
@@ -285,6 +285,8 @@ import addTag from '../components/addTag.vue';
 import addRemark from '../components/addRemark.vue';
 import addCustomerType from '../components/addCustomerType.vue';
 import addVisitStatus from '../components/addVisitStatus.vue';
+import softPhone from '@/utils/SoftPhoneService'
+import { xorDecrypt } from '@/utils/util'
 export default {
   name: "Customer",
   components: {addVisitStatus,addCustomerType,addRemark,addTag,assignUser,addOrEditCustomer,editSource, addBatchSms,customerDetails,addVisit,addVisit },
@@ -452,6 +454,14 @@ export default {
     this.getList();
   },
   methods: {
+    handleCall(customerId) {
+      getSipPhoneNumber({customerId: customerId}).then(res => {
+        const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+        const resultData = res.data.slice(0, -6);
+        const phoneNumber = xorDecrypt(resultData, privateKey);
+        softPhone.call(phoneNumber)
+      })
+    },
     closeVisitStatus(){
         this.addVisitStatus.open=false;
         this.getList();

+ 18 - 0
src/views/member/list.vue

@@ -251,6 +251,13 @@
             type="text"
             @click="handledetails(scope.row)"
           >详情</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            v-hasPermi="['user:fsUser:getSipPhoneNumber']"
+            v-if="scope.row.phone"
+            @click="handleCall(scope.row.userId)"
+          >外呼</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -347,6 +354,9 @@ import { listUser, getMemberUser, getUser, addUser, updateUser, delUser, exportU
 import {getUserList} from "@/api/company/companyUser";
 import userDetails from '@/views/store/components/userDetails.vue';
 import { getTask } from '@/api/common'
+import { getSipPhoneNumber } from '@/api/user/fsUser'
+import { xorDecrypt } from '@/utils/util'
+import softPhone from '@/utils/SoftPhoneService'
 export default {
   name: "FsUser",
   components: {userDetails},
@@ -720,6 +730,14 @@ export default {
     getProjectLabel(projectId) {
       return this.projectOptions.find(item => parseInt(item.dictValue) === projectId)?.dictLabel;
     },
+    handleCall(userId) {
+      getSipPhoneNumber({userId: userId}).then(res => {
+        const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+        const resultData = res.data.slice(0, -6);
+        const phoneNumber = xorDecrypt(resultData, privateKey);
+        softPhone.call(phoneNumber)
+      })
+    },
   }
 };
 </script>

+ 31 - 2
src/views/member/mylist.vue

@@ -256,6 +256,13 @@
             type="text"
             @click="handledetails(scope.row)"
           >详情</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            v-hasPermi="['user:myUser:getSipPhoneNumber']"
+            v-if="scope.row.phone"
+            @click="handleCall(scope.row.userId)"
+          >外呼</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -358,11 +365,25 @@
 </template>
 
 <script>
-import { listUser,getMemberUser,getUser,updateMemberUser, addUser, updateUser, delUser, exportUser, auditUser,myListUser } from "@/api/user/fsUser";
+import {
+  listUser,
+  getMemberUser,
+  getUser,
+  updateMemberUser,
+  addUser,
+  updateUser,
+  delUser,
+  exportUser,
+  auditUser,
+  myListUser,
+  getSipPhoneNumber
+} from '@/api/user/fsUser'
 import {transferUser} from "@/api/users/user";
 import {getUserList} from "@/api/company/companyUser";
 import userDetails from '@/views/store/components/userDetails.vue';
 import userCoursePeriod from '../../components/course/userCoursePeriod.vue'
+import { xorDecrypt } from '@/utils/util'
+import softPhone from '@/utils/SoftPhoneService'
 export default {
   name: "FsUser",
   components: {userDetails,userCoursePeriod},
@@ -750,7 +771,15 @@ export default {
     },
     handleClose(){
       this.dialogVisible = false;
-    }
+    },
+    handleCall(userId) {
+      getSipPhoneNumber({userId: userId}).then(res => {
+        const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+        const resultData = res.data.slice(0, -6);
+        const phoneNumber = xorDecrypt(resultData, privateKey);
+        softPhone.call(phoneNumber)
+      })
+    },
   }
 };
 </script>

+ 19 - 1
src/views/store/user/list.vue

@@ -132,6 +132,13 @@
             type="text"
             @click="handleNickName(scope.row)"
           >修改昵称</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            v-hasPermi="['user:user:getSipPhoneNumber']"
+            v-if="scope.row.phone"
+            @click="handleCall(scope.row.userId)"
+          >外呼</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -184,6 +191,9 @@
 <script>
 import { listUser, getUser, delUser, addUser, updateUser, exportUser } from "@/api/store/user";
 import userDetails from '../components/userDetails.vue';
+import { getSipPhoneNumber } from '@/api/user/fsUser'
+import { xorDecrypt } from '@/utils/util'
+import softPhone from '@/utils/SoftPhoneService'
 export default {
   name: "User",
   components: {userDetails},
@@ -421,7 +431,15 @@ export default {
           this.download(response.msg);
           this.exportLoading = false;
         }).catch(() => {});
-    }
+    },
+    handleCall(userId) {
+      getSipPhoneNumber({userId: userId}).then(res => {
+        const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+        const resultData = res.data.slice(0, -6);
+        const phoneNumber = xorDecrypt(resultData, privateKey);
+        softPhone.call(phoneNumber)
+      })
+    },
   }
 };
 </script>

+ 19 - 1
src/views/store/user/myList.vue

@@ -138,6 +138,13 @@
             @click="handleCreateOrder(scope.row)"
             v-hasPermi="['store:packageOrder:add']"
           >去下单</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            v-hasPermi="['his:user:getSipPhoneNumber']"
+            v-if="scope.row.phone"
+            @click="handleCall(scope.row)"
+          >外呼</el-button>
         </template>
       </el-table-column>
     </el-table>
@@ -232,6 +239,9 @@ import userDetails from '../components/userDetails.vue';
 import packageOrderDialog from './components/packageOrderDialog.vue';
 import consultationOrderDialog from './components/consultationOrderDialog.vue';
 import integralOrderDialog from './components/integralOrderDialog.vue';
+import { getSipPhoneNumber } from '@/api/user/fsUser'
+import { xorDecrypt } from '@/utils/util'
+import softPhone from '@/utils/SoftPhoneService'
 export default {
   name: "User",
   components: {userDetails, packageOrderDialog, consultationOrderDialog, integralOrderDialog},
@@ -511,7 +521,15 @@ export default {
           this.download(response.msg);
           this.exportLoading = false;
         }).catch(() => {});
-    }
+    },
+    handleCall(userId) {
+      getSipPhoneNumber({userId: userId}).then(res => {
+        const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
+        const resultData = res.data.slice(0, -6);
+        const phoneNumber = xorDecrypt(resultData, privateKey);
+        softPhone.call(phoneNumber)
+      })
+    },
   }
 };
 </script>