| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802 |
- <template>
- <el-dialog
- :visible.sync="dialogVisible"
- :width="awaitingGatewaySelect ? '560px' : '440px'"
- append-to-body
- :close-on-click-modal="false"
- :close-on-press-escape="false"
- :show-close="canManualClose"
- :before-close="beforeDialogClose"
- @opened="onDialogOpened"
- @close="handleClose"
- custom-class="call-phone-dialog"
- >
- <template slot="title">
- <div class="dialog-title">
- <svg class="title-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
- </svg>
- <span>一键外呼</span>
- </div>
- </template>
- <div class="call-content">
- <!-- 横向进度条 -->
- <div class="progress-bar-container">
- <div class="progress-track">
- <div class="progress-fill" :style="{ width: progressPercent + '%' }" />
- </div>
- <div class="progress-steps">
- <div
- v-for="(step, index) in flowSteps"
- :key="step.key"
- class="progress-step"
- :class="'step-' + step.status"
- >
- <div class="step-dot">
- <span class="step-num" v-if="step.status === 'pending'">{{ index + 1 }}</span>
- <i class="el-icon-loading" v-else-if="step.status === 'loading'"></i>
- <svg v-else-if="step.status === 'success'" class="step-check" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3">
- <polyline points="20 6 9 17 4 12"/>
- </svg>
- <svg v-else-if="step.status === 'error'" class="step-cross" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3">
- <line x1="18" y1="6" x2="6" y2="18"/>
- <line x1="6" y1="6" x2="18" y2="18"/>
- </svg>
- </div>
- <span class="step-label">{{ step.title }}</span>
- </div>
- </div>
- </div>
- <!-- 错误提示与重试 -->
- <div class="error-bar" v-if="hasStepError && globalErrorMsg">
- <i class="el-icon-warning" />
- <span>{{ globalErrorMsg }}</span>
- <button class="retry-btn" @click="retryInit" v-if="callState === 'idle'">重试</button>
- </div>
- <!-- 线路选择(必须选择后才能继续) -->
- <div class="gateway-picker" v-if="awaitingGatewaySelect" v-loading="gatewayListLoading">
- <div class="gateway-picker-head">
- <div class="gateway-picker-title">选择外呼线路</div>
- <div class="gateway-picker-sub">请选择线路后再继续外呼,未选择无法发起通话</div>
- </div>
- <div class="gateway-list">
- <div
- v-for="item in gatewayList"
- :key="item.id"
- class="gateway-card"
- :class="{
- selected: String(selectedGatewayId) === String(item.id),
- ['theme-' + getGatewayBadge(item).theme]: true
- }"
- @click="selectGateway(item.id)"
- >
- <div class="gateway-card-main">
- <div class="gateway-name">{{ item.gwDesc || item.gwName || ('线路 ' + item.id) }}</div>
- <div class="gateway-meta" v-if="item.caller || item.gwName">
- <span v-if="item.gwName && item.gwDesc !== item.gwName">{{ item.gwName }}</span>
- <span v-if="item.caller">主叫 {{ item.caller }}</span>
- </div>
- </div>
- <div class="gateway-side">
- <span class="gateway-badge">{{ getGatewayBadge(item).text }}</span>
- <i class="el-icon-check gateway-check" v-if="String(selectedGatewayId) === String(item.id)"></i>
- </div>
- </div>
- </div>
- <div class="gateway-actions">
- <div class="gateway-selected-tip" v-if="selectedGatewayTitle">
- 已选:{{ selectedGatewayTitle }}
- </div>
- <button
- class="gateway-confirm-btn"
- :disabled="!selectedGatewayId || gatewayConfirming"
- @click="confirmGatewaySelect"
- >
- <i class="el-icon-loading" v-if="gatewayConfirming"></i>
- <span>{{ gatewayConfirming ? '连接中...' : '确认线路并外呼' }}</span>
- </button>
- </div>
- </div>
- <!-- 外呼号码显示区域 -->
- <div class="call-area" v-if="isReady && !awaitingGatewaySelect">
- <div class="phone-number">{{ maskedPhone }}</div>
- <!-- 电话图标 + 动画 -->
- <div class="phone-icon-wrap" :class="iconAnimClass">
- <div class="phone-icon-bg">
- <span class="pulse-ring-1" v-if="callState === 'connecting' || callState === 'ringing'" />
- <span class="pulse-ring-2" v-if="callState === 'connecting' || callState === 'ringing'" />
- <span class="wave-bar w1" v-if="callState === 'talking' && !isOnHold" />
- <span class="wave-bar w2" v-if="callState === 'talking' && !isOnHold" />
- <span class="wave-bar w3" v-if="callState === 'talking' && !isOnHold" />
- <svg viewBox="0 0 24 24" width="32" height="32" fill="none" :stroke="iconStroke" stroke-width="2">
- <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
- </svg>
- </div>
- </div>
- <!-- 通话状态文字 -->
- <div class="call-state-text" :class="statusClass">
- <span>{{ stateText }}</span>
- <span class="call-timer" v-if="callTimer && callState === 'talking'">{{ callTimer }}</span>
- </div>
- <!-- 音量控制(紧凑单行布局) -->
- <div class="volume-controls" v-if="callState === 'talking' && !isOnHold">
- <div class="volume-item">
- <div class="volume-icon" :class="{ muted: isMicMuted }" @click="toggleMicMute">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
- <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
- <line v-if="isMicMuted" x1="1" y1="1" x2="23" y2="23"/>
- </svg>
- </div>
- <input type="range" class="volume-slider" min="0" max="100" :value="isMicMuted ? 0 : micVolume" @input="onMicVolumeChange" />
- <span class="volume-value">{{ isMicMuted ? 0 : micVolume }}%</span>
- </div>
- <div class="volume-item">
- <div class="volume-icon" :class="{ muted: isSpeakerMuted }" @click="toggleSpeakerMute">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
- <line v-if="isSpeakerMuted || speakerVolume === 0" x1="23" y1="9" x2="17" y2="15"/>
- <line v-if="isSpeakerMuted || speakerVolume === 0" x1="17" y1="9" x2="23" y2="15"/>
- <path v-else d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
- </svg>
- </div>
- <input type="range" class="volume-slider" min="0" max="100" :value="isSpeakerMuted ? 0 : speakerVolume" @input="onSpeakerVolumeChange" />
- <span class="volume-value">{{ isSpeakerMuted ? 0 : speakerVolume }}%</span>
- </div>
- </div>
- <!-- 操作按钮 -->
- <div class="call-actions">
- <template v-if="callState === 'connecting' || callState === 'ringing'">
- <button class="action-btn hangup-btn" @click="handleHangup">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
- <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>
- </svg>
- <span>挂断</span>
- </button>
- </template>
- <template v-if="callState === 'talking'">
- <button class="action-btn hangup-btn" @click="handleHangup">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
- <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>
- </svg>
- <span>挂断</span>
- </button>
- <button class="action-btn" :class="isOnHold ? 'resume-btn' : 'pause-btn'" @click="isOnHold ? handleResume() : handlePause()">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
- <rect v-if="!isOnHold" x="6" y="4" width="4" height="16" rx="1"/>
- <rect v-if="!isOnHold" x="14" y="4" width="4" height="16" rx="1"/>
- <polygon v-else points="5 3 19 12 5 21 5 3"/>
- </svg>
- <span>{{ isOnHold ? '恢复' : '暂停' }}</span>
- </button>
- </template>
- </div>
- <!-- 通话已结束:统一文案,后台同步不展示细节 -->
- <div class="ended-notice" v-if="callState === 'ended'">
- <i class="el-icon-loading"></i>
- <span>通话结束,系统回收资源中</span>
- </div>
- </div>
- </div>
- </el-dialog>
- </template>
- <script>
- import axios from 'axios'
- 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 {remoteGatewayList} from '@/api/aiSipCall/aiSipCallGateway.js'
- import { encryptMobile, callEndSyncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr.js'
- const IPCC_CONFIG = IPCC_DEFAULTS
- const JS_SIP_CONFIG = JS_SIP_DEFAULTS
- const TIMEOUT_DECRYPT = 6000
- const TIMEOUT_IPCC_CONNECT = IPCC_CONFIG.CONNECT_TIMEOUT || 8000
- const TIMEOUT_SIP_REGISTER = 10000
- const TIMEOUT_AGENT_READY = 10000
- const POLL_INTERVAL_AGENT = 150
- const DELAY_STEP_TRANSITION = 400
- /** 正常挂断后延迟同步话单 */
- const DELAY_SYNC_CALL_RECORD = 3000
- /** 挂断后最多多久必须关窗 */
- const CLOSE_AFTER_HANGUP_MS = 2000
- const HANGUP_LOCK_TIMEOUT = 30000
- const OUTBOUND_BRIDGE_PROTECT_MS = 45000
- const IPCC_GATEWAY_RELEASE_DELAY_MS = IPCC_CONFIG.GATEWAY_RELEASE_DELAY_MS || 500
- const MOBILE_GATEWAY_MATCH = ['润天售后', '润天售后外呼', '手机外呼']
- const ACTIVE_CALL_STORAGE_KEY = 'sip_call_dialog_active_call_v1'
- const GLOBAL_SYNCED_UUIDS = new Set()
- const GLOBAL_SYNCING_UUIDS = new Set()
- const GLOBAL_SYNCED_UUID_MAX = 300
- const ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS = 2 * 60 * 1000
- const INIT_WATCHDOG_MS = 60000
- /** 流程中断后尽快关窗 */
- const CLOSE_AFTER_FAIL_MS = 1500
- /** 同一次弹窗内,取消后自动恢复启动的最大次数,防止死循环 */
- const MAX_RECOVER_AFTER_CANCEL = 1
- function normalizeCallUuid(uuid) {
- if (uuid == null || uuid === '') return ''
- return String(uuid).trim()
- }
- function markGlobalSynced(id) {
- if (!id) return
- GLOBAL_SYNCED_UUIDS.add(id)
- GLOBAL_SYNCING_UUIDS.delete(id)
- while (GLOBAL_SYNCED_UUIDS.size > GLOBAL_SYNCED_UUID_MAX) {
- const first = GLOBAL_SYNCED_UUIDS.values().next().value
- GLOBAL_SYNCED_UUIDS.delete(first)
- }
- }
- /** 弹窗关闭后仍可后台同步,避免关窗导致话单丢失 / 死锁 */
- function scheduleGlobalCallSync(uuid) {
- const id = normalizeCallUuid(uuid)
- if (!id) return
- if (GLOBAL_SYNCED_UUIDS.has(id) || GLOBAL_SYNCING_UUIDS.has(id)) return
- GLOBAL_SYNCING_UUIDS.add(id)
- setTimeout(() => {
- callEndSyncByUuid({ uuid: id })
- .then((res) => {
- markGlobalSynced(id)
- })
- .catch((err) => {
- markGlobalSynced(id)
- console.warn('[一键外呼] 后台同步失败(已忽略):', id, err && err.message)
- })
- }, DELAY_SYNC_CALL_RECORD)
- }
- export default {
- name: 'CallPhoneDialog',
- props: {
- /** 弹窗显隐,配合 .sync / update:sipVisible */
- sipVisible: { type: Boolean, default: false },
- /** 加密手机号(与 sipPlainPhone 二选一,优先加密) */
- sipEncryptedPhone: { type: String, default: '' },
- sipEncryptedUserId: { type: Number, default: '' },
- /** 明文手机号(无加密号时使用,跳过解密步骤) */
- sipPlainPhone: { type: String, default: '' },
- /** 通话类型 audio/video */
- sipDialType: { type: String, default: 'audio' },
- /** 业务关联 ID,挂断后通过 sip-call-ended 回传 */
- sipRecordId: { type: [String, Number], default: '' }
- },
- data() {
- return {
- flowSteps: [
- { key: 'decrypt', title: '解析号码', status: 'pending', errorMsg: '' },
- { key: 'gateway', title: '选择线路', status: 'pending', errorMsg: '' },
- { key: 'ipcc', title: '连接座席', status: 'pending', errorMsg: '' },
- { key: 'jssip', title: '注册分机', status: 'pending', errorMsg: '' },
- { key: 'call', title: '发起外呼', status: 'pending', errorMsg: '' }
- ],
- ipccConnected: false,
- sipRegistered: false,
- isReady: false,
- globalErrorMsg: '',
- decryptedPhone: '',
- maskedPhone: '',
- isDecrypting: false,
- decryptCancelTokenSource: null,
- callState: 'idle',
- isOnHold: false,
- callTimer: '',
- stateText: '',
- statusClass: '',
- timerInterval: null,
- callStartTime: null,
- currentCallUuid: '',
- ccPhoneBar: null,
- phone: null,
- profileManager: null,
- ccSocketConnected: false,
- ccConnectingResolve: null,
- ccConnectingReject: null,
- ccConnectingPromise: null,
- isCallingReady: false,
- currentUserDisplay: '',
- autoCloseCountdown: 0,
- autoCloseTimer: null,
- isAutoClosing: false,
- _hangupCloseFailSafe: null,
- isSyncPending: false,
- syncPhase: 'idle',
- syncPhaseText: '',
- micVolume: 80,
- speakerVolume: 80,
- isMicMuted: false,
- isSpeakerMuted: false,
- savedMicVolume: 80,
- savedSpeakerVolume: 80,
- initCancelled: false,
- isInitializing: false,
- sipReject: null,
- callStartResolve: null,
- isHangingUp: false,
- hangupLockTimer: null,
- ccEventRefs: [],
- sipEventHandlers: null,
- callDirection: '',
- _bridgeStartedAt: 0,
- _suppressIpccHangupUntil: 0,
- _callEstablishedAt: 0,
- _lastAsrAt: 0,
- _ipccRecovering: false,
- _completingHangup: false,
- _audioHealthTimer: null,
- _remoteHangupCheckTimer: null,
- _lastCallTimerTickAt: 0,
- _ipccReconnectTimer: null,
- _ipccReconnecting: false,
- _ipccReconnectFailCount: 0,
- _ccConnectingTimeoutId: null,
- _ccConnectStartedAt: 0,
- _ipccConnectedAt: 0,
- _pendingIpccHangup: false,
- gatewayList: [],
- connectedGatewayIds: [],
- selectedGatewayId: null,
- awaitingGatewaySelect: false,
- gatewayConfirming: false,
- gatewayListLoading: false,
- _gatewaySelectResolve: null,
- _gatewaySelectReject: null,
- _pendingExtInfo: null,
- beforeUnloadHandler: null,
- _initGeneration: 0,
- _openRunId: 0,
- _syncLockFailSafeTimer: null,
- _initWatchdogTimer: null,
- _needReleaseStaleIpccSession: false,
- /** 防止 opened / watch / mounted 重复启动 */
- _bootStarted: false,
- _booting: false,
- /** 当前正在执行的 initComponent 代数;cancel 时清零,避免 finally 与抬代互相卡死 */
- _initActiveGen: 0,
- /** 本次弹窗内 cancel 后自动恢复次数 */
- _recoverAfterCancelCount: 0
- }
- },
- computed: {
- dialogVisible: {
- get() { return this.sipVisible },
- set(val) {
- if (!val && !this._canCloseDialog()) {
- this.$message.warning(this._closeBlockReason())
- return
- }
- this.$emit('update:sipVisible', val)
- }
- },
- canManualClose() {
- return this._canCloseDialog()
- },
- effectiveDialType() { return this.sipDialType || 'audio' },
- effectiveRecordId() { return this.sipRecordId },
- selectedGateway() {
- if (!this.selectedGatewayId) return null
- return this.gatewayList.find(g => String(g.id) === String(this.selectedGatewayId)) || null
- },
- selectedGatewayTitle() {
- const g = this.selectedGateway
- if (!g) return ''
- return g.gwDesc || g.gwName || ('线路 ' + g.id)
- },
- hasStepError() { return this.flowSteps.some(s => s.status === 'error') },
- progressPercent() {
- const done = this.flowSteps.filter(s => s.status === 'success').length
- const total = this.flowSteps.length
- const current = this.flowSteps.findIndex(s => s.status === 'loading')
- if (current >= 0) return ((current + 0.5) / total) * 100
- return (done / total) * 100
- },
- iconAnimClass() {
- if (this.callState === 'connecting') return 'icon-pulsing'
- if (this.callState === 'ringing') return 'icon-ringing'
- if (this.callState === 'talking') return this.isOnHold ? 'icon-hold' : 'icon-talking'
- return ''
- },
- iconStroke() {
- if (this.callState === 'talking' && !this.isOnHold) return '#4caf50'
- if (this.callState === 'connecting' || this.callState === 'ringing') return '#409eff'
- return '#409eff'
- }
- },
- watch: {
- sipVisible(val, oldVal) {
- // 关闭时清理;打开启动改由 @opened / mounted 触发,避免 watch 与弹窗生命周期竞态
- if (!val && oldVal === true) {
- this._teardownCallSession()
- }
- }
- },
- created() {
- // Vue2 不会把 data 里 `_` 开头字段代理到 this,必须挂到实例上,否则 ++undefined => NaN
- this._openRunId = 0
- this._initGeneration = 0
- this._initActiveGen = 0
- this._bootStarted = false
- this._booting = false
- this._recoverAfterCancelCount = 0
- this._gatewaySelectResolve = null
- this._gatewaySelectReject = null
- this._pendingExtInfo = null
- this._recoverAfterPageLoad()
- },
- mounted() {
- this.beforeUnloadHandler = (event) => {
- this.handleBeforeUnload()
- }
- window.addEventListener('beforeunload', this.beforeUnloadHandler)
- // v-if 挂载且可见时:部分 Element 版本不一定立刻抛 opened,这里兜底启动
- if (this.sipVisible) {
- this.$nextTick(() => {
- this._healStaleBootLocks('mounted')
- this._scheduleBoot('mounted')
- })
- }
- },
- methods: {
- setStepStatus(key, status, errorMsg = '') {
- const step = this.flowSteps.find(s => s.key === key)
- if (step) {
- step.status = status
- if (errorMsg) step.errorMsg = errorMsg
- }
- },
- /** 弹窗动画完成后再启动,保证 DOM 已就绪 */
- onDialogOpened() {
- this._healStaleBootLocks('opened')
- this._scheduleBoot('opened')
- },
- /**
- * 打开时自愈:清理「无真实流程却占着启动锁」的残留,避免永远跳过启动
- */
- _healStaleBootLocks(from) {
- if (!this.sipVisible) return
- const inLiveFlow = this.awaitingGatewaySelect || this.isReady ||
- ['talking', 'ringing', 'connecting', 'ended'].includes(this.callState)
- if (inLiveFlow) return
- // 真实 init / boot 进行中不干预
- if (this._initActiveGen || this._booting) return
- if (this._bootStarted || this.isInitializing || this.initCancelled) {
- console.warn('[一键外呼] 清除残留启动锁, from=', from)
- this._bootStarted = false
- this.isInitializing = false
- this.initCancelled = false
- this.gatewayConfirming = false
- }
- },
- /**
- * 统一启动入口(防重入)
- * @param {string} from 调用来源,便于排查
- */
- _scheduleBoot(from) {
- if (!this.sipVisible) return
- if (this._bootStarted || this._booting || this.isInitializing) {
- return
- }
- // 已进入选线路 / 通话中则不再重启
- if (this.awaitingGatewaySelect || this.isReady) return
- this._bootStarted = true
- this._bootCallSession()
- },
- /** 打开弹窗后启动流程 */
- async _bootCallSession() {
- if (this._booting) return
- this._booting = true
- const token = ++this._openRunId
- // 注意:打开时不要 cancelInit,否则会把即将开始的初始化标成已取消
- this.initCancelled = false
- this._recoverAfterCancelCount = 0
- this._clearHangupCloseFailSafe()
- this._forceUnlockCallGuards({ soft: true })
- // 同步立刻更新 UI,避免“只弹窗、步骤全 pending”的观感
- this.setStepStatus('decrypt', 'loading')
- try {
- // 无残留连接时跳过 destroy,避免 Promise.race 超时后迟到的 cleanup 打断初始化
- const needCleanup = !!(this.phone || this.ccPhoneBar || this.isReady)
- if (needCleanup) {
- await this._destroyAllWithTimeout(true, 3000, token)
- }
- if (token !== this._openRunId || !this.sipVisible) {
- console.warn('[一键外呼] 启动已失效, token=', token, 'openRunId=', this._openRunId, 'visible=', this.sipVisible)
- // 仅本轮 token 仍有效时才释锁,避免误伤已经开始的新会话
- if (token === this._openRunId) this._releaseBootLocks()
- return
- }
- this.initCancelled = false
- await this.initComponent()
- } catch (err) {
- if (token !== this._openRunId || !this.sipVisible) return
- console.error('[一键外呼] 启动失败:', err && err.message)
- this.globalErrorMsg = (err && err.message) || '外呼启动失败'
- this.setStepStatus('decrypt', 'error', this.globalErrorMsg)
- this.$message.error(this.globalErrorMsg)
- this._forceUnlockCallGuards({ soft: false })
- // 允许用户点重试
- this._releaseBootLocks()
- } finally {
- // 绝不能清掉新一轮 boot 的 _booting
- if (token === this._openRunId) {
- this._booting = false
- }
- }
- },
- /** 释放启动防重入锁,保证关窗后可再次一键外呼 */
- _releaseBootLocks() {
- this._bootStarted = false
- this._booting = false
- },
- /**
- * 关窗/异常后恢复「可再次外呼」的全部守卫
- * 选线路中关闭、流程异常、超时都必须走到这里
- */
- _resetCallAvailability() {
- this._releaseBootLocks()
- this._recoverAfterCancelCount = 0
- this.initCancelled = false
- this._forceUnlockCallGuards({ soft: false })
- this._clearHangupCloseFailSafe()
- },
- /** 关闭弹窗:取消初始化并释放资源,必须恢复可再次外呼 */
- _teardownCallSession() {
- this._openRunId++
- this.cancelInit()
- this._resetCallAvailability()
- this._destroyAllWithTimeout(true, 3000, this._openRunId, { forceReset: true }).catch(() => {})
- },
- async _destroyAllWithTimeout(skipSync = true, ms = 3000, sessionToken = null, options = {}) {
- try {
- await Promise.race([
- this.destroyAll(skipSync, sessionToken, options),
- new Promise((resolve) => setTimeout(resolve, ms))
- ])
- } catch (e) {
- console.warn('[一键外呼] destroyAll 异常,继续启动:', e && e.message)
- }
- },
- async initComponent() {
- // 绑定本次弹窗会话,避免旧 init 的取消回调误伤新一轮外呼
- const sessionToken = this._openRunId
- const gen = ++this._initGeneration
- this._initActiveGen = gen
- this.isInitializing = true
- this.initCancelled = false
- this.resetState()
- this._armInitWatchdog(gen)
- const assertAlive = () => {
- if (this.initCancelled || gen !== this._initGeneration) {
- throw new Error('初始化已取消')
- }
- }
- try {
- this.setStepStatus('decrypt', 'loading')
- await this.resolveDialPhone()
- assertAlive()
- this.setStepStatus('decrypt', 'success')
- this.setStepStatus('gateway', 'loading')
- await this.loadGatewayOptions()
- assertAlive()
- // 选线路是用户操作,暂停 watchdog,避免选太久被误判超时关窗
- this._clearInitWatchdog()
- this.awaitingGatewaySelect = true
- await this.waitForGatewayConfirm()
- assertAlive()
- this.awaitingGatewaySelect = false
- this.setStepStatus('gateway', 'success')
- this._armInitWatchdog(gen)
- this.setStepStatus('ipcc', 'loading')
- await this.connectIPCC()
- assertAlive()
- this.setStepStatus('ipcc', 'success')
- this.setStepStatus('jssip', 'loading')
- await this.startSIP()
- assertAlive()
- this.setStepStatus('jssip', 'success')
- await new Promise(r => setTimeout(r, DELAY_STEP_TRANSITION))
- assertAlive()
- this.setStepStatus('call', 'loading')
- await this.waitForCallingReady(TIMEOUT_AGENT_READY)
- assertAlive()
- await this.autoCall()
- } catch (err) {
- // 会话已切到新一轮:旧 init 只退出,禁止动任何锁/恢复逻辑
- if (sessionToken !== this._openRunId) {
- return
- }
- if (err && err.message === '初始化已取消') {
- console.warn('[一键外呼] 初始化已取消')
- // 只放开 _bootStarted;_booting 由外层 _bootCallSession.finally 负责,避免竞态踩踏
- this._bootStarted = false
- // 弹窗仍开着:关窗动画与 reopen 竞态时自动恢复(限次)
- if (
- this.sipVisible &&
- this._recoverAfterCancelCount < MAX_RECOVER_AFTER_CANCEL
- ) {
- this._recoverAfterCancelCount++
- this.initCancelled = false
- this.$nextTick(() => {
- if (
- sessionToken === this._openRunId &&
- this.sipVisible &&
- !this._bootStarted &&
- !this._booting &&
- !this.isInitializing &&
- !this.awaitingGatewaySelect &&
- !this.isReady
- ) {
- this._scheduleBoot('recover-after-cancel')
- }
- })
- }
- } else if (gen === this._initGeneration) {
- console.error('[一键外呼] 初始化失败:', err && err.message)
- this.globalErrorMsg = (err && err.message) || '连接失败'
- this.$message.error(this.globalErrorMsg)
- this.awaitingGatewaySelect = false
- this.isReady = false
- this._bootStarted = false
- // 流程中断:尽快回收并关窗,避免卡死下次外呼
- this._forceUnlockCallGuards({ soft: false })
- this.destroyAll(true, null, { forceReset: true }).catch(() => {})
- clearTimeout(this._failCloseTimer)
- this._failCloseTimer = setTimeout(() => {
- this._failCloseTimer = null
- if (sessionToken === this._openRunId) this.doClose()
- }, CLOSE_AFTER_FAIL_MS)
- }
- } finally {
- this._clearInitWatchdog()
- // 用 _initActiveGen 判定:cancelInit 抬代后仍能释放本轮锁,且不会误清新一轮 init
- if (this._initActiveGen === gen) {
- this.isInitializing = false
- this.gatewayConfirming = false
- this._initActiveGen = 0
- }
- }
- },
- retryInit() {
- if (this.isInitializing || this._booting) return
- this._clearHangupCloseFailSafe()
- this._releaseBootLocks()
- this._recoverAfterCancelCount = 0
- this.initCancelled = false
- this.destroyAll(true, null, { forceReset: true }).then(() => {
- if (this.sipVisible) this._scheduleBoot('retry')
- })
- },
- cancelInit() {
- this.initCancelled = true
- this._initGeneration++
- // 标记当前没有活跃 init,让旧 init 的 finally 能解锁,并立刻放开 isInitializing
- this._initActiveGen = 0
- this.isInitializing = false
- this.awaitingGatewaySelect = false
- this.gatewayConfirming = false
- this.rejectCCConnect(new Error('初始化已取消'))
- if (this._gatewaySelectReject) {
- this._gatewaySelectReject(new Error('初始化已取消'))
- this._gatewaySelectReject = null
- this._gatewaySelectResolve = null
- }
- if (this.sipReject) {
- this.sipReject(new Error('初始化已取消'))
- this.sipReject = null
- }
- this.cancelDecryptRequest()
- this._clearInitWatchdog()
- },
- resetState() {
- this.initCancelled = false
- this.sipReject = null
- this.callStartResolve = null
- this.isHangingUp = false
- this.clearHangupLockTimer()
- this.stopAudioHealthCheck()
- this._cancelRemoteHangupCheck()
- this.unbindCCEvents()
- this.unbindSipEvents()
- this.cancelDecryptRequest()
- this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
- this.cancelAutoClose()
- this.ipccConnected = false
- this.sipRegistered = false
- this.isReady = false
- this.globalErrorMsg = ''
- this.callState = 'idle'
- this.isOnHold = false
- this.callTimer = ''
- this.stateText = ''
- this.statusClass = ''
- this.callStartTime = null
- this.currentCallUuid = ''
- this.callDirection = ''
- this.isCallingReady = false
- this.isAutoClosing = false
- this.isSyncPending = false
- this.syncPhase = 'idle'
- this.syncPhaseText = ''
- this._clearHangupCloseFailSafe()
- this.awaitingGatewaySelect = false
- this.gatewayConfirming = false
- this.selectedGatewayId = null
- this._pendingExtInfo = null
- if (this._gatewaySelectReject) {
- const rej = this._gatewaySelectReject
- this._gatewaySelectReject = null
- this._gatewaySelectResolve = null
- try { rej(new Error('初始化已取消')) } catch (e) {}
- } else {
- this._gatewaySelectResolve = null
- this._gatewaySelectReject = null
- }
- this._bridgeStartedAt = 0
- this._suppressIpccHangupUntil = 0
- this._callEstablishedAt = 0
- this._lastAsrAt = 0
- this._ipccRecovering = false
- this._completingHangup = false
- this._lastCallTimerTickAt = 0
- this._clearIpccReconnectState()
- this.gatewayList = []
- this.connectedGatewayIds = []
- this.stopTimer()
- },
- resetStateWithoutCancelSync() {
- this.initCancelled = false
- this.sipReject = null
- this.callStartResolve = null
- this.isHangingUp = false
- this.clearHangupLockTimer()
- this.stopAudioHealthCheck()
- this._cancelRemoteHangupCheck()
- this.unbindCCEvents()
- this.unbindSipEvents()
- this.cancelDecryptRequest()
- this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
- this.cancelAutoClose()
- this._clearHangupCloseFailSafe()
- this.ipccConnected = false
- this.sipRegistered = false
- this.isReady = false
- this.globalErrorMsg = ''
- this.callState = 'idle'
- this.isOnHold = false
- this.callTimer = ''
- this.stateText = ''
- this.statusClass = ''
- this.callStartTime = null
- this.currentCallUuid = ''
- this.callDirection = ''
- this.isCallingReady = false
- this.isAutoClosing = false
- this.isSyncPending = false
- this.syncPhase = 'idle'
- this.syncPhaseText = ''
- this._bridgeStartedAt = 0
- this._suppressIpccHangupUntil = 0
- this._callEstablishedAt = 0
- this._lastAsrAt = 0
- this._ipccRecovering = false
- this._completingHangup = false
- this._lastCallTimerTickAt = 0
- this._clearIpccReconnectState()
- this.stopTimer()
- },
- // 解密请求优化:增加超时和取消令牌
- // 解析拨号号码:优先加密号解密,否则使用明文号
- async resolveDialPhone() {
- if (this.sipEncryptedPhone) {
- await this.decryptPhone()
- return
- }
- if (this.sipPlainPhone) {
- const phone = String(this.sipPlainPhone).trim()
- if (!phone || phone.length < 3) {
- const errMsg = '明文手机号无效'
- this.setStepStatus('decrypt', 'error', errMsg)
- throw new Error(errMsg)
- }
- this.decryptedPhone = phone
- this.maskedPhone = this.maskPhone(phone)
- return
- }
- const errMsg = '未提供手机号(sipEncryptedPhone / sipPlainPhone)'
- this.setStepStatus('decrypt', 'error', errMsg)
- throw new Error(errMsg)
- },
- // 解密请求优化:增加超时和取消令牌
- async decryptPhone() {
- if (!this.sipEncryptedPhone) {
- const errMsg = '未提供加密手机号'
- this.setStepStatus('decrypt', 'error', errMsg)
- throw new Error(errMsg)
- }
- this.isDecrypting = true
- this.cancelDecryptRequest()
- const CancelToken = axios.CancelToken
- this.decryptCancelTokenSource = CancelToken.source()
- try {
- const random = Math.floor(100000 + Math.random() * 900000).toString()
- const combined = this.sipEncryptedPhone + random
- const userId = this.sipEncryptedUserId
- const data={
- phone:combined,
- id:userId,
- }
- const response = await Promise.race([
- encryptMobile(data, { cancelToken: this.decryptCancelTokenSource.token }),
- new Promise((_, reject) => setTimeout(() => reject(new Error('号码解密超时')), TIMEOUT_DECRYPT))
- ])
- if (this.initCancelled) throw new Error('已取消')
- if (response.code === 200) {
- const privateKey = process.env.VUE_APP_SIP_PHONE_ENCRYPT_PRIVATE_KEY
- if (!privateKey) {
- throw new Error('解密私钥未配置')
- }
- const resultData = response.data.slice(0, -6)
- this.decryptedPhone = this.xorDecrypt(resultData, privateKey)
- this.maskedPhone = this.maskPhone(this.decryptedPhone)
- } else {
- throw new Error(response.message || response.msg || '号码解密失败')
- }
- } catch (error) {
- if (axios.isCancel(error)) {
- throw new Error('解密已取消')
- }
- const errMsg = error.message || '号码解密异常'
- this.setStepStatus('decrypt', 'error', errMsg)
- throw error
- } finally {
- this.isDecrypting = false
- this.decryptCancelTokenSource = null
- }
- },
- cancelDecryptRequest() {
- if (this.decryptCancelTokenSource) {
- this.decryptCancelTokenSource.cancel('解密已取消')
- this.decryptCancelTokenSource = null
- }
- },
- xorDecrypt(base64Str, privateKey) {
- const binaryStr = atob(base64Str)
- const keyBytes = privateKey.split('').map(ch => ch.charCodeAt(0))
- let result = ''
- for (let i = 0; i < binaryStr.length; i++) {
- result += String.fromCharCode(binaryStr.charCodeAt(i) ^ keyBytes[i % keyBytes.length])
- }
- return result
- },
- maskPhone(phone) {
- if (!phone || phone.length < 7) return phone
- return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4)
- },
- _isInCallFlow() {
- return ['talking', 'ringing', 'connecting'].includes(this.callState)
- },
- _isMobileGatewayLine() {
- const gwId = this.connectedGatewayIds[0]
- if (!gwId || !this.gatewayList.length) {
- return this.gatewayList.some((gw) => this._gatewayIsMobileLine(gw))
- }
- const gw = this.gatewayList.find((g) => String(g.id) === String(gwId))
- return this._gatewayIsMobileLine(gw)
- },
- _gatewayIsMobileLine(gateway) {
- if (!gateway) return false
- const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
- return MOBILE_GATEWAY_MATCH.some((key) => text.includes(key))
- },
- _shouldTreatMobileLineAsEnded() {
- if (!this._isMobileGatewayLine()) return false
- if (!['talking', 'ringing'].includes(this.callState)) return false
- if (!this._ipcCallSessionActive()) return true
- if (this.callState === 'ringing') return false
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- const asrRecent = this._lastAsrAt && Date.now() - this._lastAsrAt < 3000
- const timerStale = this._lastCallTimerTickAt && Date.now() - this._lastCallTimerTickAt > 3500
- if (timerStale && !asrRecent) return true
- if (!sipAlive && !asrRecent) return true
- return false
- },
- _syncMobileRemoteHangupIfNeeded(reason) {
- if (!this._shouldTreatMobileLineAsEnded()) return false
- if (this._completingHangup || this.isHangingUp) return false
- console.warn('[一键外呼] 手机线路远端已挂机,同步:', reason)
- this._forceReleaseIpccCallState()
- this._completeCallHangup('ipcc')
- return true
- },
- _handleAgentPostCallStatus(statusCode) {
- if (statusCode !== AgentStatusEnum.FILL_FORM) return
- if (!this._isInCallFlow()) return
- if (this._completingHangup) return
- this._forceReleaseIpccCallState()
- this._completeCallHangup('ipcc')
- },
- _scheduleMobileRemoteHangupSync(reason, delayMs = 600) {
- if (!this._isMobileGatewayLine()) return
- if (!['talking', 'ringing'].includes(this.callState)) return
- this._scheduleRemoteHangupCheck(reason, delayMs)
- },
- _startOutboundBridgeProtect(reason = '') {
- const now = Date.now()
- this._bridgeStartedAt = now
- this._suppressIpccHangupUntil = now + OUTBOUND_BRIDGE_PROTECT_MS
- if (this.ccPhoneBar && typeof this.ccPhoneBar.setCallConnected === 'function') {
- this.ccPhoneBar.setCallConnected(true)
- }
- if (reason) console.debug('[一键外呼] 桥接保护启动', reason)
- },
- _isRingingBridgeProtected() {
- if (!['ringing', 'connecting'].includes(this.callState) || !this.currentCallUuid) return false
- if (this._ipccRecovering) return true
- if (!this.ccPhoneBar || !this.ccSocketConnected) return false
- if (this._suppressIpccHangupUntil && Date.now() < this._suppressIpccHangupUntil) return true
- if (this._bridgeStartedAt && Date.now() - this._bridgeStartedAt < OUTBOUND_BRIDGE_PROTECT_MS) return true
- if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true
- return this._ipcCallAlive()
- },
- _ipcCallAlive() {
- if (this._ipcCallSessionActive()) return true
- if (this._ipccRecovering && this._lastAsrAt && Date.now() - this._lastAsrAt < 5000) return true
- // IPCC 恢复期间 JsSIP 仍在通,视为通话未结束
- if (this._ipccRecovering) {
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive) return true
- }
- return false
- },
- _ipcCallSessionActive() {
- if (!this.ccPhoneBar) return false;
- if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true;
- if (this.ccPhoneBar.getActiveCallUuid && this.ccPhoneBar.getActiveCallUuid()) return true;
- return false;
- },
- _isRemoteCallAlive() {
- if (!this.ccPhoneBar) return false
- // IPCC 断开恢复中:有会话快照或 JsSIP 仍在,禁止据此自动挂断
- if (this._ipccRecovering) {
- if (this._ipcCallSessionActive()) return true
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive) return true
- return false
- }
- if (!this.ccSocketConnected) return false
- if (this._isMobileGatewayLine()) {
- if (this._shouldTreatMobileLineAsEnded()) return false
- return this._ipcCallSessionActive()
- }
- return this._ipcCallSessionActive() && this.callState === 'talking'
- },
- _forceReleaseIpccCallState() {
- if (!this.ccPhoneBar) return
- this.ccPhoneBar.clearActiveCallUuid()
- if (typeof this.ccPhoneBar.setCallConnected === 'function') {
- this.ccPhoneBar.setCallConnected(false)
- }
- if (typeof this.ccPhoneBar.resetOutboundLock === 'function') {
- this.ccPhoneBar.resetOutboundLock()
- }
- },
- async _gracefulDisconnectIpcc(waitMs) {
- if (!this.ccPhoneBar) return
- const disconnectWait = waitMs || IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000
- try {
- if (typeof this.ccPhoneBar.forceDisconnect === 'function') {
- this.ccPhoneBar.forceDisconnect()
- await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
- } else if (typeof this.ccPhoneBar.disconnectAndWait === 'function') {
- await this.ccPhoneBar.disconnectAndWait(disconnectWait)
- } else {
- this.ccPhoneBar.disconnect()
- await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
- }
- } catch (e) {}
- await new Promise((r) => setTimeout(r, IPCC_GATEWAY_RELEASE_DELAY_MS))
- },
- _syncCallUuidToSdk(uuid) {
- if (uuid && this.ccPhoneBar && typeof this.ccPhoneBar.setActiveCallUuid === 'function') {
- this.ccPhoneBar.setActiveCallUuid(uuid)
- }
- },
- _refreshInCallStatusIndicator() {
- // 通话中 IPCC 闪断重连:界面保持正常通话态,异常只打日志,避免用户感知断开
- if (this.callState === 'talking') {
- this.stateText = this.isOnHold ? '已暂停' : (this._isMobileGatewayLine() ? '通话中(音频在手机)' : '通话中')
- this.statusClass = this.isOnHold ? 'status-hold' : 'status-talking'
- } else if (this.callState === 'ringing') {
- this.stateText = this.callDirection === 'outbound' ? '客户振铃中...' : '振铃中...'
- this.statusClass = 'status-ringing'
- }
- },
- _persistActiveCallSnapshot() {
- if (!this.currentCallUuid || this.callState === 'idle' || this.callState === 'ended') return
- try {
- sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
- uuid: this.currentCallUuid,
- callState: this.callState,
- callDirection: this.callDirection,
- savedAt: Date.now()
- }))
- } catch (e) {}
- },
- _restoreActiveCallSnapshot() {
- try {
- const raw = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
- if (!raw) return
- const snap = JSON.parse(raw)
- if (!snap?.uuid) return
- if (!this.currentCallUuid) this.currentCallUuid = snap.uuid
- this._syncCallUuidToSdk(this.currentCallUuid)
- } catch (e) {}
- },
- _clearActiveCallSnapshot() {
- try { sessionStorage.removeItem(ACTIVE_CALL_STORAGE_KEY) } catch (e) {}
- },
- _getIpccConnectTimeout(isReconnect = false) {
- return isReconnect
- ? (IPCC_CONFIG.RECONNECT_CONNECT_TIMEOUT || 3000)
- : (IPCC_CONFIG.CONNECT_TIMEOUT || TIMEOUT_IPCC_CONNECT)
- },
- _clearCcConnectingState() {
- if (this._ccConnectingTimeoutId) {
- clearTimeout(this._ccConnectingTimeoutId)
- this._ccConnectingTimeoutId = null
- }
- this.ccConnectingResolve = null
- this.ccConnectingReject = null
- this.ccConnectingPromise = null
- this._ipccReconnecting = false
- this._ccConnectStartedAt = 0
- },
- _finishCcConnecting(success, err) {
- const resolve = this.ccConnectingResolve
- const reject = this.ccConnectingReject
- if (this._ccConnectingTimeoutId) {
- clearTimeout(this._ccConnectingTimeoutId)
- this._ccConnectingTimeoutId = null
- }
- this.ccConnectingResolve = null
- this.ccConnectingReject = null
- this.ccConnectingPromise = null
- this._ipccReconnecting = false
- this._ccConnectStartedAt = 0
- if (success && resolve) resolve()
- else if (!success && reject) reject(err || new Error('IPCC连接失败'))
- },
- async _reconnectExistingIpcc() {
- if (this._ipccReconnecting && this.ccConnectingPromise) {
- return this.ccConnectingPromise
- }
- this._ipccReconnecting = true
- this._ccConnectStartedAt = Date.now()
- this.ccConnectingPromise = new Promise((resolve, reject) => {
- this.ccConnectingResolve = resolve
- this.ccConnectingReject = reject
- try {
- if (this.ccPhoneBar.resetKickedState) this.ccPhoneBar.resetKickedState()
- if (typeof this.ccPhoneBar.reconnect === 'function') {
- this.ccPhoneBar.reconnect()
- } else {
- this.ccPhoneBar.connect()
- }
- this._ccConnectingTimeoutId = setTimeout(() => {
- if (!this.ccSocketConnected) {
- this._finishCcConnecting(false, new Error('IPCC重连超时'))
- }
- }, this._getIpccConnectTimeout(true))
- } catch (err) {
- this._finishCcConnecting(false, err)
- }
- })
- return this.ccConnectingPromise
- },
- _scheduleIpccReconnect(duringCall, extraDelayMs = 0) {
- if (!duringCall && this.callState === 'idle' &&
- this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) {
- return
- }
- if (this._ipccReconnectTimer) {
- if (duringCall) return
- clearTimeout(this._ipccReconnectTimer)
- this._ipccReconnectTimer = null
- }
- if (this._ipccReconnecting) return
- if (this.ccConnectingPromise) {
- const elapsed = this._ccConnectStartedAt ? Date.now() - this._ccConnectStartedAt : 0
- const reconnectTimeout = this._getIpccConnectTimeout(true)
- if (elapsed < reconnectTimeout + 500) return
- console.warn('[一键外呼] IPCC连接Promise超时未结束,强制清理后重试')
- this._finishCcConnecting(false, new Error('连接状态超时'))
- }
- const delay = (duringCall
- ? (IPCC_CONFIG.RECONNECT_DELAY_IN_CALL_MS || 150)
- : (IPCC_CONFIG.RECONNECT_DELAY_MS || 100)) + (extraDelayMs || 0)
- this._ipccReconnectTimer = setTimeout(async () => {
- this._ipccReconnectTimer = null
- if (this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) return
- try {
- if (this.ccPhoneBar) {
- await this._reconnectExistingIpcc()
- }
- if (!this._pendingIpccHangup) {
- this._ipccRecovering = false
- this._refreshInCallStatusIndicator()
- }
- } catch (err) {
- if (duringCall && this._isInCallFlow()) {
- if (this._ipccReconnectFailCount == null) this._ipccReconnectFailCount = 0
- this._ipccReconnectFailCount += 1
- const backoff = this._ipccReconnectFailCount <= 8
- ? 0
- : Math.min(5000, 350 * (this._ipccReconnectFailCount - 8))
- console.debug('[一键外呼] 通话中IPCC重连失败,继续重试:', err.message || err)
- this._scheduleIpccReconnect(true, backoff)
- }
- }
- }, delay)
- },
- _clearIpccReconnectState() {
- if (this._ipccReconnectTimer) {
- clearTimeout(this._ipccReconnectTimer)
- this._ipccReconnectTimer = null
- }
- this._pendingIpccHangup = false
- this._ipccReconnectFailCount = 0
- this._clearCcConnectingState()
- this._clearActiveCallSnapshot()
- },
- _cancelRemoteHangupCheck() {
- if (this._remoteHangupCheckTimer) {
- clearTimeout(this._remoteHangupCheckTimer)
- this._remoteHangupCheckTimer = null
- }
- },
- _scheduleRemoteHangupCheck(reason, delayMs = 8000) {
- if (!['talking', 'ringing'].includes(this.callState)) return
- if (this._remoteHangupCheckTimer) return
- const delay = this.callState === 'talking' ? Math.min(delayMs, 1500) : delayMs
- this._remoteHangupCheckTimer = setTimeout(() => {
- this._remoteHangupCheckTimer = null
- if (this.callState === 'ended' || this.callState === 'idle') return
- if (this._isRingingBridgeProtected()) {
- this._scheduleRemoteHangupCheck(reason, 2000)
- return
- }
- if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded(reason)) return
- if (this._ipccRecovering && this._ipcCallAlive()) return
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive) return
- if (this._isRemoteCallAlive()) return
- console.warn('[一键外呼] 远端挂机确认:', reason)
- this._completeCallHangup('sip')
- }, delay)
- },
- _completeCallHangup(source) {
- if (this.callState === 'ended' || this.callState === 'idle') return
- if (this._completingHangup) return
- this._completingHangup = true
- const uuid = this.currentCallUuid
- try {
- this._cancelRemoteHangupCheck()
- this.stopAudioHealthCheck()
- const needIpccHangup = source !== 'ipcc'
- if (needIpccHangup && this.ccPhoneBar && this.ccSocketConnected) {
- try { this.ccPhoneBar.hangup() } catch (e) {}
- } else if (source === 'ipcc') {
- this._forceReleaseIpccCallState()
- } else if (needIpccHangup && this.ccPhoneBar && !this.ccSocketConnected && this._isInCallFlow()) {
- this._pendingIpccHangup = true
- this._persistActiveCallSnapshot()
- this._scheduleIpccReconnect(true, 0)
- }
- if (this.phone) {
- if (typeof this.phone.markIntentionalHangup === 'function') {
- this.phone.markIntentionalHangup()
- }
- try { this.phone.Terminate() } catch (e) {}
- try { this.phone.releaseLocalStream() } catch (e) {}
- }
- this.callState = 'ended'
- this.stateText = source === 'sip' ? '对方已挂机' : '已挂断'
- this.statusClass = 'status-ended'
- this.isOnHold = false
- this.stopTimer()
- this.callTimer = ''
- this._bridgeStartedAt = 0
- this._suppressIpccHangupUntil = 0
- if (!this._pendingIpccHangup) {
- this._clearActiveCallSnapshot()
- }
- this.$emit('sip-call-ended', this.effectiveRecordId)
- this.startSyncAndClose(uuid)
- } finally {
- this._completingHangup = false
- this.clearHangupLock()
- }
- },
- _isSyncNotFoundError(msg) {
- return /不存在|not found|找不到|未找到/i.test(msg || '')
- },
- _isDuplicateSyncError(msg) {
- return /duplicate|PRIMARY|已存在|重复/i.test(msg || '')
- },
- async loadGatewayOptions() {
- this.gatewayListLoading = true
- try {
- const extRes = await myCallUser()
- if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
- throw new Error('未查询到分机号信息,请先在外呼配置中绑定分机')
- }
- const { extNum, extPass, gatewayIds: myGateway } = extRes.data
- this._pendingExtInfo = { extNum, extPass, myGateway }
- let rows = []
- try {
- const gwRes = await remoteGatewayList({
- pageNum: 1,
- pageSize: 50,
- isAICall: false,
- params: { purposes: [1, 3] }
- })
- rows = gwRes.rows || []
- } catch (e) {
- console.warn('[一键外呼] remoteGatewayList 失败,尝试工具条网关列表', e && e.message)
- }
- if (!rows.length) {
- const basicRes = await getToolbarBasicParam({ extNum, myGateway })
- if (basicRes.code !== 0) throw new Error(basicRes.message || '获取线路失败')
- rows = (basicRes.data && basicRes.data.gatewayList) || []
- }
- this.gatewayList = rows
- if (!this.gatewayList.length) {
- throw new Error('暂无可用外呼线路')
- }
- // 预选:优先绑定线路中的第一条,否则选列表第一条
- const boundIds = myGateway
- ? String(myGateway).split(',').map(s => s.trim()).filter(Boolean)
- : []
- const preferred = boundIds.find(id => this.gatewayList.some(g => String(g.id) === String(id)))
- this.selectedGatewayId = preferred || this.gatewayList[0].id
- } finally {
- this.gatewayListLoading = false
- }
- },
- waitForGatewayConfirm() {
- return new Promise((resolve, reject) => {
- this._gatewaySelectResolve = resolve
- this._gatewaySelectReject = reject
- })
- },
- selectGateway(gatewayId) {
- if (this.gatewayConfirming || !this.awaitingGatewaySelect) return
- if (gatewayId == null || gatewayId === '') return
- this.selectedGatewayId = gatewayId
- },
- confirmGatewaySelect() {
- if (this.gatewayConfirming) return
- if (!this.selectedGatewayId) {
- this.$message.warning('请先选择外呼线路')
- return
- }
- const exists = this.gatewayList.some(g => String(g.id) === String(this.selectedGatewayId))
- if (!exists) {
- this.$message.error('所选线路无效,请重新选择')
- return
- }
- this.gatewayConfirming = true
- if (this._gatewaySelectResolve) {
- const resolve = this._gatewaySelectResolve
- this._gatewaySelectResolve = null
- this._gatewaySelectReject = null
- resolve()
- }
- },
- getGatewayBadge(gateway) {
- if (!gateway) return { text: '线路', theme: 'default' }
- const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
- if (MOBILE_GATEWAY_MATCH.some(k => text.includes(k))) {
- return { text: '手机线路', theme: 'mobile' }
- }
- return { text: '固话线路', theme: 'landline' }
- },
- async connectIPCC() {
- try {
- if (!this.selectedGatewayId) {
- throw new Error('请先选择外呼线路')
- }
- let extNum, extPass, myGateway
- if (this._pendingExtInfo && this._pendingExtInfo.extNum) {
- extNum = this._pendingExtInfo.extNum
- extPass = this._pendingExtInfo.extPass
- myGateway = this.selectedGatewayId
- } else {
- const extRes = await myCallUser()
- if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
- throw new Error('未查询到分机号信息')
- }
- extNum = extRes.data.extNum
- extPass = extRes.data.extPass
- myGateway = this.selectedGatewayId
- }
- this.connectedGatewayIds = [String(this.selectedGatewayId)]
- this.setupProfile(extNum, extPass)
- const basicRes = await getToolbarBasicParam({ extNum, myGateway: String(myGateway) })
- if (basicRes.code !== 0) throw new Error(basicRes.message || '获取配置失败')
- const configData = basicRes.data
- if (!configData.loginToken) throw new Error('登录令牌无效')
- this.gatewayList = configData.gatewayList || []
- const callConfig = {
- useDefaultUi: false,
- loginToken: configData.loginToken,
- ipccServer: IPCC_CONFIG.SERVER_PROD,
- gatewayList: configData.gatewayList,
- gatewayEncrypted: false,
- extPassword: configData.encryptPsw,
- extnum: extNum,
- opnum: configData.opNum || configData.userName,
- enableWss: true,
- enableHeartBeat: true,
- heartBeatIntervalSecs: 16
- }
- this.ccPhoneBar = new ccPhoneBarSocket()
- this.ccPhoneBar.initConfig(callConfig)
- this.bindCCEvents()
- this.ccPhoneBar.connect()
- await new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => reject(new Error('IPCC连接超时')), TIMEOUT_IPCC_CONNECT)
- this.ccConnectingResolve = () => { clearTimeout(timeoutId); resolve() }
- this.ccConnectingReject = (err) => { clearTimeout(timeoutId); reject(err) }
- })
- } catch (err) {
- this.setStepStatus('ipcc', 'error', err.message)
- throw err
- }
- },
- setupProfile(extNum, extPass) {
- this.profileManager = new ProfileManager()
- let profile = this.profileManager.getProfile()
- if (!profile.users) profile.users = {}
- let existingUserId = null
- for (const [id, user] of Object.entries(profile.users)) {
- if (user.user === String(extNum).trim()) {
- existingUserId = id
- break
- }
- }
- const userData = {
- note: String(extNum).trim(),
- user: String(extNum).trim(),
- domain: JS_SIP_CONFIG.DOMAIN,
- password: String(extPass).trim(),
- display_name: String(extNum).trim(),
- server: JS_SIP_CONFIG.SERVER,
- transport: JS_SIP_CONFIG.TRANSPORT,
- auto_answer: true
- }
- if (existingUserId) {
- this.profileManager.updateUser(existingUserId, userData)
- } else {
- this.profileManager.addUser(userData)
- }
- const updatedProfile = this.profileManager.getProfile()
- for (const [id, user] of Object.entries(updatedProfile.users || {})) {
- if (user.user === String(extNum).trim()) {
- this.profileManager.switchUser(id)
- break
- }
- }
- this.currentUserDisplay = String(extNum).trim()
- },
- on(event, callback) {
- if (!this.ccPhoneBar) return
- this.ccPhoneBar.on(event, callback)
- this.ccEventRefs.push({ event, callback })
- },
- unbindCCEvents() {
- if (this.ccPhoneBar) {
- this.ccEventRefs.forEach(({ event, callback }) => {
- try { this.ccPhoneBar.off(event, callback) } catch (e) {}
- })
- }
- this.ccEventRefs = []
- },
- bindCCEvents() {
- if (!this.ccPhoneBar) return
- this.on(EventList.WS_CONNECTED, () => {
- this.ipccConnected = true
- this.ccSocketConnected = true
- this._ipccConnectedAt = Date.now()
- if (this._isInCallFlow() || this._ipccRecovering || this._pendingIpccHangup) {
- this._restoreActiveCallSnapshot()
- if (this.ccPhoneBar && !this._pendingIpccHangup) {
- this.ccPhoneBar.restoreCallSession()
- }
- }
- if (this._pendingIpccHangup) {
- this._pendingIpccHangup = false
- this._ipccRecovering = false
- try { this.ccPhoneBar.hangup() } catch (e) {}
- this._forceReleaseIpccCallState()
- this._clearActiveCallSnapshot()
- } else {
- this._ipccRecovering = false
- this._ipccReconnectFailCount = 0
- this._refreshInCallStatusIndicator()
- }
- if (this.ccConnectingPromise) {
- this._finishCcConnecting(true)
- } else if (this.ccConnectingResolve) {
- this.ccConnectingResolve()
- this.ccConnectingResolve = null
- }
- })
- this.on(EventList.WS_DISCONNECTED, () => {
- this.ipccConnected = false
- this.ccSocketConnected = false
- const inCall = this._isInCallFlow()
- if (inCall) {
- console.warn('[一键外呼] IPCC断开(通话中),后台重连恢复通话')
- this._persistActiveCallSnapshot()
- this._cancelRemoteHangupCheck()
- this._ipccRecovering = true
- this._refreshInCallStatusIndicator()
- this._scheduleIpccReconnect(true)
- return
- }
- this.isCallingReady = false
- this.isReady = false
- if (this.ccConnectingReject) {
- this.ccConnectingReject(new Error('IPCC WebSocket连接断开'))
- this.ccConnectingReject = null
- }
- })
- this.on(EventList.ASR_RESULT_GENERATE, () => {
- this._lastAsrAt = Date.now()
- this._cancelRemoteHangupCheck()
- if (this.callState === 'ringing' && this._isInCallFlow()) {
- this.callState = 'talking'
- this.stateText = '通话中'
- this.statusClass = 'status-talking'
- this._callEstablishedAt = Date.now()
- if (!this.callStartTime) {
- this.callStartTime = Date.now()
- this.startTimer()
- }
- }
- if (this._ipccRecovering && this.callState === 'talking') {
- this._ipccRecovering = false
- this._refreshInCallStatusIndicator()
- }
- })
- this.on(EventList.STATUS_CHANGED, (msg) => {
- if (msg?.object) {
- const statusCode = msg.object.status
- this._handleAgentPostCallStatus(statusCode)
- const readyStatuses = [
- AgentStatusEnum.BUSY,
- AgentStatusEnum.BUSY_REST,
- AgentStatusEnum.BUSY_MEETING,
- AgentStatusEnum.BUSY_TRAINING,
- AgentStatusEnum.FILL_FORM,
- AgentStatusEnum.FREE
- ]
- this.isCallingReady = readyStatuses.includes(statusCode)
- }
- })
- this.on(EventList.OUTBOUND_START, (msg) => {
- this.callDirection = 'outbound'
- this._startOutboundBridgeProtect('OUTBOUND_START')
- this.callState = 'connecting'
- this.stateText = '正在呼叫...'
- this.statusClass = 'status-connecting'
- this.setStepStatus('call', 'success')
- this.isReady = true
- if (msg?.object?.uuid) {
- this.currentCallUuid = msg.object.uuid
- this._syncCallUuidToSdk(this.currentCallUuid)
- }
- if (this.callStartResolve) this.callStartResolve()
- })
- this.on(EventList.CALLEE_RINGING, () => {
- this.callState = 'ringing'
- this.stateText = '振铃中...'
- this.statusClass = 'status-ringing'
- if (this.callDirection === 'outbound') {
- this._startOutboundBridgeProtect('CALLEE_RINGING')
- }
- this.startAudioHealthCheck()
- })
- const applyTalkingState = (msg) => {
- if (msg?.object?.uuid) {
- this.currentCallUuid = msg.object.uuid
- this._syncCallUuidToSdk(this.currentCallUuid)
- }
- this.callState = 'talking'
- this._callEstablishedAt = Date.now()
- this._suppressIpccHangupUntil = Date.now() + 8000
- this.callStartTime = Date.now()
- this.startTimer()
- this._refreshInCallStatusIndicator()
- if (this.phone) {
- this.isSpeakerMuted = false
- this.isMicMuted = false
- this.phone.ensureLocalStream()
- .then(() => {
- this.syncVolumeToPhone()
- this.phone.resumeRemoteAudio()
- })
- .catch(() => console.warn('[一键外呼] 通话中麦克风不可用'))
- }
- this.startAudioHealthCheck()
- }
- this.on(EventList.CALLER_ANSWERED, (msg) => {
- if (msg?.object?.uuid) {
- this.currentCallUuid = msg.object.uuid
- this._syncCallUuidToSdk(this.currentCallUuid)
- }
- if (this.callDirection === 'outbound') {
- this.callState = 'ringing'
- this.stateText = '客户振铃中...'
- this.statusClass = 'status-ringing'
- this._startOutboundBridgeProtect('CALLER_ANSWERED')
- this.startAudioHealthCheck()
- return
- }
- applyTalkingState(msg)
- })
- this.on(EventList.CALLEE_ANSWERED, (msg) => {
- applyTalkingState(msg)
- })
- const handleIpccHangup = (msg) => {
- if (this._completingHangup) return
- this._suppressIpccHangupUntil = 0
- this._bridgeStartedAt = 0
- if (msg?.object?.uuid) this.currentCallUuid = msg.object.uuid
- this._completeCallHangup('ipcc')
- }
- this.on(EventList.CALLER_HANGUP, handleIpccHangup)
- this.on(EventList.CALLEE_HANGUP, handleIpccHangup)
- this.on(EventList.OUTBOUND_FINISHED, () => {
- if (this.callState === 'idle' || this.callState === 'ended') {
- console.debug('[一键外呼] 611(外呼任务结束),空闲态忽略')
- return
- }
- if (this._isMobileGatewayLine() && this.callState === 'talking') {
- console.debug('[一键外呼] 手机线路 611,复核是否已挂机')
- this._scheduleMobileRemoteHangupSync('611_talking', 500)
- return
- }
- console.debug('[一键外呼] 611外呼任务结束,不参与挂机,当前态=', this.callState)
- })
- this.on(EventList.ASR_PROCESS_END_CUSTOMER, () => {
- if (this._isMobileGatewayLine() && this.callState === 'talking') {
- console.debug('[一键外呼] 手机线路 ASR 结束,复核是否已挂机')
- this._scheduleMobileRemoteHangupSync('asr_customer_end', 800)
- }
- })
- this.on(EventList.CALLER_RESPOND_TIMEOUT, () => {
- if (this._isRingingBridgeProtected()) {
- console.debug('[一键外呼] 分机超时在桥接保护期内,忽略')
- return
- }
- if (this.isHangingUp) return
- // IPCC 闪断恢复中或 JsSIP 仍在通:不自动挂断
- if (this._ipccRecovering || !this.ccSocketConnected) {
- console.warn('[一键外呼] 分机响应超时(IPCC恢复中),保持通话并重连')
- this._persistActiveCallSnapshot()
- this._scheduleIpccReconnect(true)
- return
- }
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive && this._isInCallFlow()) {
- console.warn('[一键外呼] 分机响应超时但JsSIP仍在通,忽略自动挂断')
- return
- }
- this._completeCallHangup('ipcc')
- })
- this.on(EventList.EXTENSION_OFF_LINE, () => {
- if (this.callState === 'idle' || this.callState === 'ended') return
- // 关键:IPCC 断开/重连时分机可能短暂离线,绝不能打断 JsSIP 通话
- if (this._ipccRecovering || !this.ccSocketConnected) {
- console.warn('[一键外呼] 分机离线(IPCC恢复中),保持JsSIP并后台重连')
- this._persistActiveCallSnapshot()
- this._ipccRecovering = true
- this._scheduleIpccReconnect(true)
- return
- }
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive && this._isInCallFlow()) {
- console.warn('[一键外呼] 分机离线但JsSIP仍在通,后台重连IPCC,不自动挂断')
- this._ipccRecovering = true
- this._persistActiveCallSnapshot()
- this._refreshInCallStatusIndicator()
- this._scheduleIpccReconnect(true)
- return
- }
- if (this._isMobileGatewayLine() && ['talking', 'ringing'].includes(this.callState)) {
- console.warn('[一键外呼] 手机线路分机离线,同步挂机')
- this._forceReleaseIpccCallState()
- this._completeCallHangup('ipcc')
- return
- }
- console.warn('[一键外呼] 分机离线')
- this._completeCallHangup('ipcc')
- })
- this.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
- this.isOnHold = true
- this.stateText = '已暂停'
- this.statusClass = 'status-hold'
- })
- this.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
- this.isOnHold = false
- this.stateText = '通话中'
- this.statusClass = 'status-talking'
- })
- this.on(EventList.CALLER_BUSY, () => {
- this.isHangingUp = true
- this.callState = 'ended'
- this.stateText = '线路忙'
- this.statusClass = 'status-failed'
- this.$message.warning('上一通电话未挂机,请稍后再试')
- this.stopTimer()
- this.startSyncAndClose(this.currentCallUuid)
- })
- },
- bindSipEvents() {
- this.unbindSipEvents()
- if (!this.phone) return
- this.sipEventHandlers = {
- onAnswered: () => {
- if (this.callDirection === 'outbound' && this.callState !== 'talking') return
- if (this.phone) {
- this.syncVolumeToPhone()
- this.phone.resumeRemoteAudio()
- }
- },
- onSessionClosed: (payload) => {
- if (this.isHangingUp || this._completingHangup || payload?.intentional) return
- if (this._ipccRecovering && this._ipcCallAlive()) {
- console.debug('[一键外呼] SIP结束但IPCC恢复中,继续等待')
- return
- }
- if (this.callState === 'talking') {
- if (this._isMobileGatewayLine()) {
- if (this._syncMobileRemoteHangupIfNeeded('sip_session_closed')) return
- this._scheduleMobileRemoteHangupSync('mobile_sip_end', 400)
- return
- }
- if (this._isRemoteCallAlive()) {
- console.debug('[一键外呼] SIP结束但IPCC仍活跃,继续')
- return
- }
- this._completeCallHangup('sip')
- return
- }
- if (this.callState === 'ringing' || this.callState === 'connecting') {
- if (this._isRingingBridgeProtected()) {
- this._scheduleRemoteHangupCheck('sip_ringing_bridge', 2500)
- return
- }
- if (this._isRemoteCallAlive()) return
- this._completeCallHangup('sip')
- }
- },
- onIceMediaIssue: ({ state, timedOut } = {}) => {
- if (this.callState !== 'talking') return
- if (this._ipccRecovering && this._ipcCallAlive()) return
- const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
- if (sipAlive) {
- this.phone.resumeRemoteAudio()
- return
- }
- if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('ice_media')) return
- if (this._isRemoteCallAlive()) return
- if (timedOut || state === 'failed' || state === 'disconnected') {
- this._completeCallHangup('sip')
- }
- }
- }
- this.phone.On('OnAnswered', this.sipEventHandlers.onAnswered)
- this.phone.On('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
- this.phone.On('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
- },
- unbindSipEvents() {
- if (this.phone && this.sipEventHandlers) {
- try {
- this.phone.Off('OnAnswered', this.sipEventHandlers.onAnswered)
- this.phone.Off('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
- this.phone.Off('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
- } catch (e) {}
- }
- this.sipEventHandlers = null
- },
- startAudioHealthCheck() {
- this.stopAudioHealthCheck()
- const intervalMs = this._isMobileGatewayLine() ? 1000 : 2000
- this._audioHealthTimer = setInterval(() => {
- if (!['talking', 'ringing', 'connecting'].includes(this.callState) || !this.phone) {
- this.stopAudioHealthCheck()
- return
- }
- if (this.isHangingUp || this._completingHangup) return
- if (this._ipccRecovering && this._ipcCallAlive()) return
- if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('health_poll')) return
- const sipAlive = this.phone.hasActiveCall?.() || this.phone.hasSipSession?.()
- if (!sipAlive) {
- if (this._isMobileGatewayLine()) {
- if (this._syncMobileRemoteHangupIfNeeded('health_sip_gone')) return
- } else if (this._isRemoteCallAlive()) return
- if (this.callState === 'talking') {
- this._completeCallHangup('sip')
- return
- }
- if (this._isRingingBridgeProtected()) return
- this._completeCallHangup('sip')
- return
- }
- if (this.callState === 'talking') {
- this.phone.resumeRemoteAudio()
- }
- }, intervalMs)
- },
- stopAudioHealthCheck() {
- if (this._audioHealthTimer) {
- clearInterval(this._audioHealthTimer)
- this._audioHealthTimer = null
- }
- },
- rejectCCConnect(err) {
- if (this.ccConnectingReject) {
- this.ccConnectingReject(err)
- this.ccConnectingReject = null
- this.ccConnectingResolve = null
- }
- },
- async startSIP() {
- if (this.phone) {
- this.unbindSipEvents()
- try { this.phone.destroy() } catch (e) {}
- this.phone = null
- }
- const userProfile = this.profileManager.getCurrentUserProfile()
- if (!userProfile?.user || !userProfile?.domain || !userProfile?.password) {
- throw new Error('SIP账号配置不完整')
- }
- // 请求麦克风权限,确保音频可用
- try {
- await checkMicrophonePermission()
- } catch (err) {
- throw new Error('麦克风权限未授予,无法进行通话')
- }
- const settings = this.profileManager.getSettings()
- this.phone = new WebPhone(userProfile, settings)
- return new Promise((resolve, reject) => {
- this.sipReject = reject
- const timeoutId = setTimeout(() => {
- this.setStepStatus('jssip', 'error', 'JsSIP注册超时')
- reject(new Error('JsSIP注册超时'))
- }, TIMEOUT_SIP_REGISTER)
- this.phone.On('OnRegister', (event) => {
- if (event.registered) {
- this.sipRegistered = true
- this.sipReject = null
- clearTimeout(timeoutId)
- this.syncVolumeToPhone()
- if (this.ccPhoneBar && this.ccSocketConnected) {
- this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY)
- }
- resolve()
- }
- })
- this.phone.On('OnReconnectStatus', ({ failed }) => {
- if (failed) {
- clearTimeout(timeoutId)
- this.sipReject = null
- reject(new Error('JsSIP重连超时'))
- }
- })
- this.bindSipEvents()
- this.phone.Start(settings.reconnect)
- })
- },
- syncVolumeToPhone() {
- if (!this.phone) return
- try {
- this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
- this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
- } catch (err) {
- console.warn('[一键外呼] 同步音量失败:', err)
- }
- },
- async waitForCallingReady(maxWaitMs = TIMEOUT_AGENT_READY) {
- if (this.isCallingReady) return
- const startTime = Date.now()
- return new Promise((resolve, reject) => {
- const check = () => {
- if (this.initCancelled) return reject(new Error('初始化已取消'))
- if (this.isCallingReady) return resolve()
- if (Date.now() - startTime >= maxWaitMs) {
- this.setStepStatus('call', 'error', '坐席就绪等待超时')
- reject(new Error('坐席就绪等待超时'))
- } else {
- setTimeout(check, POLL_INTERVAL_AGENT)
- }
- }
- check()
- })
- },
- async autoCall() {
- if (!this.ccPhoneBar || !this.ccSocketConnected) {
- throw new Error('IPCC未连接')
- }
- if (!this.isCallingReady) {
- throw new Error('坐席未就绪,请稍候')
- }
- if (!this.decryptedPhone || this.decryptedPhone.length < 3) {
- throw new Error('号码无效')
- }
- this._forceReleaseIpccCallState()
- if (this.phone) {
- try {
- await this.phone.ensureLocalStream()
- this.isSpeakerMuted = false
- this.isMicMuted = false
- this.syncVolumeToPhone()
- this.phone.resumeRemoteAudio()
- } catch (e) {
- throw new Error('麦克风不可用,无法进行通话')
- }
- }
- try {
- const started = this.ccPhoneBar.call(this.decryptedPhone, this.effectiveDialType, VideoLevels.HD.levelId)
- if (started === false) {
- this._forceReleaseIpccCallState()
- throw new Error('外呼被拒绝,请稍后重试')
- }
- } catch (err) {
- this.setStepStatus('call', 'error', err.message)
- throw err
- }
- return new Promise((resolve) => {
- this.callStartResolve = () => {
- this.callStartResolve = null
- resolve()
- }
- })
- },
- handlePause() {
- if (!this.ccPhoneBar?.holdCall) return
- if (!this.ccSocketConnected || this._ipccRecovering) {
- console.warn('[一键外呼] IPCC未就绪,暂不可暂停')
- return
- }
- try { this.ccPhoneBar.holdCall() } catch (err) {
- console.warn('[一键外呼] 暂停失败:', err && err.message)
- }
- },
- handleResume() {
- if (!this.ccPhoneBar?.unHoldCall) return
- if (!this.ccSocketConnected || this._ipccRecovering) {
- console.warn('[一键外呼] IPCC未就绪,暂不可恢复')
- return
- }
- try { this.ccPhoneBar.unHoldCall() } catch (err) {
- console.warn('[一键外呼] 恢复失败:', err && err.message)
- }
- },
- toggleMicMute() {
- if (!this.phone) return
- this.isMicMuted = !this.isMicMuted
- this.phone.ToggleMicPhone()
- if (!this.isMicMuted) {
- this.micVolume = this.savedMicVolume || 80
- } else {
- this.savedMicVolume = this.micVolume
- }
- this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
- },
- toggleSpeakerMute() {
- if (!this.phone) return
- this.isSpeakerMuted = !this.isSpeakerMuted
- if (!this.isSpeakerMuted) {
- this.speakerVolume = this.savedSpeakerVolume || 80
- } else {
- this.savedSpeakerVolume = this.speakerVolume
- }
- this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
- },
- onMicVolumeChange(e) {
- const vol = parseInt(e.target.value, 10)
- if (isNaN(vol)) return
- this.micVolume = vol
- if (this.isMicMuted && vol > 0) this.isMicMuted = false
- if (this.phone) this.phone.SetMicPhone(this.isMicMuted, vol / 100)
- },
- onSpeakerVolumeChange(e) {
- const vol = parseInt(e.target.value, 10)
- if (isNaN(vol)) return
- this.speakerVolume = vol
- if (this.isSpeakerMuted && vol > 0) this.isSpeakerMuted = false
- if (this.phone) this.phone.SetSpeaker(this.isSpeakerMuted, vol / 100)
- },
- handleHangup() {
- if (this.isHangingUp || this.callState === 'ended') return
- this.isHangingUp = true
- this.clearHangupLockTimer()
- this.hangupLockTimer = setTimeout(() => {
- this.isHangingUp = false
- this.hangupLockTimer = null
- }, HANGUP_LOCK_TIMEOUT)
- this._completeCallHangup('user')
- },
- clearHangupLock() {
- this.isHangingUp = false
- this.clearHangupLockTimer()
- },
- clearHangupLockTimer() {
- if (this.hangupLockTimer) {
- clearTimeout(this.hangupLockTimer)
- this.hangupLockTimer = null
- }
- },
- _recoverAfterPageLoad() {
- try {
- // 刷新后强制解锁,避免上次异常导致下次无法外呼
- this._resetCallAvailability()
- this._initActiveGen = 0
- const rawSnap = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
- if (!rawSnap) {
- this._needReleaseStaleIpccSession = true
- return
- }
- const snap = JSON.parse(rawSnap)
- const age = Date.now() - (snap && snap.savedAt ? Number(snap.savedAt) : 0)
- const uuid = normalizeCallUuid(snap && snap.uuid)
- this._clearActiveCallSnapshot()
- this._needReleaseStaleIpccSession = true
- if (uuid && age >= 0 && age <= ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS) {
- console.warn('[一键外呼] recover stale call, background sync:', uuid)
- scheduleGlobalCallSync(uuid)
- } else {
- console.warn('[一键外呼] clear expired call snapshot')
- }
- } catch (e) {
- this._clearActiveCallSnapshot()
- this._needReleaseStaleIpccSession = true
- }
- },
- _clearHangupCloseFailSafe() {
- if (this._hangupCloseFailSafe) {
- clearTimeout(this._hangupCloseFailSafe)
- this._hangupCloseFailSafe = null
- }
- if (this._failCloseTimer) {
- clearTimeout(this._failCloseTimer)
- this._failCloseTimer = null
- }
- },
- _clearInitWatchdog() {
- if (this._initWatchdogTimer) {
- clearTimeout(this._initWatchdogTimer)
- this._initWatchdogTimer = null
- }
- },
- _armInitWatchdog(gen) {
- this._clearInitWatchdog()
- this._initWatchdogTimer = setTimeout(() => {
- this._initWatchdogTimer = null
- if (!this.isInitializing || gen !== this._initGeneration) return
- // 选线路等待用户操作,不算卡住,顺延一轮
- if (this.awaitingGatewaySelect) {
- this._armInitWatchdog(gen)
- return
- }
- console.warn('[一键外呼] init timeout, force unlock')
- this.cancelInit()
- this._resetCallAvailability()
- this.globalErrorMsg = '外呼初始化超时,请重试'
- this.$message.error(this.globalErrorMsg)
- this.destroyAll(true, null, { forceReset: true }).catch(() => {})
- this._failCloseTimer = setTimeout(() => {
- this._failCloseTimer = null
- this.doClose()
- }, CLOSE_AFTER_FAIL_MS)
- }, INIT_WATCHDOG_MS)
- },
- _forceUnlockCallGuards({ soft = false } = {}) {
- this.clearHangupLock()
- this._completingHangup = false
- this.isHangingUp = false
- this.isSyncPending = false
- this.isAutoClosing = false
- this.syncPhase = 'idle'
- this.syncPhaseText = ''
- this.gatewayConfirming = false
- this._clearHangupCloseFailSafe()
- this.cancelAutoClose()
- if (this.callState === 'ended') this.callState = 'idle'
- if (!soft) {
- this.isInitializing = false
- this.isReady = false
- this.awaitingGatewaySelect = false
- this._initActiveGen = 0
- this._clearInitWatchdog()
- }
- },
- _normalizeCallUuid(uuid) {
- return normalizeCallUuid(uuid)
- },
- _canCloseDialog() {
- if (this.isAutoClosing) return false
- if (this.callState === 'ended') return false
- if (['talking', 'ringing', 'connecting'].includes(this.callState)) return false
- // 选线路阶段允许关闭,关窗后必须能再次外呼
- if (this.isInitializing && !this.awaitingGatewaySelect) return false
- return true
- },
- _closeBlockReason() {
- if (this.callState === 'ended' || this.isAutoClosing) return '通话收尾中,请稍候'
- if (['talking', 'ringing', 'connecting'].includes(this.callState)) return '通话中请先挂断'
- if (this.isInitializing && !this.awaitingGatewaySelect) return '正在连接外呼,请稍候'
- return '当前不可关闭'
- },
- beforeDialogClose(done) {
- if (!this._canCloseDialog()) {
- this.$message.warning(this._closeBlockReason())
- done(false)
- return
- }
- done()
- },
- /**
- * 挂断收尾:前端只展示统一文案;2秒内关窗;3秒后后台同步话单(不展示同步细节)
- */
- startSyncAndClose(uuid) {
- const id = this._normalizeCallUuid(uuid)
- this.cancelAutoClose()
- this._clearHangupCloseFailSafe()
- this.isSyncPending = false
- this.isAutoClosing = true
- this.syncPhase = 'closing'
- this.syncPhaseText = ''
- this.callState = 'ended'
- this.stateText = '通话结束,系统回收资源中'
- this.statusClass = 'status-ended'
- // 后台延迟 3s 同步,与弹窗生命周期解耦,避免关窗丢同步 / 死锁
- scheduleGlobalCallSync(id)
- // 尽快回收连接资源(短超时,不拖住关窗)
- this.cleanupConnection(true).catch((e) => {
- console.warn('[一键外呼] 挂断后回收异常:', e && e.message)
- })
- // 最多 2 秒必须关窗(不展示同步进度)
- this._hangupCloseFailSafe = setTimeout(() => {
- this._hangupCloseFailSafe = null
- this.cancelAutoClose()
- this._resetCallAvailability()
- this.doClose()
- }, CLOSE_AFTER_HANGUP_MS)
- },
- cancelAutoClose() {
- if (this.autoCloseTimer) {
- clearInterval(this.autoCloseTimer)
- this.autoCloseTimer = null
- }
- this.autoCloseCountdown = 0
- this.isAutoClosing = false
- },
- doClose() {
- this.cancelAutoClose()
- this._clearHangupCloseFailSafe()
- this.isAutoClosing = false
- this.isSyncPending = false
- this.syncPhase = 'idle'
- this._resetCallAvailability()
- this.$emit('update:sipVisible', false)
- },
- startTimer() {
- this.stopTimer()
- this._lastCallTimerTickAt = Date.now()
- this.timerInterval = setInterval(() => {
- if (this.callStartTime) {
- const elapsed = Math.floor((Date.now() - this.callStartTime) / 1000)
- const mins = Math.floor(elapsed / 60).toString().padStart(2, '0')
- const secs = (elapsed % 60).toString().padStart(2, '0')
- this.callTimer = `${mins}:${secs}`
- this._lastCallTimerTickAt = Date.now()
- }
- }, 1000)
- },
- stopTimer() {
- if (this.timerInterval) {
- clearInterval(this.timerInterval)
- this.timerInterval = null
- }
- },
- handleClose() {
- // 关窗动画迟到回调:若已重新打开新会话,绝不能再动新流程
- if (this.sipVisible) {
- console.warn('[一键外呼] 忽略迟到的 close 回调,避免打断新外呼')
- return
- }
- if (['talking', 'ringing', 'connecting'].includes(this.callState)) {
- try { this.handleHangup() } catch (e) {}
- }
- this._resetCallAvailability()
- this.destroyAll(true, this._openRunId, { forceReset: true }).catch(() => {})
- },
- handleBeforeUnload() {
- const uuid = this._normalizeCallUuid(this.currentCallUuid)
- if (uuid && !GLOBAL_SYNCED_UUIDS.has(uuid) && !GLOBAL_SYNCING_UUIDS.has(uuid)) {
- const syncData = { uuid }
- if (navigator.sendBeacon) {
- const blob = new Blob([JSON.stringify(syncData)], { type: 'application/json' })
- navigator.sendBeacon('/api/aiSipCall/callEndSyncByUuid', blob)
- markGlobalSynced(uuid)
- } else {
- scheduleGlobalCallSync(uuid)
- }
- try {
- sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
- uuid,
- callState: this.callState,
- callDirection: this.callDirection,
- savedAt: Date.now()
- }))
- } catch (e) {}
- }
- if (this.ccPhoneBar && this.callState !== 'ended' && this.callState !== 'idle') {
- if (this.ccSocketConnected) {
- try { this.ccPhoneBar.hangup() } catch (e) {}
- }
- }
- try { this._forceReleaseIpccCallState() } catch (e) {}
- },
- async cleanupConnection(fast = false) {
- this.stopAudioHealthCheck()
- this._cancelRemoteHangupCheck()
- this._clearIpccReconnectState()
- this.unbindSipEvents()
- if (this.phone) {
- try { this.phone.releaseLocalStream() } catch (e) {}
- try { this.phone.destroy() } catch (e) {}
- this.phone = null
- }
- if (this.ccPhoneBar) {
- this._forceReleaseIpccCallState()
- const waitMs = fast ? 400 : (IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
- await this._gracefulDisconnectIpcc(waitMs)
- this.ccPhoneBar = null
- }
- },
- async destroyAll(skipSync = false, sessionToken = null, { forceReset = false } = {}) {
- const isStale = () => sessionToken != null && sessionToken !== this._openRunId
- if (isStale()) return
- this.stopTimer()
- this.cancelAutoClose()
- this.clearHangupLockTimer()
- // 仅清理本次开始时的连接引用,避免超时后误杀新会话
- const phoneRef = this.phone
- const ccRef = this.ccPhoneBar
- const inCall = ['talking', 'ringing', 'connecting'].includes(this.callState)
- if (!skipSync && inCall && !this.isHangingUp && ccRef) {
- this.isHangingUp = true
- if (this.ccSocketConnected) {
- try { ccRef.hangup() } catch (e) {}
- } else {
- this._pendingIpccHangup = true
- this._persistActiveCallSnapshot()
- try { this._scheduleIpccReconnect(true, 0) } catch (e) {}
- }
- if (this.currentCallUuid) {
- scheduleGlobalCallSync(this.currentCallUuid)
- }
- }
- if (isStale()) return
- try {
- await Promise.race([
- this._cleanupConnectionRefs(phoneRef, ccRef),
- new Promise((resolve) => setTimeout(resolve, 2500))
- ])
- } catch (e) {
- console.warn('[一键外呼] cleanupConnection 异常:', e && e.message)
- if (this.phone === phoneRef) this.phone = null
- if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
- }
- // 超时竞态:若已进入新初始化,禁止迟到的 destroy 清空步骤
- if (isStale()) return
- // 关窗强制复位;进行中的新会话(弹窗可见)才跳过,避免打断选线路/通话
- if (!forceReset && this.sipVisible && (this.isInitializing || this.isReady || this.awaitingGatewaySelect)) {
- console.warn('[一键外呼] destroyAll 跳过 resetState,避免打断进行中的外呼流程')
- return
- }
- this.resetStateWithoutCancelSync()
- if (forceReset) {
- this._releaseBootLocks()
- this._recoverAfterCancelCount = 0
- this.initCancelled = false
- this.isInitializing = false
- this.awaitingGatewaySelect = false
- this.gatewayConfirming = false
- this._initActiveGen = 0
- }
- },
- async _cleanupConnectionRefs(phoneRef, ccRef) {
- this.stopAudioHealthCheck()
- this._cancelRemoteHangupCheck()
- this._clearIpccReconnectState()
- this.unbindSipEvents()
- if (phoneRef && this.phone === phoneRef) {
- try { phoneRef.releaseLocalStream() } catch (e) {}
- try { phoneRef.destroy() } catch (e) {}
- if (this.phone === phoneRef) this.phone = null
- }
- if (ccRef && this.ccPhoneBar === ccRef) {
- this._forceReleaseIpccCallState()
- await this._gracefulDisconnectIpcc(IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
- if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
- }
- },
- },
- beforeDestroy() {
- if (this.beforeUnloadHandler) {
- window.removeEventListener('beforeunload', this.beforeUnloadHandler)
- }
- this._clearInitWatchdog()
- this._clearHangupCloseFailSafe()
- this.cancelInit()
- this._resetCallAvailability()
- this.destroyAll(true, null, { forceReset: true })
- }
- }
- </script>
- <style scoped>
- .call-content { padding: 0; }
- .dialog-title {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 15px;
- font-weight: 600;
- color: #303133;
- }
- .title-icon { color: #409eff; }
- .progress-bar-container { padding: 4px 0 12px; }
- .progress-track {
- height: 3px;
- background: #ebeef5;
- border-radius: 2px;
- margin-bottom: 12px;
- overflow: hidden;
- }
- .progress-fill {
- height: 100%;
- background: linear-gradient(90deg, #409eff, #67c23a);
- transition: width 0.4s ease;
- }
- .progress-steps { display: flex; justify-content: space-between; gap: 4px; }
- .progress-step {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- flex: 1;
- }
- .step-dot {
- width: 24px;
- height: 24px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.3s ease;
- }
- .step-pending .step-dot { background: #f5f7fa; border: 2px solid #dcdfe6; }
- .step-loading .step-dot { background: #ecf5ff; border: 2px solid #409eff; }
- .step-success .step-dot { background: #67c23a; border: 2px solid #67c23a; }
- .step-error .step-dot { background: #f56c6c; border: 2px solid #f56c6c; }
- .step-num { font-size: 10px; font-weight: 600; color: #909399; }
- .step-dot .el-icon-loading { font-size: 12px; color: #409eff; }
- .step-label { font-size: 9px; font-weight: 500; white-space: nowrap; }
- .step-pending .step-label,
- .step-error .step-label { color: #c0c4cc; }
- .step-loading .step-label { color: #409eff; }
- .step-success .step-label { color: #303133; }
- .error-bar {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 6px 10px;
- margin-bottom: 8px;
- background: #fef0f0;
- border-radius: 6px;
- color: #f56c6c;
- font-size: 11px;
- }
- .retry-btn {
- background: #f56c6c;
- color: white;
- border: none;
- border-radius: 4px;
- padding: 2px 8px;
- cursor: pointer;
- font-size: 11px;
- }
- .call-area {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 8px 0 4px;
- }
- .phone-number {
- font-size: 22px;
- font-weight: 700;
- color: #1a1a2e;
- letter-spacing: 2px;
- margin-bottom: 14px;
- }
- .phone-icon-wrap { margin-bottom: 12px; }
- .phone-icon-bg {
- position: relative;
- width: 64px;
- height: 64px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #f0f5ff;
- }
- .icon-talking .phone-icon-bg { background: #f0f9eb; }
- .pulse-ring-1, .pulse-ring-2 {
- position: absolute;
- inset: 0;
- border-radius: 50%;
- border: 2px solid #409eff;
- opacity: 0;
- animation: pulse-expand 1.8s ease-out infinite;
- }
- .pulse-ring-2 { animation-delay: 0.6s; }
- @keyframes pulse-expand {
- 0% { transform: scale(0.85); opacity: 0.6; }
- 100% { transform: scale(1.5); opacity: 0; }
- }
- .icon-pulsing svg { animation: icon-bounce 1s ease-in-out infinite; }
- .icon-ringing svg { animation: icon-ring 0.5s ease-in-out infinite; }
- @keyframes icon-bounce {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-3px); }
- }
- @keyframes icon-ring {
- 0%, 100% { transform: rotate(0deg); }
- 25% { transform: rotate(6deg); }
- 75% { transform: rotate(-6deg); }
- }
- .wave-bar {
- position: absolute;
- bottom: 6px;
- width: 2px;
- background: #67c23a;
- border-radius: 1px;
- animation: wave-jump 0.8s ease-in-out infinite;
- }
- .w1 { height: 8px; left: calc(50% - 12px); animation-delay: 0s; }
- .w2 { height: 13px; left: calc(50% - 5px); animation-delay: 0.15s; }
- .w3 { height: 8px; left: calc(50% + 8px); animation-delay: 0.3s; }
- @keyframes wave-jump {
- 0%, 100% { transform: scaleY(0.6); }
- 50% { transform: scaleY(1.2); }
- }
- .call-state-text {
- text-align: center;
- font-size: 13px;
- font-weight: 600;
- margin-bottom: 12px;
- }
- .call-timer {
- font-size: 16px;
- font-family: monospace;
- color: #303133;
- display: block;
- margin-top: 2px;
- }
- .status-connecting { color: #e6a23c; }
- .status-ringing { color: #409eff; }
- .status-talking { color: #4caf50; }
- .status-ended { color: #909399; }
- .status-hold { color: #e6a23c; }
- .volume-controls {
- display: flex;
- gap: 16px;
- padding: 6px 12px;
- margin-bottom: 10px;
- background: #f8fafc;
- border-radius: 20px;
- width: 100%;
- justify-content: center;
- }
- .volume-item {
- display: flex;
- align-items: center;
- gap: 6px;
- flex: 1;
- }
- .volume-icon {
- width: 26px;
- height: 26px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #ecf5ff;
- cursor: pointer;
- color: #409eff;
- flex-shrink: 0;
- }
- .volume-icon.muted { background: #fef0f0; color: #f56c6c; }
- .volume-slider {
- flex: 1;
- height: 3px;
- -webkit-appearance: none;
- background: #e4e7ed;
- border-radius: 2px;
- outline: none;
- }
- .volume-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: #409eff;
- cursor: pointer;
- }
- .volume-value { font-size: 10px; color: #909399; width: 32px; text-align: right; }
- .call-actions {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 20px;
- margin: 4px 0;
- }
- .action-btn {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 4px;
- background: none;
- border: none;
- cursor: pointer;
- color: #fff;
- transition: transform 0.1s;
- }
- .action-btn:active { transform: scale(0.96); }
- .action-btn span { font-size: 10px; margin-top: 2px; }
- .action-btn svg { width: 36px; height: 36px; padding: 8px; border-radius: 50%; }
- .hangup-btn svg { background: #f56c6c; color: #fff; box-shadow: 0 2px 8px rgba(245,108,108,0.3); }
- .pause-btn svg { background: #e6a23c; box-shadow: 0 2px 8px rgba(230,162,60,0.3); }
- .resume-btn svg { background: #67c23a; box-shadow: 0 2px 8px rgba(103,194,58,0.3); }
- .ended-notice {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 5px;
- margin-top: 8px;
- font-size: 11px;
- color: #909399;
- }
- .ended-notice svg {
- animation: notice-pulse 2s ease-in-out infinite;
- }
- @keyframes notice-pulse {
- 0%, 100% { opacity: 0.6; }
- 50% { opacity: 1; }
- }
- .gateway-picker {
- margin-top: 4px;
- padding: 12px;
- background: linear-gradient(180deg, #f3f8ff 0%, #fafcff 100%);
- border: 1px solid #d9e8ff;
- border-radius: 12px;
- }
- .gateway-picker-head { margin-bottom: 10px; }
- .gateway-picker-title {
- font-size: 15px;
- font-weight: 600;
- color: #1f2d3d;
- }
- .gateway-picker-sub {
- margin-top: 4px;
- font-size: 12px;
- color: #8a97a8;
- }
- .gateway-list {
- max-height: 240px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .gateway-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 10px 12px;
- background: #fff;
- border: 1px solid #e4eaf2;
- border-radius: 10px;
- cursor: pointer;
- transition: all .18s ease;
- }
- .gateway-card:hover { border-color: #9ec5ff; box-shadow: 0 2px 8px rgba(31,111,235,.08); }
- .gateway-card.selected {
- border-color: #1f6feb;
- background: linear-gradient(90deg, #eef5ff 0%, #ffffff 70%);
- box-shadow: inset 3px 0 0 #1f6feb;
- }
- .gateway-card.theme-mobile.selected { box-shadow: inset 3px 0 0 #12b886; border-color: #69db9d; }
- .gateway-name {
- font-size: 13px;
- font-weight: 600;
- color: #1f2d3d;
- line-height: 1.35;
- }
- .gateway-meta {
- margin-top: 4px;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- font-size: 12px;
- color: #909399;
- }
- .gateway-side {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
- }
- .gateway-badge {
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 11px;
- background: #eef2f7;
- color: #606266;
- }
- .gateway-card.theme-mobile .gateway-badge { background: #e6fcf5; color: #0ca678; }
- .gateway-card.theme-landline .gateway-badge { background: #e7f1ff; color: #1f6feb; }
- .gateway-check { color: #1f6feb; font-size: 16px; font-weight: 700; }
- .gateway-actions {
- margin-top: 12px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- }
- .gateway-selected-tip {
- flex: 1;
- font-size: 12px;
- color: #1f6feb;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .gateway-confirm-btn {
- border: none;
- outline: none;
- height: 36px;
- padding: 0 16px;
- border-radius: 8px;
- background: linear-gradient(135deg, #1f6feb 0%, #3b82f6 100%);
- color: #fff;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- box-shadow: 0 4px 12px rgba(31,111,235,.28);
- }
- .gateway-confirm-btn:disabled {
- opacity: .55;
- cursor: not-allowed;
- box-shadow: none;
- }
- </style>
- <style>
- .call-phone-dialog { border-radius: 12px; overflow: hidden; }
- .call-phone-dialog .el-dialog__header { padding: 14px 20px 10px; border-bottom: 1px solid #f0f0f0; }
- .call-phone-dialog .el-dialog__body { padding: 12px 20px 18px; }
- .call-phone-dialog .el-dialog__headerbtn { top: 14px; right: 18px; }
- </style>
|