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