|
|
@@ -0,0 +1,2402 @@
|
|
|
+<template>
|
|
|
+ <div class="webphone-container">
|
|
|
+ <div class="dialer">
|
|
|
+ <!-- 状态栏:左侧头像下拉,中间用户名,右侧音量及网络图标 -->
|
|
|
+ <div class="status-bar">
|
|
|
+ <!-- 左侧区域:头像下拉 + 呼叫状态图标 -->
|
|
|
+ <div class="status-left">
|
|
|
+ <div class="user-avatar-dropdown" v-click-outside="closeDropdown">
|
|
|
+ <i class="material-icons user-avatar-icon"
|
|
|
+ :class="{ 'network-available': isRegistered, 'no-network': !isConnected }"
|
|
|
+ @click="toggleDropdown"
|
|
|
+ title="点击切换账号">account_circle</i>
|
|
|
+ <div class="dropdown-menu" v-show="dropdownVisible">
|
|
|
+ <div class="dropdown-group">
|
|
|
+ <a href="#" class="dropdown-item" v-for="(userProfile, userId) in userList" :key="userId" @click.prevent="switchAccount(userId)" :title="'切换到: ' + userProfile.note">
|
|
|
+ <i class="material-icons" v-if="currentUserId === userId">check</i>
|
|
|
+ <i class="material-icons" v-else style="visibility: hidden;">check</i>
|
|
|
+ {{ userProfile.note }}
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+ <div class="dropdown-group">
|
|
|
+ <a href="#" class="dropdown-item" @click.prevent="openEditAccountDialog" title="编辑当前账号信息"><i class="material-icons">edit</i>编辑账号</a>
|
|
|
+ <a href="#" class="dropdown-item" @click.prevent="openAddAccountDialog" title="添加新的SIP账号"><i class="material-icons">add</i>添加账号</a>
|
|
|
+ <a href="#" class="dropdown-item" @click.prevent="confirmDeleteAccount" title="删除当前账号"><i class="material-icons">delete</i>删除账号</a>
|
|
|
+ </div>
|
|
|
+ <div class="dropdown-group">
|
|
|
+ <a href="#" class="dropdown-item" @click.prevent="resetSettings" title="恢复默认设置"><i class="material-icons">settings_backup_restore</i>清空设置</a>
|
|
|
+ <a href="#" class="dropdown-item" @click.prevent="resetReconnectState" title="重新连接服务器"><i class="material-icons">autorenew</i>重新连接</a>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <i class="material-icons call-status-icon"
|
|
|
+ v-show="callStatus !== 'idle'"
|
|
|
+ :class="{ inprogress: callStatus === 'ringing', 'ringing-icon': callStatus === 'ringing' }"
|
|
|
+ title="通话中">call</i>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 中间区域:用户名居中显示 -->
|
|
|
+ <div class="status-center">
|
|
|
+ <span class="display-user"
|
|
|
+ :class="{ 'network-available': isRegistered }"
|
|
|
+ :title="currentUserDisplay">{{ currentUserDisplay }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧区域:麦克风、扬声器、网络状态图标 -->
|
|
|
+ <div class="status-right">
|
|
|
+ <div class="volume-control-group" @mouseenter="showMicSlider" @mouseleave="startHideSliderTimer">
|
|
|
+ <i class="material-icons microphone-icon"
|
|
|
+ :class="{ muted: isMicMuted, 'connection-success': isConnected && isRegistered, 'connection-failed': !isConnected }"
|
|
|
+ @click="toggleMuteMic"
|
|
|
+ :title="isMicMuted ? '取消静音' : '静音'">mic</i>
|
|
|
+ <div class="volume-slider-container mic-volume-slider" v-show="micSliderVisible" @mouseenter="cancelHideSliderTimer" @mouseleave="startHideSliderTimer">
|
|
|
+ <input type="range" min="0" max="1" step="0.01" v-model="micVolume" @input="changeMicVolume" class="volume-slider">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="volume-control-group" @mouseenter="showSpeakerSlider" @mouseleave="startHideSliderTimer">
|
|
|
+ <i class="material-icons speaker-icon"
|
|
|
+ :class="{ muted: isSpeakerMuted, 'connection-success': isConnected && isRegistered, 'connection-failed': !isConnected }"
|
|
|
+ @click="toggleMuteSpeaker"
|
|
|
+ :title="isSpeakerMuted ? '取消静音' : '静音'">volume_up</i>
|
|
|
+ <div class="volume-slider-container speaker-volume-slider" v-show="speakerSliderVisible" @mouseenter="cancelHideSliderTimer" @mouseleave="startHideSliderTimer">
|
|
|
+ <input type="range" min="0" max="1" step="0.01" v-model="speakerVolume" @input="changeSpeakerVolume" class="volume-slider">
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <i class="material-icons network-icon"
|
|
|
+ :class="{ 'no-network': !isConnected, 'network-available': isConnected && isRegistered, 'network-connecting': isConnected && !isRegistered }"
|
|
|
+ :title="!isConnected ? '未连接' : (isRegistered ? '已注册' : '连接中')">signal_cellular_alt</i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 拨号显示屏与删除按钮 -->
|
|
|
+ <div class="display-wrapper">
|
|
|
+ <!-- 明文/密文选择框(居中显示) -->
|
|
|
+ <div class="dial-mode-selector">
|
|
|
+ <label class="radio-label" title="使用明文号码直接拨号">
|
|
|
+ <input type="radio" value="plaintext" v-model="dialMode" @change="handleDialModeChange">
|
|
|
+ <span>明文</span>
|
|
|
+ </label>
|
|
|
+ <label class="radio-label" title="使用加密号码拨号(自动解密)">
|
|
|
+ <input type="radio" value="encrypted" v-model="dialMode" @change="handleDialModeChange">
|
|
|
+ <span>密文</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <input type="tel"
|
|
|
+ class="dialer-display"
|
|
|
+ :class="{ 'center-align': isContentFit, 'right-align': !isContentFit }"
|
|
|
+ id="display"
|
|
|
+ v-model="dialNumber"
|
|
|
+ @keypress="handleDisplayKeypress"
|
|
|
+ placeholder="输入电话号码"
|
|
|
+ ref="dialerInput"
|
|
|
+ title="输入要拨打的电话号码">
|
|
|
+ <i class="material-icons delete-icon"
|
|
|
+ @click="deleteCharByCursor"
|
|
|
+ style="cursor: default;"
|
|
|
+ title="删除光标前字符">backspace</i>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 归属地与计时器 -->
|
|
|
+ <div class="container" v-show="province" title="来电归属地">
|
|
|
+ <span class="province">{{ province }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="container" title="通话时长">
|
|
|
+ <span class="call-timer" v-show="callDuration !== '00:00'">{{ callDuration }}</span>
|
|
|
+ </div>
|
|
|
+ <!-- 解密状态提示 -->
|
|
|
+ <div class="container decrypting-tip" v-show="isDecrypting" title="正在解密号码">
|
|
|
+ <span>🔄 正在解密号码...</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 拨号键盘 -->
|
|
|
+ <div class="dialer-keypad">
|
|
|
+ <button class="dialer-button"
|
|
|
+ v-for="digit in dialKeys"
|
|
|
+ :key="digit"
|
|
|
+ @click="onDigitClick(digit)"
|
|
|
+ :title="callStatus === 'talking' ? '发送DTMF: ' + digit : '输入: ' + digit">{{ digit }}</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 呼叫按钮组 -->
|
|
|
+ <div class="call-buttons">
|
|
|
+ <button class="call-button call-left-button"
|
|
|
+ :class="{ hidden: !showLeftButton, normal: leftButtonNormal }"
|
|
|
+ @click="onLeftButtonClick"
|
|
|
+ :title="getLeftButtonTitle()">
|
|
|
+ <i class="material-icons">{{ leftButtonIcon }}</i>
|
|
|
+ </button>
|
|
|
+ <button class="call-button call-hangup-button"
|
|
|
+ :class="getHangupButtonClass()"
|
|
|
+ @click="onHangupClick"
|
|
|
+ :title="getHangupButtonTitle()">
|
|
|
+ <i class="material-icons">{{ hangupButtonIcon }}</i>
|
|
|
+ </button>
|
|
|
+ <button class="call-button call-right-button"
|
|
|
+ :class="{ hidden: !showRightButton, normal: rightButtonNormal, hangup: rightButtonHangup }"
|
|
|
+ @click="onRightButtonClick"
|
|
|
+ :title="getRightButtonTitle()">
|
|
|
+ <i class="material-icons">{{ rightButtonIcon }}</i>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部状态栏:状态消息和版本号(不重叠) -->
|
|
|
+ <div class="status-footer">
|
|
|
+ <div class="status-footer-left">
|
|
|
+ <div class="status-bar-message"
|
|
|
+ :class="statusType"
|
|
|
+ v-if="statusText"
|
|
|
+ :title="statusText">{{ statusText }}</div>
|
|
|
+ <div class="reconnect-failed" v-if="reconnectFailed" title="重连超时,请尝试手动重新连接">
|
|
|
+ <span>重连超时</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="version-ribbon" title="软电话版本">v1.0.0</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 添加/编辑账号模态框 -->
|
|
|
+ <div id="phoneModal" class="modal" v-show="accountDialogVisible" @click.self="accountDialogVisible = false">
|
|
|
+ <div class="modal-header">
|
|
|
+ <i class="material-icons">{{ accountDialogTitle === '添加账号' ? 'add' : 'edit' }}</i>
|
|
|
+ <span id="modalTitle">{{ accountDialogTitle }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="modal-content">
|
|
|
+ <form @submit.prevent="saveAccount">
|
|
|
+ <div class="form-group">
|
|
|
+ <input type="text" id="note" v-model="accountForm.note" placeholder="备注">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <input type="text" id="server" v-model="accountForm.server" placeholder="服务(ws://129.28.164.235:5066)" required>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <input type="text" id="username" v-model="accountForm.username" placeholder="用户名">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <input type="text" id="domain" v-model="accountForm.domain" placeholder="域名" required>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <input type="text" id="loginName" v-model="accountForm.loginName" placeholder="登录名" required>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <input :type="showPassword ? 'text' : 'password'" id="password" v-model="accountForm.password" placeholder="密码" required>
|
|
|
+ <i class="material-icons password-toggle" @click="showPassword = !showPassword">{{ showPassword ? 'visibility_off' : 'visibility' }}</i>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <select id="transport" v-model="accountForm.transport">
|
|
|
+ <option value="wss">Transport (WSS)</option>
|
|
|
+ <option value="ws">Transport (WS)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-buttons">
|
|
|
+ <button type="button" class="cancel-button" @click="accountDialogVisible = false">取消</button>
|
|
|
+ <button type="submit" class="add-button">保存</button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 设置模态框 -->
|
|
|
+ <div id="settingModal" class="modal" v-show="settingsDialogVisible" @click.self="settingsDialogVisible = false">
|
|
|
+ <div class="modal-header">
|
|
|
+ <i class="material-icons">settings</i>
|
|
|
+ <span>SIP 设置</span>
|
|
|
+ </div>
|
|
|
+ <div class="modal-content">
|
|
|
+ <form @submit.prevent="saveSettings">
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ User Agent
|
|
|
+ <i class="material-icons info-icon" title="SIP 用户代理标识,用于服务器识别客户端类型。例如:JsSIP、Zoiper 等">info</i>
|
|
|
+ </label>
|
|
|
+ <input type="text" id="userAgent" v-model="settingsForm.userAgent" placeholder="例如: JsSIP" required>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ Session Expires
|
|
|
+ <i class="material-icons info-icon" title="SIP 会话的有效期(单位:秒)。超时后需要重新注册。建议值:180-600 秒">info</i>
|
|
|
+ </label>
|
|
|
+ <input type="number" id="sessionExpires" v-model.number="settingsForm.sessionExpires" placeholder="例如: 180" required min="60">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ Min Session Expires
|
|
|
+ <i class="material-icons info-icon" title="服务器可接受的最小会话有效期(单位:秒)。必须小于 Session Expires。建议值:90-180 秒">info</i>
|
|
|
+ </label>
|
|
|
+ <input type="number" id="minSessionExpires" v-model.number="settingsForm.minSessionExpires" placeholder="例如: 120" required min="30">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ 启用 STUN
|
|
|
+ <i class="material-icons info-icon" title="STUN 服务器用于 NAT 穿透,帮助在防火墙或复杂网络环境下建立连接。内网或直接连接可关闭">info</i>
|
|
|
+ </label>
|
|
|
+ <select id="stun" v-model="settingsForm.stun">
|
|
|
+ <option :value="false" selected>否</option>
|
|
|
+ <option :value="true">是</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ ICE Server
|
|
|
+ <i class="material-icons info-icon" title="ICE 服务器地址,用于音视频媒体流穿透。格式:stun:server:port 或 turn:user:pass@server:port。例如:stun:stun.l.google.com:19302">info</i>
|
|
|
+ </label>
|
|
|
+ <input type="text" id="iceServer" v-model="settingsForm.iceServer" placeholder="例如: stun:stun.l.google.com:19302">
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ 自动接听
|
|
|
+ <i class="material-icons info-icon" title="收到来电时是否自动接听,无需手动操作。开启后适合客服场景">info</i>
|
|
|
+ </label>
|
|
|
+ <select id="autoAnswer" v-model="settingsForm.autoAnswer">
|
|
|
+ <option :value="false" selected>否</option>
|
|
|
+ <option :value="true">是</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label class="form-label">
|
|
|
+ 自动重连
|
|
|
+ <i class="material-icons info-icon" title="WebSocket 或 SIP 连接断开后是否尝试自动重连。建议开启以应对网络波动">info</i>
|
|
|
+ </label>
|
|
|
+ <select id="reconnect" v-model="settingsForm.reconnect">
|
|
|
+ <option :value="true" selected>是</option>
|
|
|
+ <option :value="false">否</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" v-if="settingsForm.reconnect">
|
|
|
+ <label class="form-label">
|
|
|
+ 重连间隔
|
|
|
+ <i class="material-icons info-icon" title="每次重连尝试之间的等待时间(单位:秒)。建议值:10-30 秒,过短会增加服务器压力">info</i>
|
|
|
+ </label>
|
|
|
+ <input type="number" id="reconnectInterval" v-model.number="settingsForm.reconnectInterval" placeholder="例如: 15" min="5" max="300">
|
|
|
+ </div>
|
|
|
+ <div class="form-buttons">
|
|
|
+ <button type="button" class="cancel-button" @click="settingsDialogVisible = false">取消</button>
|
|
|
+ <button type="submit" class="add-button">保存</button>
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { WebPhone, ProfileManager, checkMicrophonePermission } from '@/api/aiSipCall/softPhone.js';
|
|
|
+import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket.js';
|
|
|
+import { EventList, VideoLevels, AgentStatusEnum } from '@/assets/callCenterPhoneBarSdk/constants.js';
|
|
|
+import { myCallUser, getToolbarBasicParam } from '@/api/aiSipCall/aiSipCallUser.js';
|
|
|
+import { syncByUuid, encryptMobile } from '@/api/aiSipCall/aiSipCallOutboundCdr.js';
|
|
|
+
|
|
|
+// ==================== 全局配置常量 ====================
|
|
|
+/**
|
|
|
+ * IPCC服务器配置
|
|
|
+ */
|
|
|
+const IPCC_CONFIG = {
|
|
|
+ /** 生产环境IPCC服务器地址 */
|
|
|
+ SERVER_PROD: 'sip.ylrzcloud.com',
|
|
|
+ /** 本地调试IPCC服务器地址 */
|
|
|
+ SERVER_LOCAL: '129.28.164.235',
|
|
|
+ /** 本地调试端口 */
|
|
|
+ PORT_LOCAL: 1081,
|
|
|
+ /** WebSocket连接超时时间(毫秒) */
|
|
|
+ CONNECT_TIMEOUT: 15000
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * SIP默认账号配置
|
|
|
+ */
|
|
|
+const SIP_DEFAULT_CONFIG = {
|
|
|
+ /** 默认服务器地址 */
|
|
|
+ SERVER: 'ws://129.28.164.235:5066',
|
|
|
+ /** 默认域名 */
|
|
|
+ DOMAIN: '129.28.164.235',
|
|
|
+ /** 默认传输协议 */
|
|
|
+ TRANSPORT: 'ws'
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 音量控制配置
|
|
|
+ */
|
|
|
+const VOLUME_CONFIG = {
|
|
|
+ /** 默认音量值 (0-1) */
|
|
|
+ DEFAULT: 0.8,
|
|
|
+ /** 滑块隐藏延迟时间(毫秒) */
|
|
|
+ HIDE_DELAY: 1000
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * UI状态常量
|
|
|
+ */
|
|
|
+const UI_STATE = {
|
|
|
+ /** 空闲状态 */
|
|
|
+ IDLE: 'idle',
|
|
|
+ /** 振铃状态 */
|
|
|
+ RINGING: 'ringing',
|
|
|
+ /** 通话中状态 */
|
|
|
+ TALKING: 'talking'
|
|
|
+};
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'SoftPhone',
|
|
|
+ directives: {
|
|
|
+ 'click-outside': {
|
|
|
+ bind(el, binding, vnode) {
|
|
|
+ el.clickOutsideEvent = function(event) {
|
|
|
+ if (!(el === event.target || el.contains(event.target))) {
|
|
|
+ vnode.context[binding.expression]();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ document.body.addEventListener('click', el.clickOutsideEvent);
|
|
|
+ },
|
|
|
+ unbind(el) {
|
|
|
+ document.body.removeEventListener('click', el.clickOutsideEvent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ // 拨号与显示相关
|
|
|
+ dialNumber: '',
|
|
|
+ callDuration: '00:00',
|
|
|
+ province: '',
|
|
|
+ isContentFit: true,
|
|
|
+
|
|
|
+ // 网络与注册状态
|
|
|
+ isRegistered: false,
|
|
|
+ isConnected: false,
|
|
|
+ callStatus: UI_STATE.IDLE,
|
|
|
+
|
|
|
+ // 音频控制
|
|
|
+ speakerVolume: VOLUME_CONFIG.DEFAULT,
|
|
|
+ micVolume: VOLUME_CONFIG.DEFAULT,
|
|
|
+ isSpeakerMuted: false,
|
|
|
+ isMicMuted: false,
|
|
|
+ speakerSliderVisible: false,
|
|
|
+ micSliderVisible: false,
|
|
|
+ volumeTimerId: null,
|
|
|
+
|
|
|
+ // UI按钮状态
|
|
|
+ showLeftButton: false,
|
|
|
+ showRightButton: false,
|
|
|
+ leftButtonNormal: false,
|
|
|
+ rightButtonNormal: false,
|
|
|
+ rightButtonHangup: false,
|
|
|
+
|
|
|
+ // 用户账号管理
|
|
|
+ dropdownVisible: false,
|
|
|
+ userList: {},
|
|
|
+ currentUserId: '',
|
|
|
+ currentUserDisplay: '',
|
|
|
+
|
|
|
+ // 账号对话框
|
|
|
+ accountDialogVisible: false,
|
|
|
+ accountDialogTitle: '添加账号',
|
|
|
+ isEditMode: false,
|
|
|
+ editingUserId: null,
|
|
|
+ accountForm: {
|
|
|
+ note: '',
|
|
|
+ server: SIP_DEFAULT_CONFIG.SERVER,
|
|
|
+ username: '',
|
|
|
+ domain: SIP_DEFAULT_CONFIG.DOMAIN,
|
|
|
+ loginName: '',
|
|
|
+ password: '',
|
|
|
+ transport: SIP_DEFAULT_CONFIG.TRANSPORT
|
|
|
+ },
|
|
|
+ showPassword: false,
|
|
|
+
|
|
|
+ // 设置对话框
|
|
|
+ settingsDialogVisible: false,
|
|
|
+ settingsForm: {
|
|
|
+ userAgent: 'JsSIP',
|
|
|
+ sessionExpires: 180,
|
|
|
+ minSessionExpires: 120,
|
|
|
+ stun: false,
|
|
|
+ iceServer: '',
|
|
|
+ autoAnswer: true,
|
|
|
+ reconnect: true,
|
|
|
+ reconnectInterval: 15
|
|
|
+ },
|
|
|
+
|
|
|
+ // 拨号键盘
|
|
|
+ dialKeys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'],
|
|
|
+
|
|
|
+ // SIP电话实例
|
|
|
+ phone: null,
|
|
|
+ profileManager: null,
|
|
|
+
|
|
|
+ // 状态提示
|
|
|
+ statusText: '',
|
|
|
+ statusType: 'info',
|
|
|
+ statusTimerId: null, // 状态消息定时器ID
|
|
|
+ isReconnecting: false,
|
|
|
+ reconnectFailed: false,
|
|
|
+
|
|
|
+ // 呼叫中心集成
|
|
|
+ ccPhoneBar: null,
|
|
|
+ ccSocketConnected: false,
|
|
|
+ ccSocketFailed: false,
|
|
|
+ ccConnectingPromise: null,
|
|
|
+ ccConnectingResolve: null,
|
|
|
+ ccConnectingReject: null,
|
|
|
+
|
|
|
+ // 坐席状态
|
|
|
+ isCallingReady: false, // 是否可以外呼(坐席已置忙)
|
|
|
+ isOnHold: false, // 通话是否处于保持状态
|
|
|
+
|
|
|
+ // 通话记录管理
|
|
|
+ currentCallUuid: '', // 当前通话的UUID
|
|
|
+ callUuidMap: {}, // 存储所有通话的UUID映射,防止覆盖
|
|
|
+
|
|
|
+ // 明文/密文拨号模式
|
|
|
+ dialMode: 'plaintext', // 拨号方式:plaintext-明文, encrypted-密文
|
|
|
+ decryptedPhoneNumber: '', // 存储解密后的号码(用于拨号,不显示在输入框)
|
|
|
+ isDecrypting: false, // 是否正在解密
|
|
|
+ encryptingLock: false, // 加密锁,防止重复加密请求
|
|
|
+ encryptedNumberUnwatch: null // 加密号码监听器的unwatch函数
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ hangupButtonIcon() {
|
|
|
+ return this.callStatus !== 'idle' ? 'call_end' : 'phone';
|
|
|
+ },
|
|
|
+ leftButtonIcon() {
|
|
|
+ if (this.callStatus === 'ringing') return 'phone';
|
|
|
+ if (this.callStatus === 'talking') {
|
|
|
+ // 优先使用 IPCC 的保持状态,其次使用 JsSIP 的状态
|
|
|
+ if (this.ccPhoneBar && this.ccSocketConnected) {
|
|
|
+ return this.isOnHold ? 'play_arrow' : 'pause';
|
|
|
+ }
|
|
|
+ return this.phone && this.phone.IsOnHold() ? 'play_arrow' : 'pause';
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ rightButtonIcon() {
|
|
|
+ if (this.callStatus === 'ringing') return 'call_end';
|
|
|
+ if (this.callStatus === 'talking') return 'call_split';
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ speakerVolume(val) {
|
|
|
+ if (this.phone) this.phone.SetSpeaker(this.isSpeakerMuted, val);
|
|
|
+ },
|
|
|
+ micVolume(val) {
|
|
|
+ if (this.phone) this.phone.SetMicPhone(this.isMicMuted, val);
|
|
|
+ },
|
|
|
+ dialNumber() {
|
|
|
+ this.updateContentAlignment();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async mounted() {
|
|
|
+ this.profileManager = new ProfileManager();
|
|
|
+ const profile = this.profileManager.getProfile();
|
|
|
+ this.userList = profile.users || {};
|
|
|
+ this.currentUserId = profile.user || '';
|
|
|
+ if (this.currentUserId && this.userList[this.currentUserId]) {
|
|
|
+ this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
|
|
|
+ }
|
|
|
+ const settings = this.profileManager.getSettings();
|
|
|
+ this.settingsForm = { ...settings };
|
|
|
+ this.speakerVolume = profile.speaker_volume !== undefined ? profile.speaker_volume : 0.8;
|
|
|
+ this.micVolume = profile.mic_volume !== undefined ? profile.mic_volume : 0.8;
|
|
|
+ this.isSpeakerMuted = profile.speaker_paused || false;
|
|
|
+ this.isMicMuted = profile.mic_paused || false;
|
|
|
+ await this.initCCAndStart();
|
|
|
+ // 设置明文/密文监听器
|
|
|
+ this.setupEncryptedNumberWatcher();
|
|
|
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ console.log('[生命周期] 组件即将销毁');
|
|
|
+ this.destroyAllConnections();
|
|
|
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
|
|
+ // 清除所有定时器
|
|
|
+ this.clearAllTimers();
|
|
|
+ // 清理事件监听器
|
|
|
+ this.removeEventListeners();
|
|
|
+ console.log('[生命周期] 组件已销毁');
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // 获取左侧按钮标题
|
|
|
+ getLeftButtonTitle() {
|
|
|
+ if (this.callStatus === 'ringing') return '接听来电';
|
|
|
+ if (this.callStatus === 'talking') {
|
|
|
+ return this.isOnHold ? '恢复通话' : '保持通话';
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ // 获取挂机按钮标题
|
|
|
+ getHangupButtonTitle() {
|
|
|
+ if (this.callStatus !== 'idle') return '结束通话';
|
|
|
+ if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) return '发起外呼';
|
|
|
+ return '未就绪';
|
|
|
+ },
|
|
|
+ // 获取右侧按钮标题
|
|
|
+ getRightButtonTitle() {
|
|
|
+ if (this.callStatus === 'ringing') return '拒绝来电';
|
|
|
+ if (this.callStatus === 'talking') return '呼叫转移';
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ // 获取挂机按钮样式类名(注册成功显示绿色)
|
|
|
+ getHangupButtonClass() {
|
|
|
+ // 通话中时显示挂机红色样式
|
|
|
+ if (this.callStatus !== 'idle') {
|
|
|
+ return 'hangup';
|
|
|
+ }
|
|
|
+ // 空闲状态且注册成功且呼叫中心就绪,显示绿色外呼按钮
|
|
|
+ if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) {
|
|
|
+ return 'call-ready';
|
|
|
+ }
|
|
|
+ // 其他情况显示灰色禁用样式
|
|
|
+ return 'disabled';
|
|
|
+ },
|
|
|
+ updateContentAlignment() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const inputEl = this.$refs.dialerInput;
|
|
|
+ if (!inputEl) return;
|
|
|
+ this.isContentFit = inputEl.scrollWidth <= inputEl.clientWidth;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ scrollInputToEnd() {
|
|
|
+ const inputEl = this.$refs.dialerInput;
|
|
|
+ if (!inputEl) return;
|
|
|
+ this.$nextTick(() => {
|
|
|
+ // 确保光标在最后一个字符后面
|
|
|
+ inputEl.focus();
|
|
|
+ inputEl.setSelectionRange(this.dialNumber.length, this.dialNumber.length);
|
|
|
+ inputEl.scrollLeft = inputEl.scrollWidth - inputEl.clientWidth;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ restoreCursorAfterDelete(start, end) {
|
|
|
+ const inputEl = this.$refs.dialerInput;
|
|
|
+ if (!inputEl) return;
|
|
|
+ this.$nextTick(() => {
|
|
|
+ inputEl.focus();
|
|
|
+ inputEl.setSelectionRange(start, end);
|
|
|
+ this.updateContentAlignment();
|
|
|
+ });
|
|
|
+ },
|
|
|
+ deleteCharByCursor() {
|
|
|
+ const inputEl = this.$refs.dialerInput;
|
|
|
+ if (!inputEl) return;
|
|
|
+ if (document.activeElement !== inputEl) {
|
|
|
+ inputEl.focus();
|
|
|
+ inputEl.setSelectionRange(this.dialNumber.length, this.dialNumber.length);
|
|
|
+ }
|
|
|
+ // 删除光标前的一个字符
|
|
|
+ const cursorPos = inputEl.selectionStart || this.dialNumber.length;
|
|
|
+ if (cursorPos > 0) {
|
|
|
+ this.dialNumber = this.dialNumber.slice(0, cursorPos - 1) + this.dialNumber.slice(cursorPos);
|
|
|
+ this.restoreCursorAfterDelete(cursorPos - 1, cursorPos - 1);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ showStatus(text, type = 'info') {
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (this.statusTimerId) {
|
|
|
+ clearTimeout(this.statusTimerId);
|
|
|
+ this.statusTimerId = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.statusText = text;
|
|
|
+ this.statusType = type;
|
|
|
+
|
|
|
+ // 如果是“就绪”状态,不清空(一直显示)
|
|
|
+ if (text === '就绪') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他消息显示10秒后恢复为“就绪”
|
|
|
+ this.statusTimerId = setTimeout(() => {
|
|
|
+ // 检查是否仍然连接正常
|
|
|
+ if (this.isRegistered && this.ccSocketConnected && this.isCallingReady) {
|
|
|
+ this.statusText = '就绪';
|
|
|
+ this.statusType = 'success';
|
|
|
+ } else {
|
|
|
+ // 如果连接异常,清空状态文本
|
|
|
+ this.statusText = '';
|
|
|
+ }
|
|
|
+ this.statusTimerId = null;
|
|
|
+ }, 10000);
|
|
|
+ },
|
|
|
+ // ==================== 呼叫中心集成 ====================
|
|
|
+ async initCCAndStart() {
|
|
|
+ this.ccSocketConnected = false;
|
|
|
+ this.ccSocketFailed = false;
|
|
|
+ this.isCallingReady = false;
|
|
|
+ console.log('[SoftPhone] 开始初始化...');
|
|
|
+ try {
|
|
|
+ await this.ensureCCSocketConnect();
|
|
|
+ console.log('[SoftPhone] IPCC连接成功');
|
|
|
+ await this.startPhone();
|
|
|
+ console.log('[SoftPhone] SIP注册启动完成');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[SoftPhone] 初始化失败:', err.message);
|
|
|
+ this.ccSocketFailed = true;
|
|
|
+ this.showStatus(`初始化失败: ${err.message}`, 'error');
|
|
|
+ if (err.message.includes('分机号')) {
|
|
|
+ this.$message({
|
|
|
+ message: '未配置分机号,请联系管理员在【系统管理】-【员工管理】页面绑定 SIP 角色',
|
|
|
+ type: 'warning',
|
|
|
+ duration: 5000,
|
|
|
+ showClose: true
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ ensureCCSocketConnect() {
|
|
|
+ if (this.ccSocketConnected) return Promise.resolve();
|
|
|
+ if (this.ccConnectingPromise) return this.ccConnectingPromise;
|
|
|
+ this.ccConnectingPromise = new Promise((resolve, reject) => {
|
|
|
+ this.ccConnectingResolve = resolve;
|
|
|
+ this.ccConnectingReject = reject;
|
|
|
+ this._doConnectCCSocket();
|
|
|
+ });
|
|
|
+ return this.ccConnectingPromise;
|
|
|
+ },
|
|
|
+ async _doConnectCCSocket() {
|
|
|
+ try {
|
|
|
+ // 获取分机信息
|
|
|
+ const extRes = await myCallUser();
|
|
|
+ if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
|
|
|
+ throw new Error('未查询到分机号信息');
|
|
|
+ }
|
|
|
+ const { extNum, extPass, gatewayIds: myGateway } = extRes.data;
|
|
|
+
|
|
|
+ // 设置默认账号
|
|
|
+ this.setupDefaultAccount(extNum, extPass);
|
|
|
+
|
|
|
+ // 获取工具条配置
|
|
|
+ const basicRes = await getToolbarBasicParam({ extNum, myGateway });
|
|
|
+ if (basicRes.code !== 0) throw new Error(basicRes.message || '获取配置失败');
|
|
|
+ const configData = basicRes.data;
|
|
|
+ if (!configData.loginToken) throw new Error('登录令牌无效');
|
|
|
+
|
|
|
+ // 构建呼叫配置
|
|
|
+ const callConfig = {
|
|
|
+ useDefaultUi: false,
|
|
|
+ loginToken: configData.loginToken,
|
|
|
+ ipccServer: IPCC_CONFIG.SERVER_PROD,
|
|
|
+ gatewayList: configData.gatewayList,
|
|
|
+ gatewayEncrypted: false,
|
|
|
+ extPassword: configData.encryptPsw,
|
|
|
+ extnum: extNum,
|
|
|
+ opnum: configData.opNum || configData.userName,
|
|
|
+ enableWss: true,
|
|
|
+ enableHeartBeat: true,
|
|
|
+ heartBeatIntervalSecs: 16
|
|
|
+ };
|
|
|
+
|
|
|
+ // 初始化并连接
|
|
|
+ this.ccPhoneBar = new ccPhoneBarSocket();
|
|
|
+ this.ccPhoneBar.initConfig(callConfig);
|
|
|
+
|
|
|
+ // 绑定事件监听器
|
|
|
+ this._bindCCEvents();
|
|
|
+
|
|
|
+ // 发起连接
|
|
|
+ this.ccPhoneBar.connect();
|
|
|
+
|
|
|
+ // 设置连接超时
|
|
|
+ const timeoutId = setTimeout(() => {
|
|
|
+ if (!this.ccSocketConnected && this.ccConnectingReject) {
|
|
|
+ this.ccConnectingReject(new Error('连接超时'));
|
|
|
+ }
|
|
|
+ }, IPCC_CONFIG.CONNECT_TIMEOUT);
|
|
|
+
|
|
|
+ const originalResolve = this.ccConnectingResolve;
|
|
|
+ this.ccConnectingResolve = () => {
|
|
|
+ clearTimeout(timeoutId);
|
|
|
+ if (originalResolve) originalResolve();
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ if (this.ccConnectingReject) this.ccConnectingReject(err);
|
|
|
+ this.ccConnectingPromise = null;
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 绑定呼叫中心事件监听器
|
|
|
+ */
|
|
|
+ _bindCCEvents() {
|
|
|
+ // WebSocket连接事件
|
|
|
+ this.ccPhoneBar.on(EventList.WS_CONNECTED, () => {
|
|
|
+ console.log('[IPCC] WebSocket已连接');
|
|
|
+ this.ccSocketConnected = true;
|
|
|
+ this.ccSocketFailed = false;
|
|
|
+ if (this.ccConnectingResolve) this.ccConnectingResolve();
|
|
|
+ this.ccConnectingPromise = null;
|
|
|
+ });
|
|
|
+
|
|
|
+ this.ccPhoneBar.on(EventList.WS_DISCONNECTED, () => {
|
|
|
+ console.warn('[IPCC] WebSocket断开');
|
|
|
+ this.ccSocketConnected = false;
|
|
|
+ this.isCallingReady = false;
|
|
|
+ if (this.phone && this.isRegistered) {
|
|
|
+ this.showStatus('连接断开', 'warn');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 坐席状态改变
|
|
|
+ this.ccPhoneBar.on(EventList.STATUS_CHANGED, (msg) => {
|
|
|
+ if (msg?.object) {
|
|
|
+ const statusCode = msg.object.status;
|
|
|
+ const busyStatuses = [
|
|
|
+ AgentStatusEnum.BUSY,
|
|
|
+ AgentStatusEnum.BUSY_REST,
|
|
|
+ AgentStatusEnum.BUSY_MEETING,
|
|
|
+ AgentStatusEnum.BUSY_TRAINING
|
|
|
+ ];
|
|
|
+ const wasReady = this.isCallingReady;
|
|
|
+ this.isCallingReady = busyStatuses.includes(statusCode);
|
|
|
+ if (wasReady !== this.isCallingReady) {
|
|
|
+ console.log(`[IPCC] 坐席状态变更: ${statusCode} -> ${this.isCallingReady ? '就绪' : '未就绪'}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 错误处理
|
|
|
+ this.ccPhoneBar.on(EventList.REQUEST_ARGS_ERROR, () => {
|
|
|
+ console.error('[IPCC] 请求参数错误');
|
|
|
+ if (!this.ccSocketConnected && this.ccConnectingReject) {
|
|
|
+ this.ccConnectingReject(new Error('请求参数错误'));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ this.ccPhoneBar.on(EventList.SERVER_ERROR, () => {
|
|
|
+ console.error('[IPCC] 服务器错误');
|
|
|
+ if (!this.ccSocketConnected && this.ccConnectingReject) {
|
|
|
+ this.ccConnectingReject(new Error('服务器错误'));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 外呼开始 - 从消息中获取UUID
|
|
|
+ this.ccPhoneBar.on(EventList.OUTBOUND_START, (msg) => {
|
|
|
+ console.log('[IPCC] 外呼开始', msg);
|
|
|
+ // 显示拨号中状态
|
|
|
+ this.showStatus('拨号中...', 'info');
|
|
|
+ // 从消息中获取真实的UUID
|
|
|
+ if (msg?.object?.uuid) {
|
|
|
+ const outboundUuid = msg.object.uuid;
|
|
|
+ this.currentCallUuid = outboundUuid;
|
|
|
+ this.callUuidMap[outboundUuid] = {
|
|
|
+ startTime: Date.now(),
|
|
|
+ phoneNumber: this.dialNumber,
|
|
|
+ status: 'outbound_start'
|
|
|
+ };
|
|
|
+ console.log(`[UUID] 外呼开始获取UUID: ${outboundUuid}`);
|
|
|
+ } else {
|
|
|
+ console.warn('[UUID] 外呼开始消息中未获取到UUID');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 通话事件
|
|
|
+ this.ccPhoneBar.on(EventList.CALLEE_RINGING, () => {
|
|
|
+ console.log('[IPCC] 被叫振铃');
|
|
|
+ this.callStatus = UI_STATE.RINGING;
|
|
|
+ // 显示振铃状态
|
|
|
+ this.showStatus('振铃中...', 'info');
|
|
|
+ // 更新UUID状态
|
|
|
+ if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
|
|
|
+ this.callUuidMap[this.currentCallUuid].status = 'ringing';
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const handleCallAnswered = (msg) => {
|
|
|
+ console.log('[IPCC] 通话已接听', msg);
|
|
|
+ // 从消息中获取真实的UUID(优先使用)
|
|
|
+ if (msg?.object?.uuid) {
|
|
|
+ const realUuid = msg.object.uuid;
|
|
|
+ // 如果当前没有UUID或者UUID不同,更新为真实UUID
|
|
|
+ if (!this.currentCallUuid || this.currentCallUuid !== realUuid) {
|
|
|
+ if (this.currentCallUuid && this.currentCallUuid !== realUuid) {
|
|
|
+ console.log(`[UUID] 更新UUID: ${this.currentCallUuid} -> ${realUuid}`);
|
|
|
+ // 迁移数据到新的UUID
|
|
|
+ if (this.callUuidMap[this.currentCallUuid]) {
|
|
|
+ this.callUuidMap[realUuid] = this.callUuidMap[this.currentCallUuid];
|
|
|
+ delete this.callUuidMap[this.currentCallUuid];
|
|
|
+ }
|
|
|
+ } else if (!this.currentCallUuid) {
|
|
|
+ console.log(`[UUID] 首次设置UUID: ${realUuid}`);
|
|
|
+ this.callUuidMap[realUuid] = {
|
|
|
+ startTime: Date.now(),
|
|
|
+ phoneNumber: this.dialNumber,
|
|
|
+ status: 'answered'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ this.currentCallUuid = realUuid;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.warn('[UUID] 接听消息中未获取到UUID');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.callStatus = UI_STATE.TALKING;
|
|
|
+ this.showLeftButton = true;
|
|
|
+ this.showRightButton = true;
|
|
|
+ this.leftButtonNormal = true;
|
|
|
+ this.rightButtonNormal = true;
|
|
|
+ // 显示通话中状态
|
|
|
+ this.showStatus('通话中', 'success');
|
|
|
+
|
|
|
+ // 更新UUID状态
|
|
|
+ if (this.currentCallUuid && this.callUuidMap[this.currentCallUuid]) {
|
|
|
+ this.callUuidMap[this.currentCallUuid].status = 'answered';
|
|
|
+ this.callUuidMap[this.currentCallUuid].answerTime = Date.now();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ this.ccPhoneBar.on(EventList.CALLER_ANSWERED, handleCallAnswered);
|
|
|
+ this.ccPhoneBar.on(EventList.CALLEE_ANSWERED, handleCallAnswered);
|
|
|
+
|
|
|
+ const handleCallHangup = (msg) => {
|
|
|
+ console.log('[IPCC] 通话结束');
|
|
|
+ // 捕获当前UUID到局部变量,防止被新通话覆盖
|
|
|
+ const callUuid = this.currentCallUuid;
|
|
|
+
|
|
|
+ // 更新UUID状态
|
|
|
+ if (callUuid && this.callUuidMap[callUuid]) {
|
|
|
+ this.callUuidMap[callUuid].status = 'ended';
|
|
|
+ this.callUuidMap[callUuid].endTime = Date.now();
|
|
|
+ }
|
|
|
+
|
|
|
+ this._resetCallState();
|
|
|
+
|
|
|
+ // 显示已挂机状态
|
|
|
+ this.showStatus('已挂机', 'info');
|
|
|
+
|
|
|
+ // 通话结束后,确保坐席保持忙碌状态,以便可以继续外呼
|
|
|
+ if (this.ccPhoneBar && this.ccSocketConnected) {
|
|
|
+ console.log('[挂机] 设置坐席为忙碌状态,保持可外呼');
|
|
|
+ this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用后端接口保存通话记录
|
|
|
+ this._handleCallEnd(callUuid);
|
|
|
+ };
|
|
|
+
|
|
|
+ this.ccPhoneBar.on(EventList.CALLER_HANGUP, handleCallHangup);
|
|
|
+ this.ccPhoneBar.on(EventList.CALLEE_HANGUP, handleCallHangup);
|
|
|
+
|
|
|
+ // 保持/取消保持事件
|
|
|
+ this.ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
|
|
|
+ console.log('[IPCC] 通话已保持');
|
|
|
+ this.isOnHold = true;
|
|
|
+ // 显示暂停通话状态
|
|
|
+ this.showStatus('暂停通话中', 'warn');
|
|
|
+ });
|
|
|
+
|
|
|
+ this.ccPhoneBar.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
|
|
|
+ console.log('[IPCC] 通话已恢复');
|
|
|
+ this.isOnHold = false;
|
|
|
+ // 显示恢复通话状态
|
|
|
+ this.showStatus('通话中', 'success');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ setupDefaultAccount(extNum, extPass) {
|
|
|
+ if (!extNum || !extPass) return;
|
|
|
+ extNum = String(extNum).trim();
|
|
|
+ extPass = String(extPass).trim();
|
|
|
+ if (!extNum || !extPass) return;
|
|
|
+
|
|
|
+ console.log(`[账号] 设置默认账号: ${extNum}`);
|
|
|
+
|
|
|
+ // 查找已存在的账号
|
|
|
+ let existingUserId = null;
|
|
|
+ for (const [id, user] of Object.entries(this.userList)) {
|
|
|
+ if (user.user === extNum) {
|
|
|
+ existingUserId = id;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (existingUserId) {
|
|
|
+ // 更新现有账号
|
|
|
+ console.log(`[账号] 更新已有账号: ${existingUserId}`);
|
|
|
+ const updatedProfile = {
|
|
|
+ ...this.userList[existingUserId],
|
|
|
+ note: extNum,
|
|
|
+ display_name: extNum,
|
|
|
+ password: extPass,
|
|
|
+ user: extNum,
|
|
|
+ domain: this.userList[existingUserId].domain || IPCC_CONFIG.SERVER_PROD,
|
|
|
+ server: this.userList[existingUserId].server || `wss://${IPCC_CONFIG.SERVER_PROD}`,
|
|
|
+ transport: this.userList[existingUserId].transport || 'wss'
|
|
|
+ };
|
|
|
+ this.profileManager.updateUser(existingUserId, updatedProfile);
|
|
|
+ if (this.currentUserId !== existingUserId) {
|
|
|
+ this.profileManager.switchUser(existingUserId);
|
|
|
+ this.currentUserId = existingUserId;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 创建新账号
|
|
|
+ console.log(`[账号] 创建新账号: ${extNum}`);
|
|
|
+ const newProfile = {
|
|
|
+ note: extNum,
|
|
|
+ user: extNum,
|
|
|
+ domain: SIP_DEFAULT_CONFIG.DOMAIN,
|
|
|
+ password: extPass,
|
|
|
+ display_name: extNum,
|
|
|
+ server: SIP_DEFAULT_CONFIG.SERVER,
|
|
|
+ transport: SIP_DEFAULT_CONFIG.TRANSPORT
|
|
|
+ };
|
|
|
+ this.profileManager.addUser(newProfile);
|
|
|
+ const updatedProfile = this.profileManager.getProfile();
|
|
|
+ this.userList = updatedProfile.users;
|
|
|
+ for (const [id, user] of Object.entries(this.userList)) {
|
|
|
+ if (user.user === extNum) {
|
|
|
+ this.currentUserId = id;
|
|
|
+ this.profileManager.switchUser(id);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新显示信息
|
|
|
+ if (this.currentUserId && this.userList[this.currentUserId]) {
|
|
|
+ this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载音量配置
|
|
|
+ const profile = this.profileManager.getProfile();
|
|
|
+ this.speakerVolume = profile.speaker_volume ?? VOLUME_CONFIG.DEFAULT;
|
|
|
+ this.micVolume = profile.mic_volume ?? VOLUME_CONFIG.DEFAULT;
|
|
|
+ this.isSpeakerMuted = profile.speaker_paused || false;
|
|
|
+ this.isMicMuted = profile.mic_paused || false;
|
|
|
+ },
|
|
|
+ async startPhone() {
|
|
|
+ if (!this.ccSocketConnected) {
|
|
|
+ console.warn('[SIP] 无法启动: IPCC未连接');
|
|
|
+ this.showStatus('未连接', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const userProfile = this.profileManager.getCurrentUserProfile();
|
|
|
+ if (!userProfile) {
|
|
|
+ console.warn('[SIP] 无可用账号');
|
|
|
+ this.showStatus('无可用账号', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!userProfile.user || !userProfile.domain || !userProfile.password) {
|
|
|
+ console.error('[SIP] 账号配置不完整');
|
|
|
+ this.showStatus('账号配置错误', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[SIP] 准备启动: ${userProfile.user}@${userProfile.domain}`);
|
|
|
+
|
|
|
+ // 检查麦克风权限
|
|
|
+ const hasPermission = await checkMicrophonePermission();
|
|
|
+ if (!hasPermission) {
|
|
|
+ console.warn('[SIP] 麦克风权限未授权');
|
|
|
+ this.showStatus('请授权麦克风', 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 销毁旧实例
|
|
|
+ if (this.phone) {
|
|
|
+ console.log('[SIP] 销毁旧实例');
|
|
|
+ this.phone.destroy();
|
|
|
+ this.phone = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建新实例
|
|
|
+ const settings = this.profileManager.getSettings();
|
|
|
+ this.phone = new WebPhone(userProfile, settings);
|
|
|
+
|
|
|
+ // 绑定事件
|
|
|
+ this.phone.On('OnRegister', this.onRegisterEvent);
|
|
|
+ this.phone.On('OnSessionCreated', this.onSessionCreated);
|
|
|
+ this.phone.On('OnRing', this.onRing);
|
|
|
+ this.phone.On('OnAnswered', this.onAnswered);
|
|
|
+ this.phone.On('OnSessionClosed', this.onSessionClosed);
|
|
|
+ this.phone.On('OnCallTimer', this.onCallTimer);
|
|
|
+ this.phone.On('OnStatusMessage', this.onStatusMessage);
|
|
|
+ this.phone.On('OnReconnectStatus', this.onReconnectStatus);
|
|
|
+
|
|
|
+ // 启动
|
|
|
+ console.log('[SIP] 启动WebPhone');
|
|
|
+ this.phone.Start(settings.reconnect);
|
|
|
+ },
|
|
|
+ onReconnectStatus({ isReconnecting, failed }) {
|
|
|
+ this.isReconnecting = isReconnecting;
|
|
|
+ this.reconnectFailed = failed;
|
|
|
+ if (failed) {
|
|
|
+ console.error('[SIP] 重连超时');
|
|
|
+ this.showStatus('连接超时', 'error');
|
|
|
+ } else if (isReconnecting) {
|
|
|
+ console.log('[SIP] 正在重连...');
|
|
|
+ this.showStatus('重连中...', 'info');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onStatusMessage({ type, text }) { this.showStatus(text, type); },
|
|
|
+ onRegisterEvent(event) {
|
|
|
+ this.isRegistered = event.registered;
|
|
|
+ this.isConnected = event.registered;
|
|
|
+ if (event.registered) {
|
|
|
+ this.currentUserDisplay = this.userList[this.currentUserId]?.note || this.userList[this.currentUserId]?.user || '';
|
|
|
+ console.log(`[SIP] 连接成功: ${this.currentUserDisplay}`);
|
|
|
+ this.showStatus('就绪', 'success');
|
|
|
+ // SIP 注册成功后设置 IPCC 坐席为忙碌状态
|
|
|
+ if (this.ccPhoneBar && this.ccSocketConnected) {
|
|
|
+ console.log('[SIP] 设置坐席为忙碌状态');
|
|
|
+ this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY);
|
|
|
+ }
|
|
|
+ } else if (!this.isReconnecting) {
|
|
|
+ console.warn('[SIP] 未注册');
|
|
|
+ this.showStatus('未注册', 'warn');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onSessionCreated(event) {
|
|
|
+ // 来电时自动应答
|
|
|
+ if (!event.outgoing && this.phone && this.settingsForm.autoAnswer) {
|
|
|
+ this.phone.Answer();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onRing(event) {
|
|
|
+ if (!event.outgoing) {
|
|
|
+ this.callStatus = UI_STATE.RINGING;
|
|
|
+ this.province = `${event.province || ''}${event.city ? '-' + event.city : ''}`;
|
|
|
+ this.showLeftButton = true;
|
|
|
+ this.showRightButton = true;
|
|
|
+ this.rightButtonHangup = true;
|
|
|
+ // 显示来电振铃状态
|
|
|
+ this.showStatus('来电振铃中...', 'info');
|
|
|
+ // 自动接听
|
|
|
+ if (this.phone) this.phone.Answer();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onAnswered(outgoing) {
|
|
|
+ this.callStatus = UI_STATE.TALKING;
|
|
|
+ this.showLeftButton = true;
|
|
|
+ this.showRightButton = true;
|
|
|
+ this.leftButtonNormal = true;
|
|
|
+ this.rightButtonNormal = true;
|
|
|
+ this.rightButtonHangup = false;
|
|
|
+ // 显示通话中状态(外呼或接听)
|
|
|
+ this.showStatus('通话中', 'success');
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 重置通话状态
|
|
|
+ */
|
|
|
+ _resetCallState() {
|
|
|
+ if (this.callStatus !== UI_STATE.IDLE) {
|
|
|
+ console.log(`[通话] 状态重置: ${this.callStatus} -> idle`);
|
|
|
+ this.callStatus = UI_STATE.IDLE;
|
|
|
+ this.isOnHold = false;
|
|
|
+ // 注意:不清空 dialNumber,保留用户输入的号码以便再次外呼
|
|
|
+ // this.dialNumber = '';
|
|
|
+ this.province = '';
|
|
|
+ this.callDuration = '00:00';
|
|
|
+ this.showLeftButton = false;
|
|
|
+ this.showRightButton = false;
|
|
|
+ // 注意:不清空 currentCallUuid,因为后续还需要用它来保存通话记录
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onSessionClosed() {
|
|
|
+ // 显示已挂机状态
|
|
|
+ this.showStatus('已挂机', 'info');
|
|
|
+ this._resetCallState();
|
|
|
+ },
|
|
|
+ onCallTimer(time) { this.callDuration = time; },
|
|
|
+ // 外呼 - 使用呼叫中心工具条
|
|
|
+ async makeCall() {
|
|
|
+ if (!this.ccPhoneBar || !this.ccSocketConnected) {
|
|
|
+ console.warn('[外呼] IPCC未连接');
|
|
|
+ this.showStatus('未连接', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!this.isCallingReady) {
|
|
|
+ console.warn('[外呼] 坐席未就绪');
|
|
|
+ this.showStatus('坐席未就绪', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!this.dialNumber || this.dialNumber.trim().length < 3) {
|
|
|
+ console.warn(`[外呼] 号码无效: ${this.dialNumber}`);
|
|
|
+ this.showStatus('请输入正确的号码', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否有正在进行的通话
|
|
|
+ if (this.callStatus !== UI_STATE.IDLE) {
|
|
|
+ console.warn('[外呼] 当前有通话正在进行');
|
|
|
+ this.showStatus('请先结束当前通话', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let phoneNumber;
|
|
|
+ let dialModeStr = '明文';
|
|
|
+
|
|
|
+ if (this.dialMode === 'encrypted') {
|
|
|
+ dialModeStr = '密文';
|
|
|
+
|
|
|
+ // 检查是否正在解密
|
|
|
+ if (this.isDecrypting) {
|
|
|
+ console.warn('[外呼] 正在解密中');
|
|
|
+ this.showStatus('正在解密号码,请稍候...', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否已解密
|
|
|
+ if (!this.decryptedPhoneNumber) {
|
|
|
+ console.warn('[外呼] 号码未解密或解密失败');
|
|
|
+ this.showStatus('号码未解密或解密失败,请重新输入', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用解密后的号码
|
|
|
+ phoneNumber = this.decryptedPhoneNumber;
|
|
|
+ console.log('[外呼] 使用解密后的号码进行呼叫');
|
|
|
+ } else {
|
|
|
+ // 明文模式:直接使用输入的号码
|
|
|
+ phoneNumber = this.dialNumber.trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[外呼] 呼叫: ${phoneNumber}, 拨号方式: ${dialModeStr}`);
|
|
|
+ this.ccPhoneBar.call(phoneNumber, 'audio', VideoLevels.HD.levelId);
|
|
|
+ },
|
|
|
+ // 挂机 - 使用呼叫中心工具条
|
|
|
+ endCall() {
|
|
|
+ // 严格的状态检查,防止误操作
|
|
|
+ if (this.callStatus === UI_STATE.IDLE) {
|
|
|
+ console.warn('[挂机] 当前无通话,无法挂机');
|
|
|
+ this.showStatus('当前无通话', 'warn');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[挂机] 结束通话, UUID:', this.currentCallUuid);
|
|
|
+ if (this.ccPhoneBar && this.ccSocketConnected) {
|
|
|
+ try {
|
|
|
+ this.ccPhoneBar.hangup();
|
|
|
+ console.log('[挂机] 已调用hangup方法');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[挂机] 调用hangup失败:', err);
|
|
|
+ this.showStatus('挂机失败', 'error');
|
|
|
+ }
|
|
|
+ } else if (this.phone) {
|
|
|
+ try {
|
|
|
+ this.phone.Terminate();
|
|
|
+ console.log('[挂机] 已调用Terminate方法');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[挂机] 调用Terminate失败:', err);
|
|
|
+ this.showStatus('挂机失败', 'error');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.warn('[挂机] IPCC和JsSIP均未连接');
|
|
|
+ this.showStatus('无法挂机:未连接', 'error');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // ==================== 原有UI事件(修改为使用呼叫中心) ====================
|
|
|
+ deleteLastChar() { this.dialNumber = this.dialNumber.slice(0, -1); },
|
|
|
+ onDigitClick(digit) {
|
|
|
+ // 通话中发送DTMF
|
|
|
+ if (this.callStatus === 'talking' && this.phone) {
|
|
|
+ this.phone.SendDTMF(digit);
|
|
|
+ this.phone.PlayDtmfTone(digit);
|
|
|
+ }
|
|
|
+ this.dialNumber += digit;
|
|
|
+ this.scrollInputToEnd();
|
|
|
+ },
|
|
|
+ handleDisplayKeypress(event) {
|
|
|
+ const key = event.key;
|
|
|
+ if ((key >= '0' && key <= '9') || key === '*' || key === '#') {
|
|
|
+ if (this.callStatus === 'talking' && this.phone) {
|
|
|
+ this.phone.SendDTMF(key);
|
|
|
+ this.phone.PlayDtmfTone(key);
|
|
|
+ }
|
|
|
+ this.dialNumber += key;
|
|
|
+ this.scrollInputToEnd();
|
|
|
+ event.preventDefault();
|
|
|
+ } else if (key === 'Enter') {
|
|
|
+ this.onHangupClick();
|
|
|
+ event.preventDefault();
|
|
|
+ } else if (key === 'Backspace') {
|
|
|
+ event.preventDefault();
|
|
|
+ this.deleteCharByCursor();
|
|
|
+ } else event.preventDefault();
|
|
|
+ },
|
|
|
+ onHangupClick() {
|
|
|
+ // 严格的状态检查,防止误操作
|
|
|
+ if (this.callStatus !== UI_STATE.IDLE) {
|
|
|
+ // 通话中或振铃中,执行挂机
|
|
|
+ console.log('[操作] 结束通话');
|
|
|
+ this.endCall();
|
|
|
+ } else {
|
|
|
+ // 空闲状态,执行外呼(makeCall内部已有详细检查)
|
|
|
+ console.log('[操作] 发起外呼');
|
|
|
+ this.makeCall();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLeftButtonClick() {
|
|
|
+ // 严格的状态检查,防止误操作
|
|
|
+ if (this.callStatus === UI_STATE.RINGING) {
|
|
|
+ // 接听来电
|
|
|
+ console.log('[操作] 接听来电');
|
|
|
+ if (this.phone) this.phone.Answer();
|
|
|
+ } else if (this.callStatus === UI_STATE.TALKING) {
|
|
|
+ // 保持/取消保持(仅在通话中有效)
|
|
|
+ if (this.ccPhoneBar && this.ccSocketConnected) {
|
|
|
+ try {
|
|
|
+ if (this.isOnHold) {
|
|
|
+ console.log('[操作] 恢复通话');
|
|
|
+ this.ccPhoneBar.unHoldCall();
|
|
|
+ } else {
|
|
|
+ console.log('[操作] 保持通话');
|
|
|
+ this.ccPhoneBar.holdCall();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[操作] 保持/恢复失败:', err);
|
|
|
+ this.showStatus('操作失败', 'error');
|
|
|
+ }
|
|
|
+ } else if (this.phone) {
|
|
|
+ try {
|
|
|
+ console.log('[操作] JsSIP切换保持状态');
|
|
|
+ this.phone.ToggleHold();
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[操作] 保持/恢复失败:', err);
|
|
|
+ this.showStatus('操作失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 非振铃或通话状态,忽略操作
|
|
|
+ console.warn('[操作] 当前状态不允许此操作:', this.callStatus);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onRightButtonClick() {
|
|
|
+ // 严格的状态检查,防止误操作
|
|
|
+ if (this.callStatus === UI_STATE.RINGING) {
|
|
|
+ // 拒绝来电
|
|
|
+ console.log('[操作] 拒绝来电');
|
|
|
+ this.endCall();
|
|
|
+ } else if (this.callStatus === UI_STATE.TALKING) {
|
|
|
+ // 呼叫转移(仅在通话中有效)
|
|
|
+ console.log('[操作] 呼叫转移功能暂未实现');
|
|
|
+ this.showStatus('呼叫转移功能暂未实现', 'info');
|
|
|
+ } else {
|
|
|
+ // 非振铃或通话状态,忽略操作
|
|
|
+ console.warn('[操作] 当前状态不允许此操作:', this.callStatus);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ toggleMuteMic() {
|
|
|
+ if (!this.phone) return;
|
|
|
+ this.isMicMuted = !this.isMicMuted;
|
|
|
+ this.phone.ToggleMicPhone();
|
|
|
+ if (!this.isMicMuted) this.micVolume = this.profileManager.getProfile().mic_volume || 0.8;
|
|
|
+ },
|
|
|
+ toggleMuteSpeaker() {
|
|
|
+ if (!this.phone) return;
|
|
|
+ this.isSpeakerMuted = !this.isSpeakerMuted;
|
|
|
+ this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume);
|
|
|
+ if (!this.isSpeakerMuted) this.speakerVolume = this.profileManager.getProfile().speaker_volume || 0.8;
|
|
|
+ },
|
|
|
+ changeMicVolume(event) { this.micVolume = parseFloat(event.target.value); },
|
|
|
+ changeSpeakerVolume(event) { this.speakerVolume = parseFloat(event.target.value); },
|
|
|
+ showMicSlider() { this.cancelHideSliderTimer(); this.speakerSliderVisible = false; this.micSliderVisible = true; },
|
|
|
+ showSpeakerSlider() { this.cancelHideSliderTimer(); this.micSliderVisible = false; this.speakerSliderVisible = true; },
|
|
|
+ startHideSliderTimer() {
|
|
|
+ if (this.volumeTimerId) clearTimeout(this.volumeTimerId);
|
|
|
+ this.volumeTimerId = setTimeout(() => { this.micSliderVisible = this.speakerSliderVisible = false; }, 1000);
|
|
|
+ },
|
|
|
+ cancelHideSliderTimer() { if (this.volumeTimerId) { clearTimeout(this.volumeTimerId); this.volumeTimerId = null; } },
|
|
|
+ // 账号管理方法
|
|
|
+ toggleDropdown() { this.dropdownVisible = !this.dropdownVisible; },
|
|
|
+ closeDropdown() { this.dropdownVisible = false; },
|
|
|
+ async switchAccount(userId) {
|
|
|
+ if (userId === this.currentUserId) { this.closeDropdown(); return; }
|
|
|
+ this.profileManager.switchUser(userId);
|
|
|
+ this.currentUserId = userId;
|
|
|
+ const user = this.userList[userId];
|
|
|
+ this.currentUserDisplay = user.note || user.user;
|
|
|
+ if (this.phone) { this.phone.destroy(); this.phone = null; }
|
|
|
+ if (!this.ccSocketConnected) { this.showStatus('呼叫中心连接已断开,请重试', 'error'); return; }
|
|
|
+ await this.startPhone();
|
|
|
+ this.closeDropdown();
|
|
|
+ },
|
|
|
+ setStatus(type) {
|
|
|
+ if (!this.phone) return;
|
|
|
+ if (type === 'online') { this.phone.SetQueueIn(); this.showStatus('签入成功', 'success'); }
|
|
|
+ else if (type === 'logout') { this.phone.SetQueueOut(); this.showStatus('已签出', 'info'); }
|
|
|
+ else if (type === 'offline') { this.phone.UnRegister(); this.showStatus('已离线', 'info'); }
|
|
|
+ this.dropdownVisible = false;
|
|
|
+ },
|
|
|
+ openAddAccountDialog() {
|
|
|
+ this.isEditMode = false;
|
|
|
+ this.editingUserId = null;
|
|
|
+ this.accountDialogTitle = '添加账号';
|
|
|
+ this.accountForm = { note: '', server: 'ws://129.28.164.235:5066', username: '', domain: '129.28.164.235', loginName: '', password: '', transport: 'ws' };
|
|
|
+ this.showPassword = false;
|
|
|
+ this.accountDialogVisible = true;
|
|
|
+ },
|
|
|
+ openEditAccountDialog() {
|
|
|
+ const profile = this.profileManager.getCurrentUserProfile();
|
|
|
+ if (!profile) return;
|
|
|
+ this.isEditMode = true;
|
|
|
+ this.editingUserId = this.currentUserId;
|
|
|
+ this.accountDialogTitle = '编辑账号';
|
|
|
+ this.accountForm = {
|
|
|
+ note: profile.note || profile.display_name,
|
|
|
+ server: profile.server,
|
|
|
+ username: profile.display_name,
|
|
|
+ domain: profile.domain,
|
|
|
+ loginName: profile.user,
|
|
|
+ password: profile.password,
|
|
|
+ transport: profile.transport || 'wss'
|
|
|
+ };
|
|
|
+ this.showPassword = false;
|
|
|
+ this.accountDialogVisible = true;
|
|
|
+ },
|
|
|
+ saveAccount() {
|
|
|
+ try {
|
|
|
+ if (!this.accountForm.loginName || !this.accountForm.domain || !this.accountForm.password) {
|
|
|
+ this.$message.warning('登录名、域名和密码为必填项');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (this.isEditMode && this.editingUserId) {
|
|
|
+ const updatedProfile = {
|
|
|
+ note: this.accountForm.note,
|
|
|
+ server: this.accountForm.server,
|
|
|
+ display_name: this.accountForm.username,
|
|
|
+ password: this.accountForm.password,
|
|
|
+ transport: this.accountForm.transport,
|
|
|
+ user: this.userList[this.editingUserId].user,
|
|
|
+ domain: this.userList[this.editingUserId].domain
|
|
|
+ };
|
|
|
+ this.profileManager.updateUser(this.editingUserId, updatedProfile);
|
|
|
+ this.showStatus('账号已更新', 'success');
|
|
|
+ } else {
|
|
|
+ const profile = {
|
|
|
+ note: this.accountForm.note,
|
|
|
+ user: this.accountForm.loginName,
|
|
|
+ domain: this.accountForm.domain,
|
|
|
+ password: this.accountForm.password,
|
|
|
+ display_name: this.accountForm.username,
|
|
|
+ server: this.accountForm.server,
|
|
|
+ transport: this.accountForm.transport
|
|
|
+ };
|
|
|
+ this.profileManager.addUser(profile);
|
|
|
+ this.showStatus('账号已添加', 'success');
|
|
|
+ }
|
|
|
+ this.userList = this.profileManager.getProfile().users;
|
|
|
+ this.currentUserId = this.profileManager.getProfile().user;
|
|
|
+ if (this.currentUserId && this.userList[this.currentUserId]) {
|
|
|
+ this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
|
|
|
+ }
|
|
|
+ this.accountDialogVisible = false;
|
|
|
+ if (this.phone) { this.phone.destroy(); this.phone = null; }
|
|
|
+ if (this.ccSocketConnected) this.startPhone();
|
|
|
+ } catch (err) {
|
|
|
+ this.$message.error(err.message);
|
|
|
+ this.showStatus(err.message, 'error');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ confirmDeleteAccount() {
|
|
|
+ this.$confirm('删除账号将清除本地配置,确认删除?', '提示', {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }).then(() => {
|
|
|
+ this.profileManager.deleteCurrentUser();
|
|
|
+ const newProfile = this.profileManager.getProfile();
|
|
|
+ this.userList = newProfile.users || {};
|
|
|
+ this.currentUserId = newProfile.user || '';
|
|
|
+ this.currentUserDisplay = (this.currentUserId && this.userList[this.currentUserId])
|
|
|
+ ? (this.userList[this.currentUserId].note || this.userList[this.currentUserId].user)
|
|
|
+ : '';
|
|
|
+ if (this.phone) { this.phone.destroy(); this.phone = null; }
|
|
|
+ if (this.ccSocketConnected) this.startPhone();
|
|
|
+ this.showStatus('账号已删除', 'info');
|
|
|
+ }).catch(() => {});
|
|
|
+ },
|
|
|
+ openSettingsDialog() { this.settingsDialogVisible = true; },
|
|
|
+ saveSettings() {
|
|
|
+ this.profileManager.updateSettings({
|
|
|
+ user_agent: this.settingsForm.userAgent,
|
|
|
+ session_expires: this.settingsForm.sessionExpires,
|
|
|
+ min_session_expires: this.settingsForm.minSessionExpires,
|
|
|
+ stun: this.settingsForm.stun,
|
|
|
+ ice_server: this.settingsForm.iceServer,
|
|
|
+ auto_answer: this.settingsForm.autoAnswer,
|
|
|
+ reconnect: this.settingsForm.reconnect,
|
|
|
+ reconnect_interval: this.settingsForm.reconnectInterval
|
|
|
+ });
|
|
|
+ this.settingsDialogVisible = false;
|
|
|
+ if (this.phone) { this.phone.destroy(); this.phone = null; }
|
|
|
+ this.startPhone();
|
|
|
+ this.showStatus('设置已保存', 'success');
|
|
|
+ },
|
|
|
+ resetSettings() {
|
|
|
+ this.profileManager.resetSettings();
|
|
|
+ const settings = this.profileManager.getSettings();
|
|
|
+ this.settingsForm = { ...settings };
|
|
|
+ this.showStatus('设置已重置', 'success');
|
|
|
+ },
|
|
|
+ async resetReconnectState() {
|
|
|
+ console.log('[重置] 开始重置连接...');
|
|
|
+ this.showStatus('正在重置...', 'info');
|
|
|
+
|
|
|
+ // 清理定时器
|
|
|
+ if (this.volumeTimerId) {
|
|
|
+ clearTimeout(this.volumeTimerId);
|
|
|
+ this.volumeTimerId = null;
|
|
|
+ }
|
|
|
+ if (this.statusTimerId) {
|
|
|
+ clearTimeout(this.statusTimerId);
|
|
|
+ this.statusTimerId = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 销毁 SIP 实例
|
|
|
+ if (this.phone) {
|
|
|
+ console.log('[重置] 销毁SIP实例');
|
|
|
+ this.phone.destroy();
|
|
|
+ this.phone = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 断开 IPCC 连接
|
|
|
+ if (this.ccPhoneBar) {
|
|
|
+ console.log('[重置] 断开IPCC连接');
|
|
|
+ try {
|
|
|
+ this.ccPhoneBar.disconnect();
|
|
|
+ } catch(e) {}
|
|
|
+ this.ccPhoneBar = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置状态
|
|
|
+ this.ccSocketConnected = false;
|
|
|
+ this.ccSocketFailed = false;
|
|
|
+ this.isCallingReady = false;
|
|
|
+ this.isReconnecting = false;
|
|
|
+ this.reconnectFailed = false;
|
|
|
+ this.isConnected = false;
|
|
|
+ this.isRegistered = false;
|
|
|
+ this.callStatus = UI_STATE.IDLE;
|
|
|
+
|
|
|
+ // 清理连接 Promise
|
|
|
+ if (this.ccConnectingPromise) {
|
|
|
+ if (this.ccConnectingReject) {
|
|
|
+ try {
|
|
|
+ this.ccConnectingReject(new Error('用户主动重置'));
|
|
|
+ } catch(e) {}
|
|
|
+ }
|
|
|
+ this.ccConnectingPromise = null;
|
|
|
+ this.ccConnectingResolve = null;
|
|
|
+ this.ccConnectingReject = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置 UI
|
|
|
+ this.dialNumber = '';
|
|
|
+ this.province = '';
|
|
|
+ this.callDuration = '00:00';
|
|
|
+ this.showLeftButton = false;
|
|
|
+ this.showRightButton = false;
|
|
|
+ this.leftButtonNormal = false;
|
|
|
+ this.rightButtonNormal = false;
|
|
|
+ this.rightButtonHangup = false;
|
|
|
+ this.dropdownVisible = false;
|
|
|
+
|
|
|
+ // 重新连接
|
|
|
+ console.log('[重置] 重新初始化...');
|
|
|
+ this.showStatus('重新连接中...', 'info');
|
|
|
+ try {
|
|
|
+ await this.initCCAndStart();
|
|
|
+ console.log('[重置] 重连成功');
|
|
|
+ this.showStatus('重连成功', 'success');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[重置] 重连失败:', err.message);
|
|
|
+ this.showStatus('重连失败', 'error');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ destroyAllConnections() {
|
|
|
+ console.log('[销毁] 清理所有连接和资源');
|
|
|
+
|
|
|
+ // 清理定时器
|
|
|
+ this.clearAllTimers();
|
|
|
+
|
|
|
+ // 销毁 SIP 实例
|
|
|
+ if (this.phone) {
|
|
|
+ console.log('[销毁] 销毁SIP实例');
|
|
|
+ try {
|
|
|
+ this.phone.destroy();
|
|
|
+ } catch(e) {
|
|
|
+ console.error('[销毁] SIP实例销毁失败:', e);
|
|
|
+ }
|
|
|
+ this.phone = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 断开 IPCC 连接
|
|
|
+ if (this.ccPhoneBar) {
|
|
|
+ console.log('[销毁] 断开IPCC连接');
|
|
|
+ try {
|
|
|
+ this.ccPhoneBar.disconnect();
|
|
|
+ } catch(e) {
|
|
|
+ console.error('[销毁] IPCC连接断开失败:', e);
|
|
|
+ }
|
|
|
+ this.ccPhoneBar = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清空通话记录映射
|
|
|
+ this.callUuidMap = {};
|
|
|
+ this.currentCallUuid = '';
|
|
|
+
|
|
|
+ // 重置所有状态
|
|
|
+ this.resetAllStates();
|
|
|
+
|
|
|
+ console.log('[销毁] 资源清理完成');
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 清除所有定时器
|
|
|
+ */
|
|
|
+ clearAllTimers() {
|
|
|
+ if (this.volumeTimerId) {
|
|
|
+ clearTimeout(this.volumeTimerId);
|
|
|
+ this.volumeTimerId = null;
|
|
|
+ }
|
|
|
+ if (this.statusTimerId) {
|
|
|
+ clearTimeout(this.statusTimerId);
|
|
|
+ this.statusTimerId = null;
|
|
|
+ }
|
|
|
+ console.log('[销毁] 定时器已清理');
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 重置所有状态
|
|
|
+ */
|
|
|
+ resetAllStates() {
|
|
|
+ this.ccSocketConnected = false;
|
|
|
+ this.ccSocketFailed = false;
|
|
|
+ this.isCallingReady = false;
|
|
|
+ this.isReconnecting = false;
|
|
|
+ this.reconnectFailed = false;
|
|
|
+ this.isConnected = false;
|
|
|
+ this.isRegistered = false;
|
|
|
+ this.callStatus = UI_STATE.IDLE;
|
|
|
+ this.isOnHold = false;
|
|
|
+ this.dropdownVisible = false;
|
|
|
+ this.accountDialogVisible = false;
|
|
|
+ this.settingsDialogVisible = false;
|
|
|
+ this.micSliderVisible = false;
|
|
|
+ this.speakerSliderVisible = false;
|
|
|
+ console.log('[销毁] 状态已重置');
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 移除事件监听器
|
|
|
+ */
|
|
|
+ removeEventListeners() {
|
|
|
+ // 如果phone实例存在,移除所有事件监听
|
|
|
+ if (this.phone) {
|
|
|
+ try {
|
|
|
+ this.phone.Off('OnRegister', this.onRegisterEvent);
|
|
|
+ this.phone.Off('OnSessionCreated', this.onSessionCreated);
|
|
|
+ this.phone.Off('OnRing', this.onRing);
|
|
|
+ this.phone.Off('OnAnswered', this.onAnswered);
|
|
|
+ this.phone.Off('OnSessionClosed', this.onSessionClosed);
|
|
|
+ this.phone.Off('OnCallTimer', this.onCallTimer);
|
|
|
+ this.phone.Off('OnStatusMessage', this.onStatusMessage);
|
|
|
+ this.phone.Off('OnReconnectStatus', this.onReconnectStatus);
|
|
|
+ console.log('[销毁] SIP事件监听器已移除');
|
|
|
+ } catch(e) {
|
|
|
+ console.error('[销毁] 移除SIP事件监听器失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 如果ccPhoneBar存在,移除所有事件监听
|
|
|
+ if (this.ccPhoneBar) {
|
|
|
+ try {
|
|
|
+ // ccPhoneBar的事件监听由SDK内部管理,disconnect时会清理
|
|
|
+ console.log('[销毁] IPCC事件监听器将随disconnect清理');
|
|
|
+ } catch(e) {
|
|
|
+ console.error('[销毁] 移除IPCC事件监听器失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 移除加密号码监听器
|
|
|
+ if (this.encryptedNumberUnwatch) {
|
|
|
+ try {
|
|
|
+ this.encryptedNumberUnwatch();
|
|
|
+ this.encryptedNumberUnwatch = null;
|
|
|
+ console.log('[销毁] 加密号码监听器已移除');
|
|
|
+ } catch(e) {
|
|
|
+ console.error('[销毁] 移除加密号码监听器失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ handleBeforeUnload() { this.destroyAllConnections(); },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成通话UUID
|
|
|
+ */
|
|
|
+ _generateCallUuid() {
|
|
|
+ // 使用时间戳 + 随机数确保唯一性
|
|
|
+ const timestamp = Date.now().toString(36);
|
|
|
+ const randomPart = Math.random().toString(36).substring(2, 15);
|
|
|
+ const uuid = `call_${timestamp}_${randomPart}`;
|
|
|
+ console.log(`[UUID] 生成通话ID: ${uuid}`);
|
|
|
+ return uuid;
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理通话结束,保存通话记录
|
|
|
+ */
|
|
|
+ _handleCallEnd(callUuid) {
|
|
|
+ if (!callUuid) {
|
|
|
+ console.warn('[通话结束] 未获取到通话 UUID');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[通话结束] 准备保存通话记录, UUID:', callUuid);
|
|
|
+
|
|
|
+ // 延时10秒异步处理通话结束同步(与aiSipCallManualOutbound保持一致)
|
|
|
+ setTimeout(() => {
|
|
|
+ syncByUuid({ uuid: callUuid }).then(() => {
|
|
|
+ console.log('[通话结束] 后端同步通话记录成功, UUID:', callUuid);
|
|
|
+ // 清理已完成的通话记录(保留最近10条用于调试)
|
|
|
+ const uuidKeys = Object.keys(this.callUuidMap);
|
|
|
+ if (uuidKeys.length > 10) {
|
|
|
+ // 删除最早的记录
|
|
|
+ const oldestUuid = uuidKeys[0];
|
|
|
+ delete this.callUuidMap[oldestUuid];
|
|
|
+ }
|
|
|
+ }).catch(error => {
|
|
|
+ console.error('[通话结束] 后端同步通话记录失败, UUID:', callUuid, ', 错误:', error);
|
|
|
+ });
|
|
|
+ }, 10000);
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 设置加密号码监听器
|
|
|
+ */
|
|
|
+ setupEncryptedNumberWatcher() {
|
|
|
+ // 保存watcher的unwatch函数,以便在组件销毁时清理
|
|
|
+ this.encryptedNumberUnwatch = this.$watch(
|
|
|
+ () => ({ dialMode: this.dialMode, dialNumber: this.dialNumber }),
|
|
|
+ async (newVal, oldVal) => {
|
|
|
+ // 当拨号方式为密文且电话号码不为空时,自动解密
|
|
|
+ if (newVal.dialMode === 'encrypted' && newVal.dialNumber && newVal.dialNumber.trim().length > 0) {
|
|
|
+ // 如果之前有正在进行的解密,先重置状态
|
|
|
+ if (this.isDecrypting || this.encryptingLock) {
|
|
|
+ console.log('[解密中断] 取消之前的解密请求,准备新解密');
|
|
|
+ this.isDecrypting = false;
|
|
|
+ this.encryptingLock = false;
|
|
|
+ // 等待一小段时间确保状态完全重置
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ }
|
|
|
+ await this.decryptPhoneNumber(newVal.dialNumber.trim());
|
|
|
+ } else {
|
|
|
+ // 如果切换到明文或清空号码,清除解密后的号码
|
|
|
+ this.decryptedPhoneNumber = '';
|
|
|
+ this.isDecrypting = false;
|
|
|
+ this.encryptingLock = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true }
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 实时解密号码(返回 Promise)
|
|
|
+ */
|
|
|
+ async decryptPhoneNumber(phoneNumber) {
|
|
|
+ // 防止重复解密(增加锁检查)
|
|
|
+ if (this.isDecrypting || this.encryptingLock) {
|
|
|
+ console.log('[解密跳过] 已有解密请求进行中或锁定中');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.isDecrypting = true;
|
|
|
+ this.encryptingLock = true;
|
|
|
+ console.log('[开始解密] 正在调用后端接口解密号码...');
|
|
|
+
|
|
|
+ const combined = phoneNumber + this.generateRandom();
|
|
|
+
|
|
|
+ const response = await encryptMobile({"data": combined});
|
|
|
+
|
|
|
+ if (response.code === 200) {
|
|
|
+ // 用密钥解密
|
|
|
+ const privateKey = process.env.VUE_APP_PHONE_ENCRYPT_PRIVATE_KEY;
|
|
|
+ // 去掉后六位数据
|
|
|
+ const resultData = response.data.slice(0, -6);
|
|
|
+ // 保存解密后的号码(用于拨号)
|
|
|
+ this.decryptedPhoneNumber = this.xorDecrypt(resultData, privateKey);
|
|
|
+ console.log('[解密成功] 号码已解密,可用于拨号');
|
|
|
+ } else {
|
|
|
+ this.decryptedPhoneNumber = '';
|
|
|
+ this.showStatus('号码解密失败 ', 'error');
|
|
|
+ console.error('[解密失败] 后端返回错误:', response);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ this.decryptedPhoneNumber = '';
|
|
|
+ this.showStatus('号码解密异常', 'error');
|
|
|
+ console.error('[解密异常] 捕获到错误:', error);
|
|
|
+ } finally {
|
|
|
+ // 强制释放锁,确保可以再次拨号
|
|
|
+ this.isDecrypting = false;
|
|
|
+ this.encryptingLock = false;
|
|
|
+ console.log('[解密完成] 锁已释放,当前解密状态:', this.isDecrypting, '锁定状态:', this.encryptingLock);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成6位随机数
|
|
|
+ */
|
|
|
+ generateRandom() {
|
|
|
+ return Math.floor(100000 + Math.random() * 900000).toString();
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * XOR 解密(Base64 输入,私钥字符串)
|
|
|
+ */
|
|
|
+ xorDecrypt(base64Str, privateKey) {
|
|
|
+ // Base64 解码为二进制字符串
|
|
|
+ const binaryStr = atob(base64Str);
|
|
|
+ const keyBytes = privateKey.split('').map(ch => ch.charCodeAt(0));
|
|
|
+ let result = '';
|
|
|
+ for (let i = 0; i < binaryStr.length; i++) {
|
|
|
+ const dataByte = binaryStr.charCodeAt(i);
|
|
|
+ const keyByte = keyBytes[i % keyBytes.length];
|
|
|
+ result += String.fromCharCode(dataByte ^ keyByte);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理拨号模式切换
|
|
|
+ */
|
|
|
+ handleDialModeChange() {
|
|
|
+ console.log(`[拨号模式] 切换到: ${this.dialMode === 'plaintext' ? '明文' : '密文'}`);
|
|
|
+ // 如果切换到明文,清除解密后的号码
|
|
|
+ if (this.dialMode === 'plaintext') {
|
|
|
+ this.decryptedPhoneNumber = '';
|
|
|
+ this.isDecrypting = false;
|
|
|
+ this.encryptingLock = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
|
|
|
+
|
|
|
+.webphone-container {
|
|
|
+ position: fixed;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ z-index: 9999;
|
|
|
+}
|
|
|
+
|
|
|
+.dialer {
|
|
|
+ width: 280px;
|
|
|
+ min-height: 480px;
|
|
|
+ background-color: #fafafa;
|
|
|
+ border-radius: 16px;
|
|
|
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ padding-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 状态栏 */
|
|
|
+.status-bar {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ height: 28px;
|
|
|
+ background-color: transparent;
|
|
|
+ padding: 8px 12px;
|
|
|
+ gap: 4px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+.status-left {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
+.status-center {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ overflow: hidden;
|
|
|
+ white-space: nowrap;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ padding: 0 4px;
|
|
|
+}
|
|
|
+.status-right {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.material-icons {
|
|
|
+ font-size: 22px;
|
|
|
+ transition: color 0.2s ease;
|
|
|
+}
|
|
|
+.network-icon {
|
|
|
+ cursor: default;
|
|
|
+ width: 22px;
|
|
|
+}
|
|
|
+.no-network {
|
|
|
+ color: #ff5252;
|
|
|
+}
|
|
|
+.network-available {
|
|
|
+ color: #4caf50;
|
|
|
+}
|
|
|
+.network-connecting {
|
|
|
+ color: #ffa726;
|
|
|
+ animation: pulse 1.5s infinite;
|
|
|
+}
|
|
|
+@keyframes pulse {
|
|
|
+ 0%, 100% { opacity: 1; }
|
|
|
+ 50% { opacity: 0.5; }
|
|
|
+}
|
|
|
+.microphone-icon, .speaker-icon {
|
|
|
+ cursor: pointer;
|
|
|
+ color: #555;
|
|
|
+ z-index: 1;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+}
|
|
|
+.microphone-icon:hover, .speaker-icon:hover {
|
|
|
+ color: #2196f3;
|
|
|
+ transform: scale(1.1);
|
|
|
+}
|
|
|
+.microphone-icon.muted, .speaker-icon.muted {
|
|
|
+ color: #bbb;
|
|
|
+}
|
|
|
+/* 连接成功时显示绿色 */
|
|
|
+.microphone-icon.connection-success, .speaker-icon.connection-success {
|
|
|
+ color: #4caf50;
|
|
|
+}
|
|
|
+/* 连接失败时显示灰色 */
|
|
|
+.microphone-icon.connection-failed, .speaker-icon.connection-failed {
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+.call-status-icon {
|
|
|
+ cursor: default;
|
|
|
+ color: #4caf50;
|
|
|
+ width: 22px;
|
|
|
+}
|
|
|
+.call-status-icon.inprogress {
|
|
|
+ color: #2196f3;
|
|
|
+}
|
|
|
+.ringing-icon {
|
|
|
+ animation: ringing 0.8s infinite;
|
|
|
+}
|
|
|
+@keyframes ringing {
|
|
|
+ 0%, 100% { transform: rotate(0deg); }
|
|
|
+ 50% { transform: rotate(8deg); }
|
|
|
+}
|
|
|
+.container {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ margin: 4px 0;
|
|
|
+}
|
|
|
+.call-timer {
|
|
|
+ cursor: default;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ font-weight: 500;
|
|
|
+ height: 16px;
|
|
|
+}
|
|
|
+.province {
|
|
|
+ cursor: default;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ font-weight: 500;
|
|
|
+ height: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 解密状态提示 */
|
|
|
+.decrypting-tip {
|
|
|
+ color: #ff9800;
|
|
|
+ font-size: 12px;
|
|
|
+ margin-top: 4px;
|
|
|
+ animation: pulse 1.5s ease-in-out infinite;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.display-user {
|
|
|
+ cursor: default;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ display: inline-block;
|
|
|
+ max-width: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+.user-avatar-dropdown {
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.user-avatar-icon {
|
|
|
+ color: #555;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+}
|
|
|
+.user-avatar-icon:hover {
|
|
|
+ color: #2196f3;
|
|
|
+ transform: scale(1.1);
|
|
|
+}
|
|
|
+.dropdown-menu {
|
|
|
+ position: absolute;
|
|
|
+ top: 100%;
|
|
|
+ left: 40%;
|
|
|
+ min-width: 140px;
|
|
|
+ background-color: #fff;
|
|
|
+ border: 1px solid #e0e0e0;
|
|
|
+ border-radius: 6px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
+ padding: 4px;
|
|
|
+ z-index: 1000;
|
|
|
+ animation: fadeIn 0.2s ease;
|
|
|
+}
|
|
|
+@keyframes fadeIn {
|
|
|
+ from { opacity: 0; transform: translateY(-5px); }
|
|
|
+ to { opacity: 1; transform: translateY(0); }
|
|
|
+}
|
|
|
+.dropdown-group {
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+.dropdown-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ text-decoration: none;
|
|
|
+ color: #555;
|
|
|
+ width: 100%;
|
|
|
+ white-space: nowrap;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.dropdown-item i.material-icons {
|
|
|
+ font-size: 18px;
|
|
|
+ width: 18px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.dropdown-item:hover {
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ color: #2196f3;
|
|
|
+}
|
|
|
+.volume-control-group {
|
|
|
+ position: relative;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.volume-slider-container {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 32px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ width: 160px;
|
|
|
+ background: white;
|
|
|
+ padding: 10px;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
|
+ z-index: 100;
|
|
|
+ animation: slideUp 0.2s ease;
|
|
|
+}
|
|
|
+@keyframes slideUp {
|
|
|
+ from { opacity: 0; transform: translateX(-50%) translateY(5px); }
|
|
|
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
|
+}
|
|
|
+.volume-slider {
|
|
|
+ width: 100%;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+.display-wrapper {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin: 28px 16px 0 16px;
|
|
|
+ position: relative;
|
|
|
+ gap: 6px;
|
|
|
+ width: calc(100% - 32px);
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+/* 明文/密文选择框 */
|
|
|
+.dial-mode-selector {
|
|
|
+ position: absolute;
|
|
|
+ top: -28px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ z-index: 10;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.radio-label {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #666;
|
|
|
+ user-select: none;
|
|
|
+ white-space: nowrap;
|
|
|
+ transition: color 0.2s ease;
|
|
|
+}
|
|
|
+.radio-label:hover {
|
|
|
+ color: #2196f3;
|
|
|
+}
|
|
|
+
|
|
|
+.radio-label input[type="radio"] {
|
|
|
+ margin: 0;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.radio-label span {
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.dialer-display {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ height: 42px;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 500;
|
|
|
+ border: none;
|
|
|
+ outline: none;
|
|
|
+ background-color: transparent;
|
|
|
+ color: #333;
|
|
|
+ border-bottom: 2px solid #e0e0e0;
|
|
|
+ padding: 0 6px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ overflow-x: auto;
|
|
|
+ white-space: nowrap;
|
|
|
+ transition: border-color 0.2s ease;
|
|
|
+}
|
|
|
+.dialer-display:focus {
|
|
|
+ border-bottom-color: #2196f3;
|
|
|
+}
|
|
|
+.dialer-display.center-align {
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.dialer-display.right-align {
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+.dialer-display::-webkit-scrollbar {
|
|
|
+ height: 2px;
|
|
|
+}
|
|
|
+.dialer-display::-webkit-scrollbar-thumb {
|
|
|
+ background: #ccc;
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+.delete-icon {
|
|
|
+ cursor: pointer;
|
|
|
+ color: #999;
|
|
|
+ font-size: 26px;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ user-select: none;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.delete-icon:hover {
|
|
|
+ color: #f44336;
|
|
|
+ transform: scale(1.1);
|
|
|
+}
|
|
|
+.dialer-keypad {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 10px;
|
|
|
+ padding: 8px 20px;
|
|
|
+ margin: 4px 0;
|
|
|
+}
|
|
|
+.dialer-button {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 1 / 1;
|
|
|
+ max-width: 56px;
|
|
|
+ margin: 0 auto;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ border: 2px solid #d0d0d0;
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 500;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #333;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
|
+}
|
|
|
+.dialer-button:hover {
|
|
|
+ background-color: #e8e8e8;
|
|
|
+ transform: scale(1.05);
|
|
|
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
|
+ border-color: #bbb;
|
|
|
+}
|
|
|
+.dialer-button:active {
|
|
|
+ background-color: #ddd;
|
|
|
+ transform: scale(0.95);
|
|
|
+}
|
|
|
+.call-buttons {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ padding: 4px 24px 8px 24px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.call-button {
|
|
|
+ width: 56px;
|
|
|
+ height: 56px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: #4caf50;
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ font-size: 26px;
|
|
|
+ outline: none;
|
|
|
+ margin: 0 auto;
|
|
|
+}
|
|
|
+.call-button:hover:not(.disabled) {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 6px 16px rgba(76, 175, 80, 0.4);
|
|
|
+}
|
|
|
+.call-button:active:not(.disabled) {
|
|
|
+ transform: translateY(0);
|
|
|
+ box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
|
|
|
+}
|
|
|
+.call-button.hangup {
|
|
|
+ background-color: #f44336;
|
|
|
+ color: white;
|
|
|
+ box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
|
|
|
+}
|
|
|
+.call-button.hangup:hover {
|
|
|
+ box-shadow: 0 6px 16px rgba(244, 67, 54, 0.4);
|
|
|
+}
|
|
|
+.call-button.normal {
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ color: #333;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+.call-button.normal:hover {
|
|
|
+ background-color: #e8e8e8;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
+}
|
|
|
+/* 外呼按钮就绪状态 - 绿色(注册成功且呼叫中心就绪时显示) */
|
|
|
+.call-button.call-ready {
|
|
|
+ background-color: #4caf50;
|
|
|
+ color: white;
|
|
|
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
|
|
|
+}
|
|
|
+.call-button.call-ready:hover {
|
|
|
+ box-shadow: 0 6px 16px rgba(76, 175, 80, 0.4);
|
|
|
+}
|
|
|
+.call-button.disabled {
|
|
|
+ background-color: #e0e0e0;
|
|
|
+ color: #999;
|
|
|
+ cursor: not-allowed;
|
|
|
+ box-shadow: none;
|
|
|
+}
|
|
|
+.hidden {
|
|
|
+ visibility: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 底部状态栏 - 解决遮挡问题 */
|
|
|
+.status-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 6px 12px;
|
|
|
+ margin-top: 4px;
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+.status-footer-left {
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.status-bar-message {
|
|
|
+ font-size: 11px;
|
|
|
+ max-width: 160px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ background: rgba(0, 0, 0, 0.75);
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 16px;
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+ color: white;
|
|
|
+ font-weight: 500;
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
|
+ animation: slideInLeft 0.3s ease;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+@keyframes slideInLeft {
|
|
|
+ from { opacity: 0; transform: translateX(-10px); }
|
|
|
+ to { opacity: 1; transform: translateX(0); }
|
|
|
+}
|
|
|
+.status-bar-message.error { background: rgba(244, 67, 54, 0.9); color: white; }
|
|
|
+.status-bar-message.success { background: rgba(76, 175, 80, 0.9); color: white; }
|
|
|
+.status-bar-message.warn { background: rgba(255, 152, 0, 0.9); color: white; }
|
|
|
+.status-bar-message.info { background: rgba(33, 150, 243, 0.9); color: white; }
|
|
|
+.reconnect-failed {
|
|
|
+ font-size: 11px;
|
|
|
+ background: rgba(244, 67, 54, 0.95);
|
|
|
+ padding: 4px 10px;
|
|
|
+ border-radius: 16px;
|
|
|
+ color: white;
|
|
|
+ backdrop-filter: blur(8px);
|
|
|
+ animation: shake 0.5s ease;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+@keyframes shake {
|
|
|
+ 0%, 100% { transform: translateX(0); }
|
|
|
+ 25% { transform: translateX(-5px); }
|
|
|
+ 75% { transform: translateX(5px); }
|
|
|
+}
|
|
|
+.version-ribbon {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #bbb;
|
|
|
+ background: rgba(0,0,0,0.03);
|
|
|
+ padding: 3px 8px;
|
|
|
+ border-radius: 12px;
|
|
|
+ pointer-events: none;
|
|
|
+ font-family: monospace;
|
|
|
+ font-weight: 500;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.modal {
|
|
|
+ display: block;
|
|
|
+ position: fixed;
|
|
|
+ z-index: 1000;
|
|
|
+ left: 50%;
|
|
|
+ top: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ width: 340px;
|
|
|
+ max-height: 80vh;
|
|
|
+ overflow-y: auto;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
|
|
|
+ animation: modalFadeIn 0.3s ease;
|
|
|
+}
|
|
|
+@keyframes modalFadeIn {
|
|
|
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
|
|
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
|
+}
|
|
|
+.modal-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 14px;
|
|
|
+ border-radius: 12px 12px 0 0;
|
|
|
+ background-color: #f8f8f8;
|
|
|
+ gap: 8px;
|
|
|
+ border-bottom: 1px solid #e8e8e8;
|
|
|
+}
|
|
|
+.modal-header i.material-icons {
|
|
|
+ font-size: 24px;
|
|
|
+ color: #2196f3;
|
|
|
+}
|
|
|
+.modal-header span {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+.modal-content {
|
|
|
+ background-color: #fff;
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
+.form-group {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.form-label {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #555;
|
|
|
+ margin-bottom: 6px;
|
|
|
+}
|
|
|
+.info-icon {
|
|
|
+ font-size: 16px;
|
|
|
+ color: #bbb;
|
|
|
+ cursor: help;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+}
|
|
|
+.info-icon:hover {
|
|
|
+ color: #2196f3;
|
|
|
+ transform: scale(1.1);
|
|
|
+}
|
|
|
+.form-group input[type="number"],
|
|
|
+.form-group input[type="text"],
|
|
|
+.form-group input[type="password"],
|
|
|
+.form-group select {
|
|
|
+ width: 100%;
|
|
|
+ padding: 10px 12px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 1px solid #e0e0e0;
|
|
|
+ border-radius: 6px;
|
|
|
+ color: #333;
|
|
|
+ font-size: 14px;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+ background-color: #fafafa;
|
|
|
+}
|
|
|
+.form-group input:focus,
|
|
|
+.form-group select:focus {
|
|
|
+ outline: none;
|
|
|
+ border-color: #2196f3;
|
|
|
+ background-color: #fff;
|
|
|
+ box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
|
|
|
+}
|
|
|
+.form-group .password-toggle {
|
|
|
+ position: absolute;
|
|
|
+ right: 10px;
|
|
|
+ top: 36px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #bbb;
|
|
|
+ transition: all 0.2s ease;
|
|
|
+}
|
|
|
+.form-group .password-toggle:hover {
|
|
|
+ color: #2196f3;
|
|
|
+}
|
|
|
+.form-buttons {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 60px;
|
|
|
+ margin-top: 20px;
|
|
|
+}
|
|
|
+.form-buttons button {
|
|
|
+ width: 35%;
|
|
|
+ padding: 10px;
|
|
|
+ background-color: transparent;
|
|
|
+ color: #fff;
|
|
|
+ border: none;
|
|
|
+ cursor: pointer;
|
|
|
+ border-radius: 6px;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 500;
|
|
|
+ transition: all 0.2s ease-in-out;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+.form-buttons button i.material-icons {
|
|
|
+ font-size: 18px;
|
|
|
+ vertical-align: middle;
|
|
|
+ margin-right: 4px;
|
|
|
+}
|
|
|
+.form-buttons .add-button {
|
|
|
+ background-color: #4caf50;
|
|
|
+}
|
|
|
+.form-buttons .add-button:hover {
|
|
|
+ background-color: #43a047;
|
|
|
+ box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3);
|
|
|
+ transform: translateY(-1px);
|
|
|
+}
|
|
|
+.form-buttons .cancel-button {
|
|
|
+ background-color: #9e9e9e;
|
|
|
+}
|
|
|
+.form-buttons .cancel-button:hover {
|
|
|
+ background-color: #757575;
|
|
|
+ box-shadow: 0 4px 8px rgba(158, 158, 158, 0.3);
|
|
|
+ transform: translateY(-1px);
|
|
|
+}
|
|
|
+</style>
|