|
@@ -1,2383 +0,0 @@
|
|
|
-<template>
|
|
|
|
|
- <div class="floating-softphone">
|
|
|
|
|
- <!-- 来电醒目提示(右下角固定,高于面板层级) -->
|
|
|
|
|
- <transition name="call-bubble-fade">
|
|
|
|
|
- <div class="incoming-call-alert"
|
|
|
|
|
- v-if="isIncomingRinging"
|
|
|
|
|
- @click="onIncomingAlertClick">
|
|
|
|
|
- <div class="incoming-call-alert__ripple"></div>
|
|
|
|
|
- <div class="incoming-call-alert__content">
|
|
|
|
|
- <div class="incoming-call-alert__icon-wrap">
|
|
|
|
|
- <i class="material-icons">phone_in_talk</i>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="incoming-call-alert__info">
|
|
|
|
|
- <span class="incoming-call-alert__title">来电响铃中</span>
|
|
|
|
|
- <span class="incoming-call-alert__number">{{ incomingCallDisplayNumber }}</span>
|
|
|
|
|
- <span class="incoming-call-alert__hint">点击卡片打开软电话</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="incoming-call-alert__actions">
|
|
|
|
|
- <button class="incoming-call-alert__btn answer" title="接听" @click.stop="answerIncomingCall">
|
|
|
|
|
- <i class="material-icons">call</i>
|
|
|
|
|
- </button>
|
|
|
|
|
- <button class="incoming-call-alert__btn reject" title="拒接" @click.stop="rejectIncomingCall">
|
|
|
|
|
- <i class="material-icons">call_end</i>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </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">{{ fabIconName }}</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: !showSideCallButtons, normal: callStatus === 'talking' && leftButtonNormal }"
|
|
|
|
|
- @click="onLeftButtonClick"
|
|
|
|
|
- :title="getLeftButtonTitle()">
|
|
|
|
|
- <i class="material-icons">{{ leftButtonIcon }}</i>
|
|
|
|
|
- </button>
|
|
|
|
|
- <button class="call-button call-hangup-button"
|
|
|
|
|
- :class="[getHangupButtonClass(), { hidden: !showCenterCallButton }]"
|
|
|
|
|
- @click="onHangupClick"
|
|
|
|
|
- :title="getHangupButtonTitle()">
|
|
|
|
|
- <i class="material-icons">{{ hangupButtonIcon }}</i>
|
|
|
|
|
- </button>
|
|
|
|
|
- <button class="call-button call-right-button"
|
|
|
|
|
- :class="{ hidden: !showSideCallButtons, normal: callStatus === 'talking' && rightButtonNormal, hangup: isIncomingRinging }"
|
|
|
|
|
- @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="服务(wss://sip.ylrzcloud.com:8443)" 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, IPCC_DEFAULTS, JS_SIP_DEFAULTS } from '@/api/aiSipCall/softPhone.js';
|
|
|
|
|
-import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket.js';
|
|
|
|
|
-import { EventList, VideoLevels, AgentStatusEnum } from '@/assets/callCenterPhoneBarSdk/constants.js';
|
|
|
|
|
-import { myCallUser, getToolbarBasicParam } from '@/api/aiSipCall/aiSipCallUser.js';
|
|
|
|
|
-import { syncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr.js';
|
|
|
|
|
-import {
|
|
|
|
|
- beginCCPhoneBarInit,
|
|
|
|
|
- finishCCPhoneBarInitFailed,
|
|
|
|
|
- finishCCPhoneBarInitSuccess,
|
|
|
|
|
- getConnectedSharedCCPhoneBar,
|
|
|
|
|
- incrementSharedCCPhoneBarRef
|
|
|
|
|
-} from '@/utils/ccPhoneBarShared';
|
|
|
|
|
-import {
|
|
|
|
|
- startIncomingCallAttention,
|
|
|
|
|
- stopIncomingCallAttention,
|
|
|
|
|
- requestIncomingCallNotificationPermission
|
|
|
|
|
-} from '@/utils/incomingCallAttention';
|
|
|
|
|
-
|
|
|
|
|
-// IPCC 和 JsSIP 默认配置已从 softPhone.js 统一导入,此处仅作别名引用
|
|
|
|
|
-const IPCC_CONFIG = IPCC_DEFAULTS;
|
|
|
|
|
-const JS_SIP_CONFIG = JS_SIP_DEFAULTS;
|
|
|
|
|
-
|
|
|
|
|
-const VOLUME_CONFIG = {
|
|
|
|
|
- DEFAULT: 0.8,
|
|
|
|
|
- HIDE_DELAY: 1000
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const UI_STATE = {
|
|
|
|
|
- IDLE: 'idle',
|
|
|
|
|
- RINGING: 'ringing',
|
|
|
|
|
- TALKING: 'talking'
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const FAB_SIZE = 56;
|
|
|
|
|
-const FAB_RING_SIZE = 64;
|
|
|
|
|
-const FAB_EDGE_OFFSET = 8;
|
|
|
|
|
-const FAB_MIN_VISIBLE = 28;
|
|
|
|
|
-const FAB_SAFE_MARGIN = 16;
|
|
|
|
|
-
|
|
|
|
|
-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, // 拖拽过程中是否产生了位移(区分点击和拖拽)
|
|
|
|
|
- fabEdge: 'right', // 贴边方向:left | right
|
|
|
|
|
- fabYRatio: 0.85, // 相对视口高度的纵向位置(0~1)
|
|
|
|
|
- _resizeTimer: null,
|
|
|
|
|
-
|
|
|
|
|
- // ===== 拨号与显示 =====
|
|
|
|
|
- dialNumber: '',
|
|
|
|
|
- callDuration: '00:00',
|
|
|
|
|
- province: '',
|
|
|
|
|
- isContentFit: true,
|
|
|
|
|
-
|
|
|
|
|
- // ===== 网络与注册状态 =====
|
|
|
|
|
- isRegistered: false,
|
|
|
|
|
- isConnected: false,
|
|
|
|
|
- callStatus: UI_STATE.IDLE,
|
|
|
|
|
- isIncomingCall: false, // 振铃阶段是否为来电(非外呼)
|
|
|
|
|
- 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: JS_SIP_CONFIG.SERVER,
|
|
|
|
|
- username: '',
|
|
|
|
|
- domain: JS_SIP_CONFIG.DOMAIN,
|
|
|
|
|
- loginName: '',
|
|
|
|
|
- password: '',
|
|
|
|
|
- transport: JS_SIP_CONFIG.TRANSPORT
|
|
|
|
|
- },
|
|
|
|
|
- showPassword: false,
|
|
|
|
|
-
|
|
|
|
|
- // ===== 设置对话框 =====
|
|
|
|
|
- settingsDialogVisible: false,
|
|
|
|
|
- settingsForm: {
|
|
|
|
|
- userAgent: 'JsSIP',
|
|
|
|
|
- sessionExpires: 180,
|
|
|
|
|
- minSessionExpires: 120,
|
|
|
|
|
- stun: false,
|
|
|
|
|
- iceServer: '',
|
|
|
|
|
- autoAnswer: 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,
|
|
|
|
|
- // 标记当前 ccPhoneBar 是否为共享复用实例(非本组件创建)
|
|
|
|
|
- _isSharedCCPhoneBar: false,
|
|
|
|
|
- ccSocketConnected: false,
|
|
|
|
|
- ccSocketFailed: false,
|
|
|
|
|
- ccConnectingPromise: null,
|
|
|
|
|
- ccConnectingResolve: null,
|
|
|
|
|
- ccConnectingReject: null,
|
|
|
|
|
- // ccPhoneBar 自动重连相关
|
|
|
|
|
- _ccReconnectTimer: null,
|
|
|
|
|
- _ccReconnectAttempts: 0,
|
|
|
|
|
- _ccLoginConflict: false,
|
|
|
|
|
- _initCCPromise: null,
|
|
|
|
|
- _isDestroying: false,
|
|
|
|
|
- _ccEventsBoundFor: null,
|
|
|
|
|
-
|
|
|
|
|
- // ===== 坐席状态 =====
|
|
|
|
|
- isCallingReady: false,
|
|
|
|
|
- isOnHold: false,
|
|
|
|
|
-
|
|
|
|
|
- // ===== 通话记录 =====
|
|
|
|
|
- currentCallUuid: '',
|
|
|
|
|
- callUuidMap: {},
|
|
|
|
|
-
|
|
|
|
|
- // ===== 号码存储与展示 =====
|
|
|
|
|
- plaintextRealNumber: '', // 存储的真实号码(用于拨号)
|
|
|
|
|
- displayText: '', // 展示文本(脱敏格式或原始输入)
|
|
|
|
|
-
|
|
|
|
|
- // ===== 委托通话标记 =====
|
|
|
|
|
- delegatedCallActive: false, // 是否有通话委托给弹窗的phoneBar
|
|
|
|
|
- isOutboundInProgress: false, // 外呼进行中(含 SIP 回振坐席阶段,需自动接听)
|
|
|
|
|
- incomingJsipCall: false, // 是否为JsSIP直接来电(转人工等)
|
|
|
|
|
- pendingManualNavigation: false // 转人工来电结束后是否需要跳转到通话列表
|
|
|
|
|
- };
|
|
|
|
|
- },
|
|
|
|
|
- computed: {
|
|
|
|
|
- // 真实号码(用于拨号)
|
|
|
|
|
- realNumber() {
|
|
|
|
|
- return this.plaintextRealNumber || '';
|
|
|
|
|
- },
|
|
|
|
|
- isEnabled() {
|
|
|
|
|
- return true;
|
|
|
|
|
- },
|
|
|
|
|
- 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
|
|
|
|
|
- };
|
|
|
|
|
- },
|
|
|
|
|
- /** 来电提示显示的号码 */
|
|
|
|
|
- incomingCallDisplayNumber() {
|
|
|
|
|
- if (this.incomingCaller) {
|
|
|
|
|
- return this.maskNumber(this.incomingCaller);
|
|
|
|
|
- }
|
|
|
|
|
- if (this.displayText) {
|
|
|
|
|
- return this.displayText;
|
|
|
|
|
- }
|
|
|
|
|
- return '未知号码';
|
|
|
|
|
- },
|
|
|
|
|
- hangupButtonIcon() {
|
|
|
|
|
- return this.callStatus !== 'idle' ? 'call_end' : 'phone';
|
|
|
|
|
- },
|
|
|
|
|
- /** 来电振铃时不用 close 图标,避免与面板关闭态混淆且吸边时易被裁切 */
|
|
|
|
|
- fabIconName() {
|
|
|
|
|
- if (this.isIncomingRinging) {
|
|
|
|
|
- return 'phone_in_talk';
|
|
|
|
|
- }
|
|
|
|
|
- return this.panelVisible ? 'close' : 'phone';
|
|
|
|
|
- },
|
|
|
|
|
- fabOnLeftEdge() {
|
|
|
|
|
- if (this.fabEdge === 'left') return true;
|
|
|
|
|
- if (this.fabEdge === 'right') return false;
|
|
|
|
|
- if (this.fabX === null) return false;
|
|
|
|
|
- return this.fabX + FAB_SIZE / 2 < this.getViewportSize().width / 2;
|
|
|
|
|
- },
|
|
|
|
|
- /** 来电振铃(左右接听/拒接,隐藏中间按钮) */
|
|
|
|
|
- isIncomingRinging() {
|
|
|
|
|
- return this.callStatus === UI_STATE.RINGING && this.isIncomingCall;
|
|
|
|
|
- },
|
|
|
|
|
- /** 中间按钮:空闲外呼 / 外呼振铃挂断 / 通话中挂断 */
|
|
|
|
|
- showCenterCallButton() {
|
|
|
|
|
- return this.callStatus === UI_STATE.IDLE
|
|
|
|
|
- || (this.callStatus === UI_STATE.RINGING && !this.isIncomingCall)
|
|
|
|
|
- || this.callStatus === UI_STATE.TALKING;
|
|
|
|
|
- },
|
|
|
|
|
- /** 左右侧按钮:来电振铃 或 通话中 */
|
|
|
|
|
- showSideCallButtons() {
|
|
|
|
|
- return this.isIncomingRinging || this.callStatus === UI_STATE.TALKING;
|
|
|
|
|
- },
|
|
|
|
|
- leftButtonIcon() {
|
|
|
|
|
- if (this.isIncomingRinging) 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.isIncomingRinging) 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
|
|
|
|
|
- this.$nextTick(() => this.ensureFabFullyVisible());
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- isIncomingRinging(val) {
|
|
|
|
|
- if (val) {
|
|
|
|
|
- this.fabIsCollapsed = false;
|
|
|
|
|
- this.$nextTick(() => this.ensureFabFullyVisible());
|
|
|
|
|
- startIncomingCallAttention({
|
|
|
|
|
- caller: this.incomingCallDisplayNumber,
|
|
|
|
|
- body: `来电号码:${this.incomingCallDisplayNumber},请尽快接听`
|
|
|
|
|
- });
|
|
|
|
|
- } else {
|
|
|
|
|
- stopIncomingCallAttention();
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- '$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();
|
|
|
|
|
- this._boundViewportResize = this.handleWindowResize.bind(this);
|
|
|
|
|
- window.addEventListener('resize', this._boundViewportResize);
|
|
|
|
|
- if (window.visualViewport) {
|
|
|
|
|
- window.visualViewport.addEventListener('resize', this._boundViewportResize);
|
|
|
|
|
- window.visualViewport.addEventListener('scroll', this._boundViewportResize);
|
|
|
|
|
- }
|
|
|
|
|
- this.$nextTick(() => {
|
|
|
|
|
- if (this.$el && this.$el.parentNode && this.$el.parentNode !== document.body) {
|
|
|
|
|
- document.body.appendChild(this.$el);
|
|
|
|
|
- }
|
|
|
|
|
- this.adjustFabForViewport();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 如果不在原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);
|
|
|
|
|
- // 监听其他组件请求重连 ccPhoneBar 的事件
|
|
|
|
|
- this.$root.$on('cc-phonebar-reconnect-requested', this.onCCPhoneBarReconnectRequested);
|
|
|
|
|
- },
|
|
|
|
|
- beforeDestroy() {
|
|
|
|
|
- stopIncomingCallAttention();
|
|
|
|
|
- this.destroyAllConnections();
|
|
|
|
|
- window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
|
|
|
|
- if (this._boundViewportResize) {
|
|
|
|
|
- window.removeEventListener('resize', this._boundViewportResize);
|
|
|
|
|
- if (window.visualViewport) {
|
|
|
|
|
- window.visualViewport.removeEventListener('resize', this._boundViewportResize);
|
|
|
|
|
- window.visualViewport.removeEventListener('scroll', this._boundViewportResize);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (this._resizeTimer) {
|
|
|
|
|
- clearTimeout(this._resizeTimer);
|
|
|
|
|
- this._resizeTimer = null;
|
|
|
|
|
- }
|
|
|
|
|
- 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);
|
|
|
|
|
- this.$root.$off('cc-phonebar-reconnect-requested', this.onCCPhoneBarReconnectRequested);
|
|
|
|
|
- },
|
|
|
|
|
- methods: {
|
|
|
|
|
- getViewportSize() {
|
|
|
|
|
- const vv = window.visualViewport;
|
|
|
|
|
- return {
|
|
|
|
|
- width: vv ? vv.width : window.innerWidth,
|
|
|
|
|
- height: vv ? vv.height : window.innerHeight
|
|
|
|
|
- };
|
|
|
|
|
- },
|
|
|
|
|
- getFabSize() {
|
|
|
|
|
- return this.callStatus === UI_STATE.RINGING ? FAB_RING_SIZE : FAB_SIZE;
|
|
|
|
|
- },
|
|
|
|
|
- ensureFabFullyVisible() {
|
|
|
|
|
- const fabSize = this.getFabSize();
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- if (this.fabX === null) {
|
|
|
|
|
- this.fabX = width - fabSize - FAB_SAFE_MARGIN;
|
|
|
|
|
- this.fabY = Math.max(0, Math.min(
|
|
|
|
|
- this.fabYRatio != null ? this.fabYRatio * height : height * 0.85,
|
|
|
|
|
- height - fabSize
|
|
|
|
|
- ));
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- this.fabX = Math.max(FAB_SAFE_MARGIN, Math.min(this.fabX, width - fabSize - FAB_SAFE_MARGIN));
|
|
|
|
|
- this.fabY = Math.max(0, Math.min(this.fabY, height - fabSize));
|
|
|
|
|
- this.syncFabRelativeFromAbsolute();
|
|
|
|
|
- },
|
|
|
|
|
- applyFabFromRelative(edge, yRatio) {
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- this.fabEdge = edge === 'left' ? 'left' : 'right';
|
|
|
|
|
- this.fabYRatio = Math.max(0, Math.min(1, yRatio));
|
|
|
|
|
- this.fabX = this.fabEdge === 'left'
|
|
|
|
|
- ? -FAB_EDGE_OFFSET
|
|
|
|
|
- : width - FAB_SIZE + FAB_EDGE_OFFSET;
|
|
|
|
|
- this.fabY = Math.max(0, Math.min(this.fabYRatio * height, height - FAB_SIZE));
|
|
|
|
|
- },
|
|
|
|
|
- syncFabRelativeFromAbsolute() {
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- this.fabEdge = this.fabX + FAB_SIZE / 2 < width / 2 ? 'left' : 'right';
|
|
|
|
|
- this.fabYRatio = height > 0 ? Math.max(0, Math.min(1, this.fabY / height)) : 0.85;
|
|
|
|
|
- },
|
|
|
|
|
- adjustFabForViewport() {
|
|
|
|
|
- if (this.fabX === null && !this.fabEdge) return;
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- if (this.fabIsCollapsed && !this.panelVisible) {
|
|
|
|
|
- const edge = this.fabEdge || (this.fabOnLeftEdge() ? 'left' : 'right');
|
|
|
|
|
- const yRatio = this.fabYRatio != null ? this.fabYRatio : (height > 0 ? this.fabY / height : 0.85);
|
|
|
|
|
- this.applyFabFromRelative(edge, yRatio);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- const fabSize = this.getFabSize();
|
|
|
|
|
- const minX = FAB_SAFE_MARGIN;
|
|
|
|
|
- const maxX = width - fabSize - FAB_SAFE_MARGIN;
|
|
|
|
|
- this.fabX = Math.max(minX, Math.min(this.fabX, maxX));
|
|
|
|
|
- this.fabY = Math.max(0, Math.min(this.fabY, height - fabSize));
|
|
|
|
|
- this.syncFabRelativeFromAbsolute();
|
|
|
|
|
- },
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 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 { width, height } = this.getViewportSize();
|
|
|
|
|
- let x = e.clientX - this.fabDragOffsetX;
|
|
|
|
|
- let y = e.clientY - this.fabDragOffsetY;
|
|
|
|
|
- // 允许超出边缘一半(折叠效果)
|
|
|
|
|
- x = Math.max(-FAB_SIZE / 2, Math.min(x, width - FAB_SIZE / 2));
|
|
|
|
|
- y = Math.max(0, Math.min(y, height - FAB_SIZE));
|
|
|
|
|
- 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 && !this.fabEdge) return;
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- const centerX = this.fabX != null ? this.fabX + FAB_SIZE / 2 : (this.fabEdge === 'left' ? 0 : width);
|
|
|
|
|
- const edge = centerX < width / 2 ? 'left' : 'right';
|
|
|
|
|
- const yRatio = height > 0 ? this.fabY / height : (this.fabYRatio || 0.85);
|
|
|
|
|
- this.applyFabFromRelative(edge, yRatio);
|
|
|
|
|
- // 面板打开时不折叠,避免影响操作
|
|
|
|
|
- this.fabIsCollapsed = !this.panelVisible;
|
|
|
|
|
- },
|
|
|
|
|
- onIncomingAlertClick() {
|
|
|
|
|
- this.panelVisible = true;
|
|
|
|
|
- this.fabIsCollapsed = false;
|
|
|
|
|
- },
|
|
|
|
|
- answerIncomingCall() {
|
|
|
|
|
- if (this.phone) {
|
|
|
|
|
- this.phone.Answer();
|
|
|
|
|
- }
|
|
|
|
|
- this.panelVisible = true;
|
|
|
|
|
- this.fabIsCollapsed = false;
|
|
|
|
|
- },
|
|
|
|
|
- rejectIncomingCall() {
|
|
|
|
|
- this.endCall();
|
|
|
|
|
- },
|
|
|
|
|
- onFabClick(e) {
|
|
|
|
|
- // 用户点击软电话时尝试申请桌面通知权限
|
|
|
|
|
- requestIncomingCallNotificationPermission();
|
|
|
|
|
- // 如果拖拽过程中产生了位移,不触发点击
|
|
|
|
|
- if (this.fabDragMoved) {
|
|
|
|
|
- this.fabDragMoved = false;
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- // 折叠状态:展开并打开面板
|
|
|
|
|
- if (this.fabIsCollapsed) {
|
|
|
|
|
- this.fabIsCollapsed = false;
|
|
|
|
|
- this.panelVisible = true;
|
|
|
|
|
- this.$nextTick(() => {
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- this.fabX = this.fabEdge === 'left' ? 16 : width - FAB_SIZE - 16;
|
|
|
|
|
- this.fabY = Math.max(0, Math.min(this.fabYRatio * height, height - FAB_SIZE));
|
|
|
|
|
- });
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- this.togglePanel();
|
|
|
|
|
- },
|
|
|
|
|
- saveFabPosition() {
|
|
|
|
|
- if (this.fabX === null) return;
|
|
|
|
|
- this.syncFabRelativeFromAbsolute();
|
|
|
|
|
- localStorage.setItem('FloatingSoftPhoneFabPosition', JSON.stringify({
|
|
|
|
|
- edge: this.fabEdge,
|
|
|
|
|
- yRatio: this.fabYRatio,
|
|
|
|
|
- x: this.fabX,
|
|
|
|
|
- y: this.fabY
|
|
|
|
|
- }));
|
|
|
|
|
- },
|
|
|
|
|
- loadFabPosition() {
|
|
|
|
|
- try {
|
|
|
|
|
- const saved = JSON.parse(localStorage.getItem('FloatingSoftPhoneFabPosition'));
|
|
|
|
|
- if (saved) {
|
|
|
|
|
- if (saved.edge != null && saved.yRatio != null) {
|
|
|
|
|
- this.applyFabFromRelative(saved.edge, saved.yRatio);
|
|
|
|
|
- } else if (saved.x != null && saved.y != null) {
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- const edge = (saved.x + FAB_SIZE / 2) < width / 2 ? 'left' : 'right';
|
|
|
|
|
- const yRatio = height > 0 ? Math.max(0, Math.min(1, saved.y / height)) : 0.85;
|
|
|
|
|
- this.applyFabFromRelative(edge, yRatio);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } catch (e) { /* ignore */ }
|
|
|
|
|
- if (this.fabX === null) {
|
|
|
|
|
- this.applyFabFromRelative('right', 0.85);
|
|
|
|
|
- } else {
|
|
|
|
|
- this.adjustFabForViewport();
|
|
|
|
|
- }
|
|
|
|
|
- // 初始加载时折叠贴边
|
|
|
|
|
- this.fabIsCollapsed = true;
|
|
|
|
|
- },
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 外部拨号接口 ====================
|
|
|
|
|
- /**
|
|
|
|
|
- * 外部调用拨号:存储号码并脱敏展示
|
|
|
|
|
- * 号码由调用方通过服务端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;
|
|
|
|
|
- this.$nextTick(() => this.adjustFabForViewport());
|
|
|
|
|
- },
|
|
|
|
|
- hidePanel() {
|
|
|
|
|
- if (this.callStatus !== 'idle') {
|
|
|
|
|
- this.$message.warning('通话中无法收起软电话');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- this.panelVisible = false;
|
|
|
|
|
- this.fabIsCollapsed = this.fabX !== null;
|
|
|
|
|
- this.$nextTick(() => this.adjustFabForViewport());
|
|
|
|
|
- },
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 拖拽功能 ====================
|
|
|
|
|
- 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;
|
|
|
|
|
- const { width, height } = this.getViewportSize();
|
|
|
|
|
- let x = e.clientX - this.dragOffsetX;
|
|
|
|
|
- let y = e.clientY - this.dragOffsetY;
|
|
|
|
|
- const maxX = width - panel.offsetWidth;
|
|
|
|
|
- const maxY = height - 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 { width, height } = this.getViewportSize();
|
|
|
|
|
- const maxX = width - panel.offsetWidth;
|
|
|
|
|
- const maxY = height - panel.offsetHeight;
|
|
|
|
|
- this.panelX = Math.max(0, Math.min(this.panelX, maxX));
|
|
|
|
|
- this.panelY = Math.max(0, Math.min(this.panelY, maxY));
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
- handleWindowResize() {
|
|
|
|
|
- if (this._resizeTimer) clearTimeout(this._resizeTimer);
|
|
|
|
|
- this._resizeTimer = setTimeout(() => {
|
|
|
|
|
- this.clampPosition();
|
|
|
|
|
- this.adjustFabForViewport();
|
|
|
|
|
- }, 80);
|
|
|
|
|
- },
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 号码管理 ====================
|
|
|
|
|
- /** 生成脱敏格式:前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.isIncomingRinging) 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.isIncomingRinging) 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() {
|
|
|
|
|
- if (this._ccLoginConflict) {
|
|
|
|
|
- this.showStatus('账号已在其他窗口登录,请关闭多余软电话后点击重新连接', 'error');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if (this._initCCPromise) {
|
|
|
|
|
- return this._initCCPromise;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const shared = getConnectedSharedCCPhoneBar();
|
|
|
|
|
- if (shared) {
|
|
|
|
|
- this._isDestroying = false;
|
|
|
|
|
- if (this.ccPhoneBar !== shared) {
|
|
|
|
|
- this.ccPhoneBar = shared;
|
|
|
|
|
- this._isSharedCCPhoneBar = true;
|
|
|
|
|
- incrementSharedCCPhoneBarRef();
|
|
|
|
|
- }
|
|
|
|
|
- this.ccSocketConnected = true;
|
|
|
|
|
- this.ccSocketFailed = false;
|
|
|
|
|
- this._bindCCEvents();
|
|
|
|
|
- if (!this.phone || !this.isRegistered) {
|
|
|
|
|
- await this.startPhone();
|
|
|
|
|
- }
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- this._initCCPromise = (async () => {
|
|
|
|
|
- this._isDestroying = false;
|
|
|
|
|
- this.ccSocketFailed = false;
|
|
|
|
|
- this.isCallingReady = false;
|
|
|
|
|
- try {
|
|
|
|
|
- await this.ensureCCSocketConnect();
|
|
|
|
|
- await this.startPhone();
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- this.ccSocketFailed = true;
|
|
|
|
|
- this.showStatus(`初始化失败: ${err.message}`, 'error');
|
|
|
|
|
- throw err;
|
|
|
|
|
- }
|
|
|
|
|
- })();
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- await this._initCCPromise;
|
|
|
|
|
- } finally {
|
|
|
|
|
- this._initCCPromise = null;
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- 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 shared = getConnectedSharedCCPhoneBar();
|
|
|
|
|
- if (shared) {
|
|
|
|
|
- console.log('[FloatingSoftPhone] 检测到已连接的共享 ccPhoneBar,直接复用');
|
|
|
|
|
- this.ccPhoneBar = shared;
|
|
|
|
|
- this._isSharedCCPhoneBar = true;
|
|
|
|
|
- this.ccSocketConnected = true;
|
|
|
|
|
- this.ccSocketFailed = false;
|
|
|
|
|
- this.isCallingReady = true;
|
|
|
|
|
- incrementSharedCCPhoneBarRef();
|
|
|
|
|
- this._bindCCEvents();
|
|
|
|
|
- if (this.ccConnectingResolve) this.ccConnectingResolve();
|
|
|
|
|
- this.ccConnectingPromise = null;
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- beginCCPhoneBarInit();
|
|
|
|
|
-
|
|
|
|
|
- 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('登录令牌无效');
|
|
|
|
|
-
|
|
|
|
|
- // 将 loginToken 拼接到 SIP 服务器地址上(去掉旧的 loginToken 再拼接新的)
|
|
|
|
|
- if (this.currentUserId && this.userList[this.currentUserId]) {
|
|
|
|
|
- const baseUrl = this.userList[this.currentUserId].server.split('?')[0];
|
|
|
|
|
- this.userList[this.currentUserId].server = `${baseUrl}?loginToken=${configData.loginToken}`;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- 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._isSharedCCPhoneBar = false;
|
|
|
|
|
- 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) {
|
|
|
|
|
- finishCCPhoneBarInitFailed(err);
|
|
|
|
|
- if (this.ccConnectingReject) this.ccConnectingReject(err);
|
|
|
|
|
- this.ccConnectingPromise = null;
|
|
|
|
|
- throw err;
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- _bindCCEvents() {
|
|
|
|
|
- if (this._ccEventsBoundFor === this.ccPhoneBar) return;
|
|
|
|
|
- this._ccEventsBoundFor = this.ccPhoneBar;
|
|
|
|
|
- this.ccPhoneBar.on(EventList.WS_CONNECTED, () => {
|
|
|
|
|
- finishCCPhoneBarInitSuccess(this.ccPhoneBar);
|
|
|
|
|
- window.__sharedCCPhoneBar = this.ccPhoneBar;
|
|
|
|
|
- window.__sharedCCPhoneBarRefCount = 1;
|
|
|
|
|
- this.ccSocketConnected = true;
|
|
|
|
|
- this.ccSocketFailed = false;
|
|
|
|
|
- this._ccLoginConflict = false;
|
|
|
|
|
- this._ccReconnectAttempts = 0;
|
|
|
|
|
- if (this.ccConnectingResolve) this.ccConnectingResolve();
|
|
|
|
|
- this.ccConnectingPromise = null;
|
|
|
|
|
- this.$root.$emit('cc-phonebar-reconnected', this.ccPhoneBar);
|
|
|
|
|
- console.log('[FloatingSoftPhone] ccPhoneBar 连接成功,已注册为共享实例');
|
|
|
|
|
- });
|
|
|
|
|
- this.ccPhoneBar.on(EventList.USER_LOGIN_ON_OTHER_DEVICE, (msg) => {
|
|
|
|
|
- console.warn('[FloatingSoftPhone] 检测到重复登录 status:201', msg);
|
|
|
|
|
- this._ccLoginConflict = true;
|
|
|
|
|
- this._ccReconnectAttempts = 999;
|
|
|
|
|
- if (this._ccReconnectTimer) {
|
|
|
|
|
- clearTimeout(this._ccReconnectTimer);
|
|
|
|
|
- this._ccReconnectTimer = null;
|
|
|
|
|
- }
|
|
|
|
|
- finishCCPhoneBarInitFailed(new Error('user_logined_on_other_device'));
|
|
|
|
|
- this.showStatus('软电话已在其他窗口登录,请关闭多余页面后重新连接', 'error');
|
|
|
|
|
- });
|
|
|
|
|
- this.ccPhoneBar.on(EventList.WS_DISCONNECTED, () => {
|
|
|
|
|
- console.log('[FloatingSoftPhone] ccPhoneBar WebSocket 断开');
|
|
|
|
|
- this.ccSocketConnected = false;
|
|
|
|
|
- this.isCallingReady = false;
|
|
|
|
|
- if (this.phone && this.isRegistered) this.showStatus('连接断开', 'warn');
|
|
|
|
|
- if (!this._isDestroying && !this._ccLoginConflict) {
|
|
|
|
|
- this._scheduleCCReconnect();
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
- 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.isOutboundInProgress = true;
|
|
|
|
|
- this.isIncomingCall = false;
|
|
|
|
|
- this.callStatus = UI_STATE.RINGING;
|
|
|
|
|
- this.showLeftButton = false;
|
|
|
|
|
- this.showRightButton = false;
|
|
|
|
|
- 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.isIncomingCall = false;
|
|
|
|
|
- this.callStatus = UI_STATE.RINGING;
|
|
|
|
|
- this.showLeftButton = false;
|
|
|
|
|
- this.showRightButton = false;
|
|
|
|
|
- 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.isIncomingCall = false;
|
|
|
|
|
- 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');
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
- /**
|
|
|
|
|
- * ccPhoneBar 断开后自动重连
|
|
|
|
|
- * 使用指数退避策略,最多重试 5 次,避免无限重连消耗资源
|
|
|
|
|
- */
|
|
|
|
|
- _scheduleCCReconnect() {
|
|
|
|
|
- if (this._ccLoginConflict) return;
|
|
|
|
|
- if (this._ccReconnectTimer) clearTimeout(this._ccReconnectTimer);
|
|
|
|
|
- const maxAttempts = 5;
|
|
|
|
|
- if (this._ccReconnectAttempts >= maxAttempts) {
|
|
|
|
|
- console.log('[FloatingSoftPhone] ccPhoneBar 自动重连已达最大次数,请手动重连');
|
|
|
|
|
- this.showStatus('重连失败次数过多,请点击菜单"重新连接"', 'error');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- this._ccReconnectAttempts++;
|
|
|
|
|
- const delay = Math.min(3000 * this._ccReconnectAttempts, 15000);
|
|
|
|
|
- console.log(`[FloatingSoftPhone] ccPhoneBar 将在 ${delay/1000}s 后自动重连 (${this._ccReconnectAttempts}/${maxAttempts})`);
|
|
|
|
|
- this.showStatus(`${delay/1000}s 后重连...(${this._ccReconnectAttempts}/${maxAttempts})`, 'info');
|
|
|
|
|
- this._ccReconnectTimer = setTimeout(async () => {
|
|
|
|
|
- if (this._isDestroying || this.ccSocketConnected) return;
|
|
|
|
|
- try {
|
|
|
|
|
- // 清理旧的共享实例引用(旧实例已断开,不再可用)
|
|
|
|
|
- if (window.__sharedCCPhoneBar === this.ccPhoneBar) {
|
|
|
|
|
- window.__sharedCCPhoneBar = null;
|
|
|
|
|
- window.__sharedCCPhoneBarRefCount = 0;
|
|
|
|
|
- }
|
|
|
|
|
- if (this.ccPhoneBar) {
|
|
|
|
|
- try { this.ccPhoneBar.disconnect(); } catch (e) { /* ignore */ }
|
|
|
|
|
- this.ccPhoneBar = null;
|
|
|
|
|
- }
|
|
|
|
|
- this.ccConnectingPromise = null;
|
|
|
|
|
- this.ccConnectingResolve = null;
|
|
|
|
|
- this.ccConnectingReject = null;
|
|
|
|
|
- // 重新初始化
|
|
|
|
|
- await this.initCCAndStart();
|
|
|
|
|
- console.log('[FloatingSoftPhone] ccPhoneBar 自动重连成功');
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error('[FloatingSoftPhone] ccPhoneBar 自动重连失败:', err.message);
|
|
|
|
|
- // 继续尝试重连
|
|
|
|
|
- this._scheduleCCReconnect();
|
|
|
|
|
- }
|
|
|
|
|
- }, delay);
|
|
|
|
|
- },
|
|
|
|
|
- /**
|
|
|
|
|
- * 其他组件请求重连 ccPhoneBar 的回调
|
|
|
|
|
- * 如果当前已连接则忽略,否则立即触发重连(重置重连计数以加快重试)
|
|
|
|
|
- */
|
|
|
|
|
- onCCPhoneBarReconnectRequested() {
|
|
|
|
|
- if (this.ccPhoneBar && this.ccPhoneBar.getIsConnected()) return;
|
|
|
|
|
- console.log('[FloatingSoftPhone] 收到 ccPhoneBar 重连请求');
|
|
|
|
|
- // 重置重连计数,立即开始重连
|
|
|
|
|
- this._ccReconnectAttempts = 0;
|
|
|
|
|
- this._scheduleCCReconnect();
|
|
|
|
|
- },
|
|
|
|
|
- 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: JS_SIP_CONFIG.DOMAIN,
|
|
|
|
|
- server: JS_SIP_CONFIG.SERVER,
|
|
|
|
|
- transport: JS_SIP_CONFIG.TRANSPORT
|
|
|
|
|
- };
|
|
|
|
|
- this.profileManager.updateUser(existingUserId, updatedProfile);
|
|
|
|
|
- if (this.currentUserId !== existingUserId) {
|
|
|
|
|
- this.profileManager.switchUser(existingUserId);
|
|
|
|
|
- this.currentUserId = existingUserId;
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- const newProfile = {
|
|
|
|
|
- note: extNum, user: extNum, domain: JS_SIP_CONFIG.DOMAIN,
|
|
|
|
|
- password: extPass, display_name: extNum,
|
|
|
|
|
- server: JS_SIP_CONFIG.SERVER, transport: JS_SIP_CONFIG.TRANSPORT
|
|
|
|
|
- };
|
|
|
|
|
- this.profileManager.addUser(newProfile);
|
|
|
|
|
- const updatedProfile = this.profileManager.getProfile();
|
|
|
|
|
- this.userList = updatedProfile.users;
|
|
|
|
|
- for (const [id, user] of Object.entries(this.userList)) {
|
|
|
|
|
- if (user.user === extNum) { this.currentUserId = id; this.profileManager.switchUser(id); break; }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (this.currentUserId && this.userList[this.currentUserId]) {
|
|
|
|
|
- this.currentUserDisplay = this.userList[this.currentUserId].note || this.userList[this.currentUserId].user;
|
|
|
|
|
- }
|
|
|
|
|
- const profile = this.profileManager.getProfile();
|
|
|
|
|
- this.speakerVolume = profile.speaker_volume ?? VOLUME_CONFIG.DEFAULT;
|
|
|
|
|
- this.micVolume = profile.mic_volume ?? VOLUME_CONFIG.DEFAULT;
|
|
|
|
|
- this.isSpeakerMuted = profile.speaker_paused || false;
|
|
|
|
|
- this.isMicMuted = profile.mic_paused || false;
|
|
|
|
|
- },
|
|
|
|
|
- async startPhone() {
|
|
|
|
|
- if (!this.ccSocketConnected) { 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) {
|
|
|
|
|
- // 外呼时呼叫中心回振坐席 SIP,自动接听,不展示来电 UI
|
|
|
|
|
- if (this.isOutboundInProgress) {
|
|
|
|
|
- if (this.phone) this.phone.Answer();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- this.isIncomingCall = true;
|
|
|
|
|
- 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.isIncomingCall = false;
|
|
|
|
|
- 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.isIncomingCall = false;
|
|
|
|
|
- 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.isOutboundInProgress = false;
|
|
|
|
|
- this.incomingJsipCall = false;
|
|
|
|
|
- this.incomingCaller = '';
|
|
|
|
|
- // 注意:不在_resetCallState中重置pendingManualNavigation,它由onSessionClosed消费
|
|
|
|
|
- },
|
|
|
|
|
- onSessionClosed(event) {
|
|
|
|
|
- // 转人工来电(incomingJsipCall)或主动设置的导航标记,挂断后都跳转
|
|
|
|
|
- const isTestRejected = event && event.reason === 'test_rejected';
|
|
|
|
|
- const shouldNavigate = !isTestRejected && (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(payload) {
|
|
|
|
|
- const incoming = !!(payload && payload.incoming);
|
|
|
|
|
- this.isIncomingCall = incoming;
|
|
|
|
|
- this.callStatus = UI_STATE.RINGING;
|
|
|
|
|
- this.showLeftButton = incoming;
|
|
|
|
|
- this.showRightButton = incoming;
|
|
|
|
|
- this.rightButtonHangup = incoming;
|
|
|
|
|
- this.delegatedCallActive = true;
|
|
|
|
|
- this.showStatus(incoming ? '来电振铃中...' : '振铃中...', 'info');
|
|
|
|
|
- if (incoming && this.phone) {
|
|
|
|
|
- this.phone.playIncomingRing();
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- onDialogCallTalking() {
|
|
|
|
|
- if (this.phone) {
|
|
|
|
|
- this.phone.pauseIncomingRing();
|
|
|
|
|
- }
|
|
|
|
|
- this.isIncomingCall = false;
|
|
|
|
|
- this.callStatus = UI_STATE.TALKING;
|
|
|
|
|
- this.showLeftButton = true;
|
|
|
|
|
- this.showRightButton = true;
|
|
|
|
|
- this.leftButtonNormal = true;
|
|
|
|
|
- this.rightButtonNormal = true;
|
|
|
|
|
- this.rightButtonHangup = false;
|
|
|
|
|
- this.showStatus('通话中', 'success');
|
|
|
|
|
- },
|
|
|
|
|
- onDialogCallEnded() {
|
|
|
|
|
- if (this.phone) {
|
|
|
|
|
- this.phone.pauseIncomingRing();
|
|
|
|
|
- }
|
|
|
|
|
- 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; }
|
|
|
|
|
- // 检查 ccPhoneBar 是否已连接,未连接时触发重连
|
|
|
|
|
- if (!this.ccPhoneBar || !this.ccPhoneBar.getIsConnected()) {
|
|
|
|
|
- this.showStatus('电话未连接,正在重连...', 'warn');
|
|
|
|
|
- this._scheduleCCReconnect();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if (!this.isCallingReady) {
|
|
|
|
|
- this.showStatus('坐席未就绪,请稍候', 'warn');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 人工外呼弹窗打开时,委托弹窗 phoneBar(含通话记录、工作流等业务逻辑)
|
|
|
|
|
- if (window.__floatingPhoneCallDelegateActive) {
|
|
|
|
|
- this.isOutboundInProgress = true;
|
|
|
|
|
- this.$root.$emit('floating-softphone-call-triggered', phoneNumber);
|
|
|
|
|
- this.delegatedCallActive = true;
|
|
|
|
|
- this.showStatus('拨号中...', 'info');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 普通页面:使用浮动软电话自身的 ccPhoneBar 直接外呼
|
|
|
|
|
- try {
|
|
|
|
|
- this.delegatedCallActive = false;
|
|
|
|
|
- this.isOutboundInProgress = true;
|
|
|
|
|
- this.ccPhoneBar.call(phoneNumber, 'audio', VideoLevels.HD.levelId);
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- this.isOutboundInProgress = false;
|
|
|
|
|
- console.error('[FloatingSoftPhone] 外呼失败:', err);
|
|
|
|
|
- this.showStatus('拨号失败', 'error');
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- 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.isIncomingRinging) {
|
|
|
|
|
- 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.isIncomingRinging) 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: JS_SIP_CONFIG.SERVER, username: '', domain: JS_SIP_CONFIG.DOMAIN, loginName: '', password: '', transport: JS_SIP_CONFIG.TRANSPORT };
|
|
|
|
|
- this.showPassword = false;
|
|
|
|
|
- this.accountDialogVisible = true;
|
|
|
|
|
- },
|
|
|
|
|
- openEditAccountDialog() {
|
|
|
|
|
- const profile = this.profileManager.getCurrentUserProfile();
|
|
|
|
|
- if (!profile) return;
|
|
|
|
|
- this.isEditMode = true;
|
|
|
|
|
- this.editingUserId = this.currentUserId;
|
|
|
|
|
- this.accountDialogTitle = '编辑账号';
|
|
|
|
|
- this.accountForm = {
|
|
|
|
|
- note: profile.note || profile.display_name,
|
|
|
|
|
- server: profile.server,
|
|
|
|
|
- username: profile.display_name,
|
|
|
|
|
- domain: profile.domain,
|
|
|
|
|
- loginName: profile.user,
|
|
|
|
|
- password: profile.password,
|
|
|
|
|
- transport: profile.transport || 'wss'
|
|
|
|
|
- };
|
|
|
|
|
- this.showPassword = false;
|
|
|
|
|
- this.accountDialogVisible = true;
|
|
|
|
|
- },
|
|
|
|
|
- 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');
|
|
|
|
|
- this._ccLoginConflict = false;
|
|
|
|
|
- // 清除自动重连定时器和计数
|
|
|
|
|
- if (this._ccReconnectTimer) { clearTimeout(this._ccReconnectTimer); this._ccReconnectTimer = null; }
|
|
|
|
|
- this._ccReconnectAttempts = 0;
|
|
|
|
|
- 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) {
|
|
|
|
|
- if (window.__sharedCCPhoneBar === this.ccPhoneBar) {
|
|
|
|
|
- window.__sharedCCPhoneBar = null;
|
|
|
|
|
- window.__sharedCCPhoneBarRefCount = 0;
|
|
|
|
|
- }
|
|
|
|
|
- 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._isDestroying = true;
|
|
|
|
|
- // 清除自动重连定时器
|
|
|
|
|
- if (this._ccReconnectTimer) { clearTimeout(this._ccReconnectTimer); this._ccReconnectTimer = null; }
|
|
|
|
|
- this._ccReconnectAttempts = 0;
|
|
|
|
|
- this.clearAllTimers();
|
|
|
|
|
- if (this.phone) { try { this.phone.destroy(); } catch(e) {} this.phone = null; }
|
|
|
|
|
- if (this.ccPhoneBar) {
|
|
|
|
|
- // 递减引用计数
|
|
|
|
|
- if (window.__sharedCCPhoneBarRefCount > 0) {
|
|
|
|
|
- window.__sharedCCPhoneBarRefCount--;
|
|
|
|
|
- }
|
|
|
|
|
- if (this._isSharedCCPhoneBar) {
|
|
|
|
|
- // 复用者:只需递减引用计数,不断开连接
|
|
|
|
|
- console.log('[销毁] ccPhoneBar 是共享实例,跳过断开,剩余引用:', window.__sharedCCPhoneBarRefCount);
|
|
|
|
|
- } else if (window.__sharedCCPhoneBarRefCount > 0) {
|
|
|
|
|
- // 创建者:仍有其他组件在使用,不断开连接
|
|
|
|
|
- console.log('[销毁] ccPhoneBar 仍有其他组件引用,跳过断开,剩余引用:', window.__sharedCCPhoneBarRefCount);
|
|
|
|
|
- } else {
|
|
|
|
|
- // 创建者:无其他组件引用,安全断开
|
|
|
|
|
- try { this.ccPhoneBar.disconnect(); } catch(e) {}
|
|
|
|
|
- if (window.__sharedCCPhoneBar === this.ccPhoneBar) {
|
|
|
|
|
- window.__sharedCCPhoneBar = null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- this.ccPhoneBar = null;
|
|
|
|
|
- this._ccEventsBoundFor = null;
|
|
|
|
|
- }
|
|
|
|
|
- this.callUuidMap = {};
|
|
|
|
|
- this.currentCallUuid = '';
|
|
|
|
|
- this.resetAllStates();
|
|
|
|
|
- // 通知其他组件共享实例已销毁
|
|
|
|
|
- this.$root.$emit('cc-phonebar-destroyed');
|
|
|
|
|
- },
|
|
|
|
|
- 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;
|
|
|
|
|
- opacity: 1 !important;
|
|
|
|
|
- width: 64px !important;
|
|
|
|
|
- height: 64px !important;
|
|
|
|
|
- background: #f44336 !important;
|
|
|
|
|
- box-shadow: 0 6px 20px rgba(244, 67, 54, 0.55) !important;
|
|
|
|
|
-}
|
|
|
|
|
-@keyframes fab-ringing-pulse {
|
|
|
|
|
- 0% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.7); }
|
|
|
|
|
- 70% { box-shadow: 0 0 0 20px rgba(244, 67, 54, 0); }
|
|
|
|
|
- 100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); }
|
|
|
|
|
-}
|
|
|
|
|
-.softphone-fab.fab-ringing .material-icons {
|
|
|
|
|
- font-size: 32px;
|
|
|
|
|
-}
|
|
|
|
|
-.softphone-fab.fab-ringing .fab-badge {
|
|
|
|
|
- width: 14px;
|
|
|
|
|
- height: 14px;
|
|
|
|
|
- top: 6px;
|
|
|
|
|
- right: 6px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/* ===== 来电醒目提示卡片 ===== */
|
|
|
|
|
-.incoming-call-alert {
|
|
|
|
|
- position: fixed;
|
|
|
|
|
- right: 24px;
|
|
|
|
|
- bottom: 100px;
|
|
|
|
|
- z-index: 10002;
|
|
|
|
|
- min-width: 300px;
|
|
|
|
|
- max-width: calc(100vw - 48px);
|
|
|
|
|
- box-sizing: border-box;
|
|
|
|
|
- background: linear-gradient(135deg, #ff5252 0%, #d32f2f 100%);
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- border-radius: 16px;
|
|
|
|
|
- padding: 14px 20px 14px 16px;
|
|
|
|
|
- box-shadow: 0 8px 32px rgba(211, 47, 47, 0.45), 0 0 0 2px rgba(255, 255, 255, 0.25);
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- overflow: visible;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__ripple {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- inset: 0;
|
|
|
|
|
- border-radius: inherit;
|
|
|
|
|
- box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.35);
|
|
|
|
|
- animation: incoming-alert-ripple 1.5s ease-out infinite;
|
|
|
|
|
- pointer-events: none;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__content {
|
|
|
|
|
- position: relative;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- gap: 12px;
|
|
|
|
|
- animation: incoming-alert-shake 2s ease-in-out infinite;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__icon-wrap {
|
|
|
|
|
- width: 48px;
|
|
|
|
|
- height: 48px;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- background: rgba(255, 255, 255, 0.2);
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__icon-wrap .material-icons {
|
|
|
|
|
- font-size: 28px;
|
|
|
|
|
- animation: incoming-alert-icon-ring 0.6s ease-in-out infinite alternate;
|
|
|
|
|
-}
|
|
|
|
|
-@keyframes incoming-alert-icon-ring {
|
|
|
|
|
- from { transform: rotate(-12deg); }
|
|
|
|
|
- to { transform: rotate(12deg); }
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__info {
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- min-width: 0;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- gap: 2px;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__title {
|
|
|
|
|
- font-size: 16px;
|
|
|
|
|
- font-weight: 700;
|
|
|
|
|
- letter-spacing: 0.5px;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__number {
|
|
|
|
|
- font-size: 20px;
|
|
|
|
|
- font-weight: 700;
|
|
|
|
|
- letter-spacing: 1px;
|
|
|
|
|
- line-height: 1.2;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__hint {
|
|
|
|
|
- font-size: 11px;
|
|
|
|
|
- opacity: 0.85;
|
|
|
|
|
- margin-top: 2px;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__actions {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- gap: 8px;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
- padding-right: 2px;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn {
|
|
|
|
|
- width: 44px;
|
|
|
|
|
- height: 44px;
|
|
|
|
|
- border: none;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
- transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn .material-icons {
|
|
|
|
|
- font-size: 22px;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn.answer {
|
|
|
|
|
- background: #4caf50;
|
|
|
|
|
- box-shadow: 0 4px 12px rgba(76, 175, 80, 0.5);
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn.reject {
|
|
|
|
|
- background: rgba(0, 0, 0, 0.25);
|
|
|
|
|
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn:hover {
|
|
|
|
|
- transform: scale(1.08);
|
|
|
|
|
-}
|
|
|
|
|
-.incoming-call-alert__btn:active {
|
|
|
|
|
- transform: scale(0.95);
|
|
|
|
|
-}
|
|
|
|
|
-@keyframes incoming-alert-ripple {
|
|
|
|
|
- 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.45); }
|
|
|
|
|
- 70% { box-shadow: 0 0 0 14px rgba(255, 255, 255, 0); }
|
|
|
|
|
- 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
|
|
|
|
-}
|
|
|
|
|
-@keyframes incoming-alert-shake {
|
|
|
|
|
- 0%, 100% { transform: translateY(0); }
|
|
|
|
|
- 50% { transform: translateY(-2px); }
|
|
|
|
|
-}
|
|
|
|
|
-/* 气泡过渡 */
|
|
|
|
|
-.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>
|
|
|