sipCallPhoneDialog.vue 97 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802
  1. <template>
  2. <el-dialog
  3. :visible.sync="dialogVisible"
  4. :width="awaitingGatewaySelect ? '560px' : '440px'"
  5. append-to-body
  6. :close-on-click-modal="false"
  7. :close-on-press-escape="false"
  8. :show-close="canManualClose"
  9. :before-close="beforeDialogClose"
  10. @opened="onDialogOpened"
  11. @close="handleClose"
  12. custom-class="call-phone-dialog"
  13. >
  14. <template slot="title">
  15. <div class="dialog-title">
  16. <svg class="title-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
  17. <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"/>
  18. </svg>
  19. <span>一键外呼</span>
  20. </div>
  21. </template>
  22. <div class="call-content">
  23. <!-- 横向进度条 -->
  24. <div class="progress-bar-container">
  25. <div class="progress-track">
  26. <div class="progress-fill" :style="{ width: progressPercent + '%' }" />
  27. </div>
  28. <div class="progress-steps">
  29. <div
  30. v-for="(step, index) in flowSteps"
  31. :key="step.key"
  32. class="progress-step"
  33. :class="'step-' + step.status"
  34. >
  35. <div class="step-dot">
  36. <span class="step-num" v-if="step.status === 'pending'">{{ index + 1 }}</span>
  37. <i class="el-icon-loading" v-else-if="step.status === 'loading'"></i>
  38. <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">
  39. <polyline points="20 6 9 17 4 12"/>
  40. </svg>
  41. <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">
  42. <line x1="18" y1="6" x2="6" y2="18"/>
  43. <line x1="6" y1="6" x2="18" y2="18"/>
  44. </svg>
  45. </div>
  46. <span class="step-label">{{ step.title }}</span>
  47. </div>
  48. </div>
  49. </div>
  50. <!-- 错误提示与重试 -->
  51. <div class="error-bar" v-if="hasStepError && globalErrorMsg">
  52. <i class="el-icon-warning" />
  53. <span>{{ globalErrorMsg }}</span>
  54. <button class="retry-btn" @click="retryInit" v-if="callState === 'idle'">重试</button>
  55. </div>
  56. <!-- 线路选择(必须选择后才能继续) -->
  57. <div class="gateway-picker" v-if="awaitingGatewaySelect" v-loading="gatewayListLoading">
  58. <div class="gateway-picker-head">
  59. <div class="gateway-picker-title">选择外呼线路</div>
  60. <div class="gateway-picker-sub">请选择线路后再继续外呼,未选择无法发起通话</div>
  61. </div>
  62. <div class="gateway-list">
  63. <div
  64. v-for="item in gatewayList"
  65. :key="item.id"
  66. class="gateway-card"
  67. :class="{
  68. selected: String(selectedGatewayId) === String(item.id),
  69. ['theme-' + getGatewayBadge(item).theme]: true
  70. }"
  71. @click="selectGateway(item.id)"
  72. >
  73. <div class="gateway-card-main">
  74. <div class="gateway-name">{{ item.gwDesc || item.gwName || ('线路 ' + item.id) }}</div>
  75. <div class="gateway-meta" v-if="item.caller || item.gwName">
  76. <span v-if="item.gwName && item.gwDesc !== item.gwName">{{ item.gwName }}</span>
  77. <span v-if="item.caller">主叫 {{ item.caller }}</span>
  78. </div>
  79. </div>
  80. <div class="gateway-side">
  81. <span class="gateway-badge">{{ getGatewayBadge(item).text }}</span>
  82. <i class="el-icon-check gateway-check" v-if="String(selectedGatewayId) === String(item.id)"></i>
  83. </div>
  84. </div>
  85. </div>
  86. <div class="gateway-actions">
  87. <div class="gateway-selected-tip" v-if="selectedGatewayTitle">
  88. 已选:{{ selectedGatewayTitle }}
  89. </div>
  90. <button
  91. class="gateway-confirm-btn"
  92. :disabled="!selectedGatewayId || gatewayConfirming"
  93. @click="confirmGatewaySelect"
  94. >
  95. <i class="el-icon-loading" v-if="gatewayConfirming"></i>
  96. <span>{{ gatewayConfirming ? '连接中...' : '确认线路并外呼' }}</span>
  97. </button>
  98. </div>
  99. </div>
  100. <!-- 外呼号码显示区域 -->
  101. <div class="call-area" v-if="isReady && !awaitingGatewaySelect">
  102. <div class="phone-number">{{ maskedPhone }}</div>
  103. <!-- 电话图标 + 动画 -->
  104. <div class="phone-icon-wrap" :class="iconAnimClass">
  105. <div class="phone-icon-bg">
  106. <span class="pulse-ring-1" v-if="callState === 'connecting' || callState === 'ringing'" />
  107. <span class="pulse-ring-2" v-if="callState === 'connecting' || callState === 'ringing'" />
  108. <span class="wave-bar w1" v-if="callState === 'talking' && !isOnHold" />
  109. <span class="wave-bar w2" v-if="callState === 'talking' && !isOnHold" />
  110. <span class="wave-bar w3" v-if="callState === 'talking' && !isOnHold" />
  111. <svg viewBox="0 0 24 24" width="32" height="32" fill="none" :stroke="iconStroke" stroke-width="2">
  112. <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"/>
  113. </svg>
  114. </div>
  115. </div>
  116. <!-- 通话状态文字 -->
  117. <div class="call-state-text" :class="statusClass">
  118. <span>{{ stateText }}</span>
  119. <span class="call-timer" v-if="callTimer && callState === 'talking'">{{ callTimer }}</span>
  120. </div>
  121. <!-- 音量控制(紧凑单行布局) -->
  122. <div class="volume-controls" v-if="callState === 'talking' && !isOnHold">
  123. <div class="volume-item">
  124. <div class="volume-icon" :class="{ muted: isMicMuted }" @click="toggleMicMute">
  125. <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
  126. <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
  127. <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
  128. <line v-if="isMicMuted" x1="1" y1="1" x2="23" y2="23"/>
  129. </svg>
  130. </div>
  131. <input type="range" class="volume-slider" min="0" max="100" :value="isMicMuted ? 0 : micVolume" @input="onMicVolumeChange" />
  132. <span class="volume-value">{{ isMicMuted ? 0 : micVolume }}%</span>
  133. </div>
  134. <div class="volume-item">
  135. <div class="volume-icon" :class="{ muted: isSpeakerMuted }" @click="toggleSpeakerMute">
  136. <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
  137. <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
  138. <line v-if="isSpeakerMuted || speakerVolume === 0" x1="23" y1="9" x2="17" y2="15"/>
  139. <line v-if="isSpeakerMuted || speakerVolume === 0" x1="17" y1="9" x2="23" y2="15"/>
  140. <path v-else d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
  141. </svg>
  142. </div>
  143. <input type="range" class="volume-slider" min="0" max="100" :value="isSpeakerMuted ? 0 : speakerVolume" @input="onSpeakerVolumeChange" />
  144. <span class="volume-value">{{ isSpeakerMuted ? 0 : speakerVolume }}%</span>
  145. </div>
  146. </div>
  147. <!-- 操作按钮 -->
  148. <div class="call-actions">
  149. <template v-if="callState === 'connecting' || callState === 'ringing'">
  150. <button class="action-btn hangup-btn" @click="handleHangup">
  151. <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
  152. <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)"/>
  153. </svg>
  154. <span>挂断</span>
  155. </button>
  156. </template>
  157. <template v-if="callState === 'talking'">
  158. <button class="action-btn hangup-btn" @click="handleHangup">
  159. <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
  160. <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)"/>
  161. </svg>
  162. <span>挂断</span>
  163. </button>
  164. <button class="action-btn" :class="isOnHold ? 'resume-btn' : 'pause-btn'" @click="isOnHold ? handleResume() : handlePause()">
  165. <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
  166. <rect v-if="!isOnHold" x="6" y="4" width="4" height="16" rx="1"/>
  167. <rect v-if="!isOnHold" x="14" y="4" width="4" height="16" rx="1"/>
  168. <polygon v-else points="5 3 19 12 5 21 5 3"/>
  169. </svg>
  170. <span>{{ isOnHold ? '恢复' : '暂停' }}</span>
  171. </button>
  172. </template>
  173. </div>
  174. <!-- 通话已结束:统一文案,后台同步不展示细节 -->
  175. <div class="ended-notice" v-if="callState === 'ended'">
  176. <i class="el-icon-loading"></i>
  177. <span>通话结束,系统回收资源中</span>
  178. </div>
  179. </div>
  180. </div>
  181. </el-dialog>
  182. </template>
  183. <script>
  184. import axios from 'axios'
  185. import { WebPhone, ProfileManager, checkMicrophonePermission, IPCC_DEFAULTS, JS_SIP_DEFAULTS } from '@/api/aiSipCall/softPhone.js'
  186. import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket.js'
  187. import { EventList, VideoLevels, AgentStatusEnum } from '@/assets/callCenterPhoneBarSdk/constants.js'
  188. import { myCallUser, getToolbarBasicParam} from '@/api/aiSipCall/aiSipCallUser.js'
  189. import {remoteGatewayList} from '@/api/aiSipCall/aiSipCallGateway.js'
  190. import { encryptMobile, callEndSyncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr.js'
  191. const IPCC_CONFIG = IPCC_DEFAULTS
  192. const JS_SIP_CONFIG = JS_SIP_DEFAULTS
  193. const TIMEOUT_DECRYPT = 6000
  194. const TIMEOUT_IPCC_CONNECT = IPCC_CONFIG.CONNECT_TIMEOUT || 8000
  195. const TIMEOUT_SIP_REGISTER = 10000
  196. const TIMEOUT_AGENT_READY = 10000
  197. const POLL_INTERVAL_AGENT = 150
  198. const DELAY_STEP_TRANSITION = 400
  199. /** 正常挂断后延迟同步话单 */
  200. const DELAY_SYNC_CALL_RECORD = 3000
  201. /** 挂断后最多多久必须关窗 */
  202. const CLOSE_AFTER_HANGUP_MS = 2000
  203. const HANGUP_LOCK_TIMEOUT = 30000
  204. const OUTBOUND_BRIDGE_PROTECT_MS = 45000
  205. const IPCC_GATEWAY_RELEASE_DELAY_MS = IPCC_CONFIG.GATEWAY_RELEASE_DELAY_MS || 500
  206. const MOBILE_GATEWAY_MATCH = ['润天售后', '润天售后外呼', '手机外呼']
  207. const ACTIVE_CALL_STORAGE_KEY = 'sip_call_dialog_active_call_v1'
  208. const GLOBAL_SYNCED_UUIDS = new Set()
  209. const GLOBAL_SYNCING_UUIDS = new Set()
  210. const GLOBAL_SYNCED_UUID_MAX = 300
  211. const ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS = 2 * 60 * 1000
  212. const INIT_WATCHDOG_MS = 60000
  213. /** 流程中断后尽快关窗 */
  214. const CLOSE_AFTER_FAIL_MS = 1500
  215. /** 同一次弹窗内,取消后自动恢复启动的最大次数,防止死循环 */
  216. const MAX_RECOVER_AFTER_CANCEL = 1
  217. function normalizeCallUuid(uuid) {
  218. if (uuid == null || uuid === '') return ''
  219. return String(uuid).trim()
  220. }
  221. function markGlobalSynced(id) {
  222. if (!id) return
  223. GLOBAL_SYNCED_UUIDS.add(id)
  224. GLOBAL_SYNCING_UUIDS.delete(id)
  225. while (GLOBAL_SYNCED_UUIDS.size > GLOBAL_SYNCED_UUID_MAX) {
  226. const first = GLOBAL_SYNCED_UUIDS.values().next().value
  227. GLOBAL_SYNCED_UUIDS.delete(first)
  228. }
  229. }
  230. /** 弹窗关闭后仍可后台同步,避免关窗导致话单丢失 / 死锁 */
  231. function scheduleGlobalCallSync(uuid) {
  232. const id = normalizeCallUuid(uuid)
  233. if (!id) return
  234. if (GLOBAL_SYNCED_UUIDS.has(id) || GLOBAL_SYNCING_UUIDS.has(id)) return
  235. GLOBAL_SYNCING_UUIDS.add(id)
  236. setTimeout(() => {
  237. callEndSyncByUuid({ uuid: id })
  238. .then((res) => {
  239. markGlobalSynced(id)
  240. })
  241. .catch((err) => {
  242. markGlobalSynced(id)
  243. console.warn('[一键外呼] 后台同步失败(已忽略):', id, err && err.message)
  244. })
  245. }, DELAY_SYNC_CALL_RECORD)
  246. }
  247. export default {
  248. name: 'CallPhoneDialog',
  249. props: {
  250. /** 弹窗显隐,配合 .sync / update:sipVisible */
  251. sipVisible: { type: Boolean, default: false },
  252. /** 加密手机号(与 sipPlainPhone 二选一,优先加密) */
  253. sipEncryptedPhone: { type: String, default: '' },
  254. sipEncryptedUserId: { type: Number, default: '' },
  255. /** 明文手机号(无加密号时使用,跳过解密步骤) */
  256. sipPlainPhone: { type: String, default: '' },
  257. /** 通话类型 audio/video */
  258. sipDialType: { type: String, default: 'audio' },
  259. /** 业务关联 ID,挂断后通过 sip-call-ended 回传 */
  260. sipRecordId: { type: [String, Number], default: '' }
  261. },
  262. data() {
  263. return {
  264. flowSteps: [
  265. { key: 'decrypt', title: '解析号码', status: 'pending', errorMsg: '' },
  266. { key: 'gateway', title: '选择线路', status: 'pending', errorMsg: '' },
  267. { key: 'ipcc', title: '连接座席', status: 'pending', errorMsg: '' },
  268. { key: 'jssip', title: '注册分机', status: 'pending', errorMsg: '' },
  269. { key: 'call', title: '发起外呼', status: 'pending', errorMsg: '' }
  270. ],
  271. ipccConnected: false,
  272. sipRegistered: false,
  273. isReady: false,
  274. globalErrorMsg: '',
  275. decryptedPhone: '',
  276. maskedPhone: '',
  277. isDecrypting: false,
  278. decryptCancelTokenSource: null,
  279. callState: 'idle',
  280. isOnHold: false,
  281. callTimer: '',
  282. stateText: '',
  283. statusClass: '',
  284. timerInterval: null,
  285. callStartTime: null,
  286. currentCallUuid: '',
  287. ccPhoneBar: null,
  288. phone: null,
  289. profileManager: null,
  290. ccSocketConnected: false,
  291. ccConnectingResolve: null,
  292. ccConnectingReject: null,
  293. ccConnectingPromise: null,
  294. isCallingReady: false,
  295. currentUserDisplay: '',
  296. autoCloseCountdown: 0,
  297. autoCloseTimer: null,
  298. isAutoClosing: false,
  299. _hangupCloseFailSafe: null,
  300. isSyncPending: false,
  301. syncPhase: 'idle',
  302. syncPhaseText: '',
  303. micVolume: 80,
  304. speakerVolume: 80,
  305. isMicMuted: false,
  306. isSpeakerMuted: false,
  307. savedMicVolume: 80,
  308. savedSpeakerVolume: 80,
  309. initCancelled: false,
  310. isInitializing: false,
  311. sipReject: null,
  312. callStartResolve: null,
  313. isHangingUp: false,
  314. hangupLockTimer: null,
  315. ccEventRefs: [],
  316. sipEventHandlers: null,
  317. callDirection: '',
  318. _bridgeStartedAt: 0,
  319. _suppressIpccHangupUntil: 0,
  320. _callEstablishedAt: 0,
  321. _lastAsrAt: 0,
  322. _ipccRecovering: false,
  323. _completingHangup: false,
  324. _audioHealthTimer: null,
  325. _remoteHangupCheckTimer: null,
  326. _lastCallTimerTickAt: 0,
  327. _ipccReconnectTimer: null,
  328. _ipccReconnecting: false,
  329. _ipccReconnectFailCount: 0,
  330. _ccConnectingTimeoutId: null,
  331. _ccConnectStartedAt: 0,
  332. _ipccConnectedAt: 0,
  333. _pendingIpccHangup: false,
  334. gatewayList: [],
  335. connectedGatewayIds: [],
  336. selectedGatewayId: null,
  337. awaitingGatewaySelect: false,
  338. gatewayConfirming: false,
  339. gatewayListLoading: false,
  340. _gatewaySelectResolve: null,
  341. _gatewaySelectReject: null,
  342. _pendingExtInfo: null,
  343. beforeUnloadHandler: null,
  344. _initGeneration: 0,
  345. _openRunId: 0,
  346. _syncLockFailSafeTimer: null,
  347. _initWatchdogTimer: null,
  348. _needReleaseStaleIpccSession: false,
  349. /** 防止 opened / watch / mounted 重复启动 */
  350. _bootStarted: false,
  351. _booting: false,
  352. /** 当前正在执行的 initComponent 代数;cancel 时清零,避免 finally 与抬代互相卡死 */
  353. _initActiveGen: 0,
  354. /** 本次弹窗内 cancel 后自动恢复次数 */
  355. _recoverAfterCancelCount: 0
  356. }
  357. },
  358. computed: {
  359. dialogVisible: {
  360. get() { return this.sipVisible },
  361. set(val) {
  362. if (!val && !this._canCloseDialog()) {
  363. this.$message.warning(this._closeBlockReason())
  364. return
  365. }
  366. this.$emit('update:sipVisible', val)
  367. }
  368. },
  369. canManualClose() {
  370. return this._canCloseDialog()
  371. },
  372. effectiveDialType() { return this.sipDialType || 'audio' },
  373. effectiveRecordId() { return this.sipRecordId },
  374. selectedGateway() {
  375. if (!this.selectedGatewayId) return null
  376. return this.gatewayList.find(g => String(g.id) === String(this.selectedGatewayId)) || null
  377. },
  378. selectedGatewayTitle() {
  379. const g = this.selectedGateway
  380. if (!g) return ''
  381. return g.gwDesc || g.gwName || ('线路 ' + g.id)
  382. },
  383. hasStepError() { return this.flowSteps.some(s => s.status === 'error') },
  384. progressPercent() {
  385. const done = this.flowSteps.filter(s => s.status === 'success').length
  386. const total = this.flowSteps.length
  387. const current = this.flowSteps.findIndex(s => s.status === 'loading')
  388. if (current >= 0) return ((current + 0.5) / total) * 100
  389. return (done / total) * 100
  390. },
  391. iconAnimClass() {
  392. if (this.callState === 'connecting') return 'icon-pulsing'
  393. if (this.callState === 'ringing') return 'icon-ringing'
  394. if (this.callState === 'talking') return this.isOnHold ? 'icon-hold' : 'icon-talking'
  395. return ''
  396. },
  397. iconStroke() {
  398. if (this.callState === 'talking' && !this.isOnHold) return '#4caf50'
  399. if (this.callState === 'connecting' || this.callState === 'ringing') return '#409eff'
  400. return '#409eff'
  401. }
  402. },
  403. watch: {
  404. sipVisible(val, oldVal) {
  405. // 关闭时清理;打开启动改由 @opened / mounted 触发,避免 watch 与弹窗生命周期竞态
  406. if (!val && oldVal === true) {
  407. this._teardownCallSession()
  408. }
  409. }
  410. },
  411. created() {
  412. // Vue2 不会把 data 里 `_` 开头字段代理到 this,必须挂到实例上,否则 ++undefined => NaN
  413. this._openRunId = 0
  414. this._initGeneration = 0
  415. this._initActiveGen = 0
  416. this._bootStarted = false
  417. this._booting = false
  418. this._recoverAfterCancelCount = 0
  419. this._gatewaySelectResolve = null
  420. this._gatewaySelectReject = null
  421. this._pendingExtInfo = null
  422. this._recoverAfterPageLoad()
  423. },
  424. mounted() {
  425. this.beforeUnloadHandler = (event) => {
  426. this.handleBeforeUnload()
  427. }
  428. window.addEventListener('beforeunload', this.beforeUnloadHandler)
  429. // v-if 挂载且可见时:部分 Element 版本不一定立刻抛 opened,这里兜底启动
  430. if (this.sipVisible) {
  431. this.$nextTick(() => {
  432. this._healStaleBootLocks('mounted')
  433. this._scheduleBoot('mounted')
  434. })
  435. }
  436. },
  437. methods: {
  438. setStepStatus(key, status, errorMsg = '') {
  439. const step = this.flowSteps.find(s => s.key === key)
  440. if (step) {
  441. step.status = status
  442. if (errorMsg) step.errorMsg = errorMsg
  443. }
  444. },
  445. /** 弹窗动画完成后再启动,保证 DOM 已就绪 */
  446. onDialogOpened() {
  447. this._healStaleBootLocks('opened')
  448. this._scheduleBoot('opened')
  449. },
  450. /**
  451. * 打开时自愈:清理「无真实流程却占着启动锁」的残留,避免永远跳过启动
  452. */
  453. _healStaleBootLocks(from) {
  454. if (!this.sipVisible) return
  455. const inLiveFlow = this.awaitingGatewaySelect || this.isReady ||
  456. ['talking', 'ringing', 'connecting', 'ended'].includes(this.callState)
  457. if (inLiveFlow) return
  458. // 真实 init / boot 进行中不干预
  459. if (this._initActiveGen || this._booting) return
  460. if (this._bootStarted || this.isInitializing || this.initCancelled) {
  461. console.warn('[一键外呼] 清除残留启动锁, from=', from)
  462. this._bootStarted = false
  463. this.isInitializing = false
  464. this.initCancelled = false
  465. this.gatewayConfirming = false
  466. }
  467. },
  468. /**
  469. * 统一启动入口(防重入)
  470. * @param {string} from 调用来源,便于排查
  471. */
  472. _scheduleBoot(from) {
  473. if (!this.sipVisible) return
  474. if (this._bootStarted || this._booting || this.isInitializing) {
  475. return
  476. }
  477. // 已进入选线路 / 通话中则不再重启
  478. if (this.awaitingGatewaySelect || this.isReady) return
  479. this._bootStarted = true
  480. this._bootCallSession()
  481. },
  482. /** 打开弹窗后启动流程 */
  483. async _bootCallSession() {
  484. if (this._booting) return
  485. this._booting = true
  486. const token = ++this._openRunId
  487. // 注意:打开时不要 cancelInit,否则会把即将开始的初始化标成已取消
  488. this.initCancelled = false
  489. this._recoverAfterCancelCount = 0
  490. this._clearHangupCloseFailSafe()
  491. this._forceUnlockCallGuards({ soft: true })
  492. // 同步立刻更新 UI,避免“只弹窗、步骤全 pending”的观感
  493. this.setStepStatus('decrypt', 'loading')
  494. try {
  495. // 无残留连接时跳过 destroy,避免 Promise.race 超时后迟到的 cleanup 打断初始化
  496. const needCleanup = !!(this.phone || this.ccPhoneBar || this.isReady)
  497. if (needCleanup) {
  498. await this._destroyAllWithTimeout(true, 3000, token)
  499. }
  500. if (token !== this._openRunId || !this.sipVisible) {
  501. console.warn('[一键外呼] 启动已失效, token=', token, 'openRunId=', this._openRunId, 'visible=', this.sipVisible)
  502. // 仅本轮 token 仍有效时才释锁,避免误伤已经开始的新会话
  503. if (token === this._openRunId) this._releaseBootLocks()
  504. return
  505. }
  506. this.initCancelled = false
  507. await this.initComponent()
  508. } catch (err) {
  509. if (token !== this._openRunId || !this.sipVisible) return
  510. console.error('[一键外呼] 启动失败:', err && err.message)
  511. this.globalErrorMsg = (err && err.message) || '外呼启动失败'
  512. this.setStepStatus('decrypt', 'error', this.globalErrorMsg)
  513. this.$message.error(this.globalErrorMsg)
  514. this._forceUnlockCallGuards({ soft: false })
  515. // 允许用户点重试
  516. this._releaseBootLocks()
  517. } finally {
  518. // 绝不能清掉新一轮 boot 的 _booting
  519. if (token === this._openRunId) {
  520. this._booting = false
  521. }
  522. }
  523. },
  524. /** 释放启动防重入锁,保证关窗后可再次一键外呼 */
  525. _releaseBootLocks() {
  526. this._bootStarted = false
  527. this._booting = false
  528. },
  529. /**
  530. * 关窗/异常后恢复「可再次外呼」的全部守卫
  531. * 选线路中关闭、流程异常、超时都必须走到这里
  532. */
  533. _resetCallAvailability() {
  534. this._releaseBootLocks()
  535. this._recoverAfterCancelCount = 0
  536. this.initCancelled = false
  537. this._forceUnlockCallGuards({ soft: false })
  538. this._clearHangupCloseFailSafe()
  539. },
  540. /** 关闭弹窗:取消初始化并释放资源,必须恢复可再次外呼 */
  541. _teardownCallSession() {
  542. this._openRunId++
  543. this.cancelInit()
  544. this._resetCallAvailability()
  545. this._destroyAllWithTimeout(true, 3000, this._openRunId, { forceReset: true }).catch(() => {})
  546. },
  547. async _destroyAllWithTimeout(skipSync = true, ms = 3000, sessionToken = null, options = {}) {
  548. try {
  549. await Promise.race([
  550. this.destroyAll(skipSync, sessionToken, options),
  551. new Promise((resolve) => setTimeout(resolve, ms))
  552. ])
  553. } catch (e) {
  554. console.warn('[一键外呼] destroyAll 异常,继续启动:', e && e.message)
  555. }
  556. },
  557. async initComponent() {
  558. // 绑定本次弹窗会话,避免旧 init 的取消回调误伤新一轮外呼
  559. const sessionToken = this._openRunId
  560. const gen = ++this._initGeneration
  561. this._initActiveGen = gen
  562. this.isInitializing = true
  563. this.initCancelled = false
  564. this.resetState()
  565. this._armInitWatchdog(gen)
  566. const assertAlive = () => {
  567. if (this.initCancelled || gen !== this._initGeneration) {
  568. throw new Error('初始化已取消')
  569. }
  570. }
  571. try {
  572. this.setStepStatus('decrypt', 'loading')
  573. await this.resolveDialPhone()
  574. assertAlive()
  575. this.setStepStatus('decrypt', 'success')
  576. this.setStepStatus('gateway', 'loading')
  577. await this.loadGatewayOptions()
  578. assertAlive()
  579. // 选线路是用户操作,暂停 watchdog,避免选太久被误判超时关窗
  580. this._clearInitWatchdog()
  581. this.awaitingGatewaySelect = true
  582. await this.waitForGatewayConfirm()
  583. assertAlive()
  584. this.awaitingGatewaySelect = false
  585. this.setStepStatus('gateway', 'success')
  586. this._armInitWatchdog(gen)
  587. this.setStepStatus('ipcc', 'loading')
  588. await this.connectIPCC()
  589. assertAlive()
  590. this.setStepStatus('ipcc', 'success')
  591. this.setStepStatus('jssip', 'loading')
  592. await this.startSIP()
  593. assertAlive()
  594. this.setStepStatus('jssip', 'success')
  595. await new Promise(r => setTimeout(r, DELAY_STEP_TRANSITION))
  596. assertAlive()
  597. this.setStepStatus('call', 'loading')
  598. await this.waitForCallingReady(TIMEOUT_AGENT_READY)
  599. assertAlive()
  600. await this.autoCall()
  601. } catch (err) {
  602. // 会话已切到新一轮:旧 init 只退出,禁止动任何锁/恢复逻辑
  603. if (sessionToken !== this._openRunId) {
  604. return
  605. }
  606. if (err && err.message === '初始化已取消') {
  607. console.warn('[一键外呼] 初始化已取消')
  608. // 只放开 _bootStarted;_booting 由外层 _bootCallSession.finally 负责,避免竞态踩踏
  609. this._bootStarted = false
  610. // 弹窗仍开着:关窗动画与 reopen 竞态时自动恢复(限次)
  611. if (
  612. this.sipVisible &&
  613. this._recoverAfterCancelCount < MAX_RECOVER_AFTER_CANCEL
  614. ) {
  615. this._recoverAfterCancelCount++
  616. this.initCancelled = false
  617. this.$nextTick(() => {
  618. if (
  619. sessionToken === this._openRunId &&
  620. this.sipVisible &&
  621. !this._bootStarted &&
  622. !this._booting &&
  623. !this.isInitializing &&
  624. !this.awaitingGatewaySelect &&
  625. !this.isReady
  626. ) {
  627. this._scheduleBoot('recover-after-cancel')
  628. }
  629. })
  630. }
  631. } else if (gen === this._initGeneration) {
  632. console.error('[一键外呼] 初始化失败:', err && err.message)
  633. this.globalErrorMsg = (err && err.message) || '连接失败'
  634. this.$message.error(this.globalErrorMsg)
  635. this.awaitingGatewaySelect = false
  636. this.isReady = false
  637. this._bootStarted = false
  638. // 流程中断:尽快回收并关窗,避免卡死下次外呼
  639. this._forceUnlockCallGuards({ soft: false })
  640. this.destroyAll(true, null, { forceReset: true }).catch(() => {})
  641. clearTimeout(this._failCloseTimer)
  642. this._failCloseTimer = setTimeout(() => {
  643. this._failCloseTimer = null
  644. if (sessionToken === this._openRunId) this.doClose()
  645. }, CLOSE_AFTER_FAIL_MS)
  646. }
  647. } finally {
  648. this._clearInitWatchdog()
  649. // 用 _initActiveGen 判定:cancelInit 抬代后仍能释放本轮锁,且不会误清新一轮 init
  650. if (this._initActiveGen === gen) {
  651. this.isInitializing = false
  652. this.gatewayConfirming = false
  653. this._initActiveGen = 0
  654. }
  655. }
  656. },
  657. retryInit() {
  658. if (this.isInitializing || this._booting) return
  659. this._clearHangupCloseFailSafe()
  660. this._releaseBootLocks()
  661. this._recoverAfterCancelCount = 0
  662. this.initCancelled = false
  663. this.destroyAll(true, null, { forceReset: true }).then(() => {
  664. if (this.sipVisible) this._scheduleBoot('retry')
  665. })
  666. },
  667. cancelInit() {
  668. this.initCancelled = true
  669. this._initGeneration++
  670. // 标记当前没有活跃 init,让旧 init 的 finally 能解锁,并立刻放开 isInitializing
  671. this._initActiveGen = 0
  672. this.isInitializing = false
  673. this.awaitingGatewaySelect = false
  674. this.gatewayConfirming = false
  675. this.rejectCCConnect(new Error('初始化已取消'))
  676. if (this._gatewaySelectReject) {
  677. this._gatewaySelectReject(new Error('初始化已取消'))
  678. this._gatewaySelectReject = null
  679. this._gatewaySelectResolve = null
  680. }
  681. if (this.sipReject) {
  682. this.sipReject(new Error('初始化已取消'))
  683. this.sipReject = null
  684. }
  685. this.cancelDecryptRequest()
  686. this._clearInitWatchdog()
  687. },
  688. resetState() {
  689. this.initCancelled = false
  690. this.sipReject = null
  691. this.callStartResolve = null
  692. this.isHangingUp = false
  693. this.clearHangupLockTimer()
  694. this.stopAudioHealthCheck()
  695. this._cancelRemoteHangupCheck()
  696. this.unbindCCEvents()
  697. this.unbindSipEvents()
  698. this.cancelDecryptRequest()
  699. this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
  700. this.cancelAutoClose()
  701. this.ipccConnected = false
  702. this.sipRegistered = false
  703. this.isReady = false
  704. this.globalErrorMsg = ''
  705. this.callState = 'idle'
  706. this.isOnHold = false
  707. this.callTimer = ''
  708. this.stateText = ''
  709. this.statusClass = ''
  710. this.callStartTime = null
  711. this.currentCallUuid = ''
  712. this.callDirection = ''
  713. this.isCallingReady = false
  714. this.isAutoClosing = false
  715. this.isSyncPending = false
  716. this.syncPhase = 'idle'
  717. this.syncPhaseText = ''
  718. this._clearHangupCloseFailSafe()
  719. this.awaitingGatewaySelect = false
  720. this.gatewayConfirming = false
  721. this.selectedGatewayId = null
  722. this._pendingExtInfo = null
  723. if (this._gatewaySelectReject) {
  724. const rej = this._gatewaySelectReject
  725. this._gatewaySelectReject = null
  726. this._gatewaySelectResolve = null
  727. try { rej(new Error('初始化已取消')) } catch (e) {}
  728. } else {
  729. this._gatewaySelectResolve = null
  730. this._gatewaySelectReject = null
  731. }
  732. this._bridgeStartedAt = 0
  733. this._suppressIpccHangupUntil = 0
  734. this._callEstablishedAt = 0
  735. this._lastAsrAt = 0
  736. this._ipccRecovering = false
  737. this._completingHangup = false
  738. this._lastCallTimerTickAt = 0
  739. this._clearIpccReconnectState()
  740. this.gatewayList = []
  741. this.connectedGatewayIds = []
  742. this.stopTimer()
  743. },
  744. resetStateWithoutCancelSync() {
  745. this.initCancelled = false
  746. this.sipReject = null
  747. this.callStartResolve = null
  748. this.isHangingUp = false
  749. this.clearHangupLockTimer()
  750. this.stopAudioHealthCheck()
  751. this._cancelRemoteHangupCheck()
  752. this.unbindCCEvents()
  753. this.unbindSipEvents()
  754. this.cancelDecryptRequest()
  755. this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
  756. this.cancelAutoClose()
  757. this._clearHangupCloseFailSafe()
  758. this.ipccConnected = false
  759. this.sipRegistered = false
  760. this.isReady = false
  761. this.globalErrorMsg = ''
  762. this.callState = 'idle'
  763. this.isOnHold = false
  764. this.callTimer = ''
  765. this.stateText = ''
  766. this.statusClass = ''
  767. this.callStartTime = null
  768. this.currentCallUuid = ''
  769. this.callDirection = ''
  770. this.isCallingReady = false
  771. this.isAutoClosing = false
  772. this.isSyncPending = false
  773. this.syncPhase = 'idle'
  774. this.syncPhaseText = ''
  775. this._bridgeStartedAt = 0
  776. this._suppressIpccHangupUntil = 0
  777. this._callEstablishedAt = 0
  778. this._lastAsrAt = 0
  779. this._ipccRecovering = false
  780. this._completingHangup = false
  781. this._lastCallTimerTickAt = 0
  782. this._clearIpccReconnectState()
  783. this.stopTimer()
  784. },
  785. // 解密请求优化:增加超时和取消令牌
  786. // 解析拨号号码:优先加密号解密,否则使用明文号
  787. async resolveDialPhone() {
  788. if (this.sipEncryptedPhone) {
  789. await this.decryptPhone()
  790. return
  791. }
  792. if (this.sipPlainPhone) {
  793. const phone = String(this.sipPlainPhone).trim()
  794. if (!phone || phone.length < 3) {
  795. const errMsg = '明文手机号无效'
  796. this.setStepStatus('decrypt', 'error', errMsg)
  797. throw new Error(errMsg)
  798. }
  799. this.decryptedPhone = phone
  800. this.maskedPhone = this.maskPhone(phone)
  801. return
  802. }
  803. const errMsg = '未提供手机号(sipEncryptedPhone / sipPlainPhone)'
  804. this.setStepStatus('decrypt', 'error', errMsg)
  805. throw new Error(errMsg)
  806. },
  807. // 解密请求优化:增加超时和取消令牌
  808. async decryptPhone() {
  809. if (!this.sipEncryptedPhone) {
  810. const errMsg = '未提供加密手机号'
  811. this.setStepStatus('decrypt', 'error', errMsg)
  812. throw new Error(errMsg)
  813. }
  814. this.isDecrypting = true
  815. this.cancelDecryptRequest()
  816. const CancelToken = axios.CancelToken
  817. this.decryptCancelTokenSource = CancelToken.source()
  818. try {
  819. const random = Math.floor(100000 + Math.random() * 900000).toString()
  820. const combined = this.sipEncryptedPhone + random
  821. const userId = this.sipEncryptedUserId
  822. const data={
  823. phone:combined,
  824. id:userId,
  825. }
  826. const response = await Promise.race([
  827. encryptMobile(data, { cancelToken: this.decryptCancelTokenSource.token }),
  828. new Promise((_, reject) => setTimeout(() => reject(new Error('号码解密超时')), TIMEOUT_DECRYPT))
  829. ])
  830. if (this.initCancelled) throw new Error('已取消')
  831. if (response.code === 200) {
  832. const privateKey = process.env.VUE_APP_SIP_PHONE_ENCRYPT_PRIVATE_KEY
  833. if (!privateKey) {
  834. throw new Error('解密私钥未配置')
  835. }
  836. const resultData = response.data.slice(0, -6)
  837. this.decryptedPhone = this.xorDecrypt(resultData, privateKey)
  838. this.maskedPhone = this.maskPhone(this.decryptedPhone)
  839. } else {
  840. throw new Error(response.message || response.msg || '号码解密失败')
  841. }
  842. } catch (error) {
  843. if (axios.isCancel(error)) {
  844. throw new Error('解密已取消')
  845. }
  846. const errMsg = error.message || '号码解密异常'
  847. this.setStepStatus('decrypt', 'error', errMsg)
  848. throw error
  849. } finally {
  850. this.isDecrypting = false
  851. this.decryptCancelTokenSource = null
  852. }
  853. },
  854. cancelDecryptRequest() {
  855. if (this.decryptCancelTokenSource) {
  856. this.decryptCancelTokenSource.cancel('解密已取消')
  857. this.decryptCancelTokenSource = null
  858. }
  859. },
  860. xorDecrypt(base64Str, privateKey) {
  861. const binaryStr = atob(base64Str)
  862. const keyBytes = privateKey.split('').map(ch => ch.charCodeAt(0))
  863. let result = ''
  864. for (let i = 0; i < binaryStr.length; i++) {
  865. result += String.fromCharCode(binaryStr.charCodeAt(i) ^ keyBytes[i % keyBytes.length])
  866. }
  867. return result
  868. },
  869. maskPhone(phone) {
  870. if (!phone || phone.length < 7) return phone
  871. return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4)
  872. },
  873. _isInCallFlow() {
  874. return ['talking', 'ringing', 'connecting'].includes(this.callState)
  875. },
  876. _isMobileGatewayLine() {
  877. const gwId = this.connectedGatewayIds[0]
  878. if (!gwId || !this.gatewayList.length) {
  879. return this.gatewayList.some((gw) => this._gatewayIsMobileLine(gw))
  880. }
  881. const gw = this.gatewayList.find((g) => String(g.id) === String(gwId))
  882. return this._gatewayIsMobileLine(gw)
  883. },
  884. _gatewayIsMobileLine(gateway) {
  885. if (!gateway) return false
  886. const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
  887. return MOBILE_GATEWAY_MATCH.some((key) => text.includes(key))
  888. },
  889. _shouldTreatMobileLineAsEnded() {
  890. if (!this._isMobileGatewayLine()) return false
  891. if (!['talking', 'ringing'].includes(this.callState)) return false
  892. if (!this._ipcCallSessionActive()) return true
  893. if (this.callState === 'ringing') return false
  894. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  895. const asrRecent = this._lastAsrAt && Date.now() - this._lastAsrAt < 3000
  896. const timerStale = this._lastCallTimerTickAt && Date.now() - this._lastCallTimerTickAt > 3500
  897. if (timerStale && !asrRecent) return true
  898. if (!sipAlive && !asrRecent) return true
  899. return false
  900. },
  901. _syncMobileRemoteHangupIfNeeded(reason) {
  902. if (!this._shouldTreatMobileLineAsEnded()) return false
  903. if (this._completingHangup || this.isHangingUp) return false
  904. console.warn('[一键外呼] 手机线路远端已挂机,同步:', reason)
  905. this._forceReleaseIpccCallState()
  906. this._completeCallHangup('ipcc')
  907. return true
  908. },
  909. _handleAgentPostCallStatus(statusCode) {
  910. if (statusCode !== AgentStatusEnum.FILL_FORM) return
  911. if (!this._isInCallFlow()) return
  912. if (this._completingHangup) return
  913. this._forceReleaseIpccCallState()
  914. this._completeCallHangup('ipcc')
  915. },
  916. _scheduleMobileRemoteHangupSync(reason, delayMs = 600) {
  917. if (!this._isMobileGatewayLine()) return
  918. if (!['talking', 'ringing'].includes(this.callState)) return
  919. this._scheduleRemoteHangupCheck(reason, delayMs)
  920. },
  921. _startOutboundBridgeProtect(reason = '') {
  922. const now = Date.now()
  923. this._bridgeStartedAt = now
  924. this._suppressIpccHangupUntil = now + OUTBOUND_BRIDGE_PROTECT_MS
  925. if (this.ccPhoneBar && typeof this.ccPhoneBar.setCallConnected === 'function') {
  926. this.ccPhoneBar.setCallConnected(true)
  927. }
  928. if (reason) console.debug('[一键外呼] 桥接保护启动', reason)
  929. },
  930. _isRingingBridgeProtected() {
  931. if (!['ringing', 'connecting'].includes(this.callState) || !this.currentCallUuid) return false
  932. if (this._ipccRecovering) return true
  933. if (!this.ccPhoneBar || !this.ccSocketConnected) return false
  934. if (this._suppressIpccHangupUntil && Date.now() < this._suppressIpccHangupUntil) return true
  935. if (this._bridgeStartedAt && Date.now() - this._bridgeStartedAt < OUTBOUND_BRIDGE_PROTECT_MS) return true
  936. if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true
  937. return this._ipcCallAlive()
  938. },
  939. _ipcCallAlive() {
  940. if (this._ipcCallSessionActive()) return true
  941. if (this._ipccRecovering && this._lastAsrAt && Date.now() - this._lastAsrAt < 5000) return true
  942. // IPCC 恢复期间 JsSIP 仍在通,视为通话未结束
  943. if (this._ipccRecovering) {
  944. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  945. if (sipAlive) return true
  946. }
  947. return false
  948. },
  949. _ipcCallSessionActive() {
  950. if (!this.ccPhoneBar) return false;
  951. if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true;
  952. if (this.ccPhoneBar.getActiveCallUuid && this.ccPhoneBar.getActiveCallUuid()) return true;
  953. return false;
  954. },
  955. _isRemoteCallAlive() {
  956. if (!this.ccPhoneBar) return false
  957. // IPCC 断开恢复中:有会话快照或 JsSIP 仍在,禁止据此自动挂断
  958. if (this._ipccRecovering) {
  959. if (this._ipcCallSessionActive()) return true
  960. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  961. if (sipAlive) return true
  962. return false
  963. }
  964. if (!this.ccSocketConnected) return false
  965. if (this._isMobileGatewayLine()) {
  966. if (this._shouldTreatMobileLineAsEnded()) return false
  967. return this._ipcCallSessionActive()
  968. }
  969. return this._ipcCallSessionActive() && this.callState === 'talking'
  970. },
  971. _forceReleaseIpccCallState() {
  972. if (!this.ccPhoneBar) return
  973. this.ccPhoneBar.clearActiveCallUuid()
  974. if (typeof this.ccPhoneBar.setCallConnected === 'function') {
  975. this.ccPhoneBar.setCallConnected(false)
  976. }
  977. if (typeof this.ccPhoneBar.resetOutboundLock === 'function') {
  978. this.ccPhoneBar.resetOutboundLock()
  979. }
  980. },
  981. async _gracefulDisconnectIpcc(waitMs) {
  982. if (!this.ccPhoneBar) return
  983. const disconnectWait = waitMs || IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000
  984. try {
  985. if (typeof this.ccPhoneBar.forceDisconnect === 'function') {
  986. this.ccPhoneBar.forceDisconnect()
  987. await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
  988. } else if (typeof this.ccPhoneBar.disconnectAndWait === 'function') {
  989. await this.ccPhoneBar.disconnectAndWait(disconnectWait)
  990. } else {
  991. this.ccPhoneBar.disconnect()
  992. await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
  993. }
  994. } catch (e) {}
  995. await new Promise((r) => setTimeout(r, IPCC_GATEWAY_RELEASE_DELAY_MS))
  996. },
  997. _syncCallUuidToSdk(uuid) {
  998. if (uuid && this.ccPhoneBar && typeof this.ccPhoneBar.setActiveCallUuid === 'function') {
  999. this.ccPhoneBar.setActiveCallUuid(uuid)
  1000. }
  1001. },
  1002. _refreshInCallStatusIndicator() {
  1003. // 通话中 IPCC 闪断重连:界面保持正常通话态,异常只打日志,避免用户感知断开
  1004. if (this.callState === 'talking') {
  1005. this.stateText = this.isOnHold ? '已暂停' : (this._isMobileGatewayLine() ? '通话中(音频在手机)' : '通话中')
  1006. this.statusClass = this.isOnHold ? 'status-hold' : 'status-talking'
  1007. } else if (this.callState === 'ringing') {
  1008. this.stateText = this.callDirection === 'outbound' ? '客户振铃中...' : '振铃中...'
  1009. this.statusClass = 'status-ringing'
  1010. }
  1011. },
  1012. _persistActiveCallSnapshot() {
  1013. if (!this.currentCallUuid || this.callState === 'idle' || this.callState === 'ended') return
  1014. try {
  1015. sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
  1016. uuid: this.currentCallUuid,
  1017. callState: this.callState,
  1018. callDirection: this.callDirection,
  1019. savedAt: Date.now()
  1020. }))
  1021. } catch (e) {}
  1022. },
  1023. _restoreActiveCallSnapshot() {
  1024. try {
  1025. const raw = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
  1026. if (!raw) return
  1027. const snap = JSON.parse(raw)
  1028. if (!snap?.uuid) return
  1029. if (!this.currentCallUuid) this.currentCallUuid = snap.uuid
  1030. this._syncCallUuidToSdk(this.currentCallUuid)
  1031. } catch (e) {}
  1032. },
  1033. _clearActiveCallSnapshot() {
  1034. try { sessionStorage.removeItem(ACTIVE_CALL_STORAGE_KEY) } catch (e) {}
  1035. },
  1036. _getIpccConnectTimeout(isReconnect = false) {
  1037. return isReconnect
  1038. ? (IPCC_CONFIG.RECONNECT_CONNECT_TIMEOUT || 3000)
  1039. : (IPCC_CONFIG.CONNECT_TIMEOUT || TIMEOUT_IPCC_CONNECT)
  1040. },
  1041. _clearCcConnectingState() {
  1042. if (this._ccConnectingTimeoutId) {
  1043. clearTimeout(this._ccConnectingTimeoutId)
  1044. this._ccConnectingTimeoutId = null
  1045. }
  1046. this.ccConnectingResolve = null
  1047. this.ccConnectingReject = null
  1048. this.ccConnectingPromise = null
  1049. this._ipccReconnecting = false
  1050. this._ccConnectStartedAt = 0
  1051. },
  1052. _finishCcConnecting(success, err) {
  1053. const resolve = this.ccConnectingResolve
  1054. const reject = this.ccConnectingReject
  1055. if (this._ccConnectingTimeoutId) {
  1056. clearTimeout(this._ccConnectingTimeoutId)
  1057. this._ccConnectingTimeoutId = null
  1058. }
  1059. this.ccConnectingResolve = null
  1060. this.ccConnectingReject = null
  1061. this.ccConnectingPromise = null
  1062. this._ipccReconnecting = false
  1063. this._ccConnectStartedAt = 0
  1064. if (success && resolve) resolve()
  1065. else if (!success && reject) reject(err || new Error('IPCC连接失败'))
  1066. },
  1067. async _reconnectExistingIpcc() {
  1068. if (this._ipccReconnecting && this.ccConnectingPromise) {
  1069. return this.ccConnectingPromise
  1070. }
  1071. this._ipccReconnecting = true
  1072. this._ccConnectStartedAt = Date.now()
  1073. this.ccConnectingPromise = new Promise((resolve, reject) => {
  1074. this.ccConnectingResolve = resolve
  1075. this.ccConnectingReject = reject
  1076. try {
  1077. if (this.ccPhoneBar.resetKickedState) this.ccPhoneBar.resetKickedState()
  1078. if (typeof this.ccPhoneBar.reconnect === 'function') {
  1079. this.ccPhoneBar.reconnect()
  1080. } else {
  1081. this.ccPhoneBar.connect()
  1082. }
  1083. this._ccConnectingTimeoutId = setTimeout(() => {
  1084. if (!this.ccSocketConnected) {
  1085. this._finishCcConnecting(false, new Error('IPCC重连超时'))
  1086. }
  1087. }, this._getIpccConnectTimeout(true))
  1088. } catch (err) {
  1089. this._finishCcConnecting(false, err)
  1090. }
  1091. })
  1092. return this.ccConnectingPromise
  1093. },
  1094. _scheduleIpccReconnect(duringCall, extraDelayMs = 0) {
  1095. if (!duringCall && this.callState === 'idle' &&
  1096. this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) {
  1097. return
  1098. }
  1099. if (this._ipccReconnectTimer) {
  1100. if (duringCall) return
  1101. clearTimeout(this._ipccReconnectTimer)
  1102. this._ipccReconnectTimer = null
  1103. }
  1104. if (this._ipccReconnecting) return
  1105. if (this.ccConnectingPromise) {
  1106. const elapsed = this._ccConnectStartedAt ? Date.now() - this._ccConnectStartedAt : 0
  1107. const reconnectTimeout = this._getIpccConnectTimeout(true)
  1108. if (elapsed < reconnectTimeout + 500) return
  1109. console.warn('[一键外呼] IPCC连接Promise超时未结束,强制清理后重试')
  1110. this._finishCcConnecting(false, new Error('连接状态超时'))
  1111. }
  1112. const delay = (duringCall
  1113. ? (IPCC_CONFIG.RECONNECT_DELAY_IN_CALL_MS || 150)
  1114. : (IPCC_CONFIG.RECONNECT_DELAY_MS || 100)) + (extraDelayMs || 0)
  1115. this._ipccReconnectTimer = setTimeout(async () => {
  1116. this._ipccReconnectTimer = null
  1117. if (this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) return
  1118. try {
  1119. if (this.ccPhoneBar) {
  1120. await this._reconnectExistingIpcc()
  1121. }
  1122. if (!this._pendingIpccHangup) {
  1123. this._ipccRecovering = false
  1124. this._refreshInCallStatusIndicator()
  1125. }
  1126. } catch (err) {
  1127. if (duringCall && this._isInCallFlow()) {
  1128. if (this._ipccReconnectFailCount == null) this._ipccReconnectFailCount = 0
  1129. this._ipccReconnectFailCount += 1
  1130. const backoff = this._ipccReconnectFailCount <= 8
  1131. ? 0
  1132. : Math.min(5000, 350 * (this._ipccReconnectFailCount - 8))
  1133. console.debug('[一键外呼] 通话中IPCC重连失败,继续重试:', err.message || err)
  1134. this._scheduleIpccReconnect(true, backoff)
  1135. }
  1136. }
  1137. }, delay)
  1138. },
  1139. _clearIpccReconnectState() {
  1140. if (this._ipccReconnectTimer) {
  1141. clearTimeout(this._ipccReconnectTimer)
  1142. this._ipccReconnectTimer = null
  1143. }
  1144. this._pendingIpccHangup = false
  1145. this._ipccReconnectFailCount = 0
  1146. this._clearCcConnectingState()
  1147. this._clearActiveCallSnapshot()
  1148. },
  1149. _cancelRemoteHangupCheck() {
  1150. if (this._remoteHangupCheckTimer) {
  1151. clearTimeout(this._remoteHangupCheckTimer)
  1152. this._remoteHangupCheckTimer = null
  1153. }
  1154. },
  1155. _scheduleRemoteHangupCheck(reason, delayMs = 8000) {
  1156. if (!['talking', 'ringing'].includes(this.callState)) return
  1157. if (this._remoteHangupCheckTimer) return
  1158. const delay = this.callState === 'talking' ? Math.min(delayMs, 1500) : delayMs
  1159. this._remoteHangupCheckTimer = setTimeout(() => {
  1160. this._remoteHangupCheckTimer = null
  1161. if (this.callState === 'ended' || this.callState === 'idle') return
  1162. if (this._isRingingBridgeProtected()) {
  1163. this._scheduleRemoteHangupCheck(reason, 2000)
  1164. return
  1165. }
  1166. if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded(reason)) return
  1167. if (this._ipccRecovering && this._ipcCallAlive()) return
  1168. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  1169. if (sipAlive) return
  1170. if (this._isRemoteCallAlive()) return
  1171. console.warn('[一键外呼] 远端挂机确认:', reason)
  1172. this._completeCallHangup('sip')
  1173. }, delay)
  1174. },
  1175. _completeCallHangup(source) {
  1176. if (this.callState === 'ended' || this.callState === 'idle') return
  1177. if (this._completingHangup) return
  1178. this._completingHangup = true
  1179. const uuid = this.currentCallUuid
  1180. try {
  1181. this._cancelRemoteHangupCheck()
  1182. this.stopAudioHealthCheck()
  1183. const needIpccHangup = source !== 'ipcc'
  1184. if (needIpccHangup && this.ccPhoneBar && this.ccSocketConnected) {
  1185. try { this.ccPhoneBar.hangup() } catch (e) {}
  1186. } else if (source === 'ipcc') {
  1187. this._forceReleaseIpccCallState()
  1188. } else if (needIpccHangup && this.ccPhoneBar && !this.ccSocketConnected && this._isInCallFlow()) {
  1189. this._pendingIpccHangup = true
  1190. this._persistActiveCallSnapshot()
  1191. this._scheduleIpccReconnect(true, 0)
  1192. }
  1193. if (this.phone) {
  1194. if (typeof this.phone.markIntentionalHangup === 'function') {
  1195. this.phone.markIntentionalHangup()
  1196. }
  1197. try { this.phone.Terminate() } catch (e) {}
  1198. try { this.phone.releaseLocalStream() } catch (e) {}
  1199. }
  1200. this.callState = 'ended'
  1201. this.stateText = source === 'sip' ? '对方已挂机' : '已挂断'
  1202. this.statusClass = 'status-ended'
  1203. this.isOnHold = false
  1204. this.stopTimer()
  1205. this.callTimer = ''
  1206. this._bridgeStartedAt = 0
  1207. this._suppressIpccHangupUntil = 0
  1208. if (!this._pendingIpccHangup) {
  1209. this._clearActiveCallSnapshot()
  1210. }
  1211. this.$emit('sip-call-ended', this.effectiveRecordId)
  1212. this.startSyncAndClose(uuid)
  1213. } finally {
  1214. this._completingHangup = false
  1215. this.clearHangupLock()
  1216. }
  1217. },
  1218. _isSyncNotFoundError(msg) {
  1219. return /不存在|not found|找不到|未找到/i.test(msg || '')
  1220. },
  1221. _isDuplicateSyncError(msg) {
  1222. return /duplicate|PRIMARY|已存在|重复/i.test(msg || '')
  1223. },
  1224. async loadGatewayOptions() {
  1225. this.gatewayListLoading = true
  1226. try {
  1227. const extRes = await myCallUser()
  1228. if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
  1229. throw new Error('未查询到分机号信息,请先在外呼配置中绑定分机')
  1230. }
  1231. const { extNum, extPass, gatewayIds: myGateway } = extRes.data
  1232. this._pendingExtInfo = { extNum, extPass, myGateway }
  1233. let rows = []
  1234. try {
  1235. const gwRes = await remoteGatewayList({
  1236. pageNum: 1,
  1237. pageSize: 50,
  1238. isAICall: false,
  1239. params: { purposes: [1, 3] }
  1240. })
  1241. rows = gwRes.rows || []
  1242. } catch (e) {
  1243. console.warn('[一键外呼] remoteGatewayList 失败,尝试工具条网关列表', e && e.message)
  1244. }
  1245. if (!rows.length) {
  1246. const basicRes = await getToolbarBasicParam({ extNum, myGateway })
  1247. if (basicRes.code !== 0) throw new Error(basicRes.message || '获取线路失败')
  1248. rows = (basicRes.data && basicRes.data.gatewayList) || []
  1249. }
  1250. this.gatewayList = rows
  1251. if (!this.gatewayList.length) {
  1252. throw new Error('暂无可用外呼线路')
  1253. }
  1254. // 预选:优先绑定线路中的第一条,否则选列表第一条
  1255. const boundIds = myGateway
  1256. ? String(myGateway).split(',').map(s => s.trim()).filter(Boolean)
  1257. : []
  1258. const preferred = boundIds.find(id => this.gatewayList.some(g => String(g.id) === String(id)))
  1259. this.selectedGatewayId = preferred || this.gatewayList[0].id
  1260. } finally {
  1261. this.gatewayListLoading = false
  1262. }
  1263. },
  1264. waitForGatewayConfirm() {
  1265. return new Promise((resolve, reject) => {
  1266. this._gatewaySelectResolve = resolve
  1267. this._gatewaySelectReject = reject
  1268. })
  1269. },
  1270. selectGateway(gatewayId) {
  1271. if (this.gatewayConfirming || !this.awaitingGatewaySelect) return
  1272. if (gatewayId == null || gatewayId === '') return
  1273. this.selectedGatewayId = gatewayId
  1274. },
  1275. confirmGatewaySelect() {
  1276. if (this.gatewayConfirming) return
  1277. if (!this.selectedGatewayId) {
  1278. this.$message.warning('请先选择外呼线路')
  1279. return
  1280. }
  1281. const exists = this.gatewayList.some(g => String(g.id) === String(this.selectedGatewayId))
  1282. if (!exists) {
  1283. this.$message.error('所选线路无效,请重新选择')
  1284. return
  1285. }
  1286. this.gatewayConfirming = true
  1287. if (this._gatewaySelectResolve) {
  1288. const resolve = this._gatewaySelectResolve
  1289. this._gatewaySelectResolve = null
  1290. this._gatewaySelectReject = null
  1291. resolve()
  1292. }
  1293. },
  1294. getGatewayBadge(gateway) {
  1295. if (!gateway) return { text: '线路', theme: 'default' }
  1296. const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
  1297. if (MOBILE_GATEWAY_MATCH.some(k => text.includes(k))) {
  1298. return { text: '手机线路', theme: 'mobile' }
  1299. }
  1300. return { text: '固话线路', theme: 'landline' }
  1301. },
  1302. async connectIPCC() {
  1303. try {
  1304. if (!this.selectedGatewayId) {
  1305. throw new Error('请先选择外呼线路')
  1306. }
  1307. let extNum, extPass, myGateway
  1308. if (this._pendingExtInfo && this._pendingExtInfo.extNum) {
  1309. extNum = this._pendingExtInfo.extNum
  1310. extPass = this._pendingExtInfo.extPass
  1311. myGateway = this.selectedGatewayId
  1312. } else {
  1313. const extRes = await myCallUser()
  1314. if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
  1315. throw new Error('未查询到分机号信息')
  1316. }
  1317. extNum = extRes.data.extNum
  1318. extPass = extRes.data.extPass
  1319. myGateway = this.selectedGatewayId
  1320. }
  1321. this.connectedGatewayIds = [String(this.selectedGatewayId)]
  1322. this.setupProfile(extNum, extPass)
  1323. const basicRes = await getToolbarBasicParam({ extNum, myGateway: String(myGateway) })
  1324. if (basicRes.code !== 0) throw new Error(basicRes.message || '获取配置失败')
  1325. const configData = basicRes.data
  1326. if (!configData.loginToken) throw new Error('登录令牌无效')
  1327. this.gatewayList = configData.gatewayList || []
  1328. const callConfig = {
  1329. useDefaultUi: false,
  1330. loginToken: configData.loginToken,
  1331. ipccServer: IPCC_CONFIG.SERVER_PROD,
  1332. gatewayList: configData.gatewayList,
  1333. gatewayEncrypted: false,
  1334. extPassword: configData.encryptPsw,
  1335. extnum: extNum,
  1336. opnum: configData.opNum || configData.userName,
  1337. enableWss: true,
  1338. enableHeartBeat: true,
  1339. heartBeatIntervalSecs: 16
  1340. }
  1341. this.ccPhoneBar = new ccPhoneBarSocket()
  1342. this.ccPhoneBar.initConfig(callConfig)
  1343. this.bindCCEvents()
  1344. this.ccPhoneBar.connect()
  1345. await new Promise((resolve, reject) => {
  1346. const timeoutId = setTimeout(() => reject(new Error('IPCC连接超时')), TIMEOUT_IPCC_CONNECT)
  1347. this.ccConnectingResolve = () => { clearTimeout(timeoutId); resolve() }
  1348. this.ccConnectingReject = (err) => { clearTimeout(timeoutId); reject(err) }
  1349. })
  1350. } catch (err) {
  1351. this.setStepStatus('ipcc', 'error', err.message)
  1352. throw err
  1353. }
  1354. },
  1355. setupProfile(extNum, extPass) {
  1356. this.profileManager = new ProfileManager()
  1357. let profile = this.profileManager.getProfile()
  1358. if (!profile.users) profile.users = {}
  1359. let existingUserId = null
  1360. for (const [id, user] of Object.entries(profile.users)) {
  1361. if (user.user === String(extNum).trim()) {
  1362. existingUserId = id
  1363. break
  1364. }
  1365. }
  1366. const userData = {
  1367. note: String(extNum).trim(),
  1368. user: String(extNum).trim(),
  1369. domain: JS_SIP_CONFIG.DOMAIN,
  1370. password: String(extPass).trim(),
  1371. display_name: String(extNum).trim(),
  1372. server: JS_SIP_CONFIG.SERVER,
  1373. transport: JS_SIP_CONFIG.TRANSPORT,
  1374. auto_answer: true
  1375. }
  1376. if (existingUserId) {
  1377. this.profileManager.updateUser(existingUserId, userData)
  1378. } else {
  1379. this.profileManager.addUser(userData)
  1380. }
  1381. const updatedProfile = this.profileManager.getProfile()
  1382. for (const [id, user] of Object.entries(updatedProfile.users || {})) {
  1383. if (user.user === String(extNum).trim()) {
  1384. this.profileManager.switchUser(id)
  1385. break
  1386. }
  1387. }
  1388. this.currentUserDisplay = String(extNum).trim()
  1389. },
  1390. on(event, callback) {
  1391. if (!this.ccPhoneBar) return
  1392. this.ccPhoneBar.on(event, callback)
  1393. this.ccEventRefs.push({ event, callback })
  1394. },
  1395. unbindCCEvents() {
  1396. if (this.ccPhoneBar) {
  1397. this.ccEventRefs.forEach(({ event, callback }) => {
  1398. try { this.ccPhoneBar.off(event, callback) } catch (e) {}
  1399. })
  1400. }
  1401. this.ccEventRefs = []
  1402. },
  1403. bindCCEvents() {
  1404. if (!this.ccPhoneBar) return
  1405. this.on(EventList.WS_CONNECTED, () => {
  1406. this.ipccConnected = true
  1407. this.ccSocketConnected = true
  1408. this._ipccConnectedAt = Date.now()
  1409. if (this._isInCallFlow() || this._ipccRecovering || this._pendingIpccHangup) {
  1410. this._restoreActiveCallSnapshot()
  1411. if (this.ccPhoneBar && !this._pendingIpccHangup) {
  1412. this.ccPhoneBar.restoreCallSession()
  1413. }
  1414. }
  1415. if (this._pendingIpccHangup) {
  1416. this._pendingIpccHangup = false
  1417. this._ipccRecovering = false
  1418. try { this.ccPhoneBar.hangup() } catch (e) {}
  1419. this._forceReleaseIpccCallState()
  1420. this._clearActiveCallSnapshot()
  1421. } else {
  1422. this._ipccRecovering = false
  1423. this._ipccReconnectFailCount = 0
  1424. this._refreshInCallStatusIndicator()
  1425. }
  1426. if (this.ccConnectingPromise) {
  1427. this._finishCcConnecting(true)
  1428. } else if (this.ccConnectingResolve) {
  1429. this.ccConnectingResolve()
  1430. this.ccConnectingResolve = null
  1431. }
  1432. })
  1433. this.on(EventList.WS_DISCONNECTED, () => {
  1434. this.ipccConnected = false
  1435. this.ccSocketConnected = false
  1436. const inCall = this._isInCallFlow()
  1437. if (inCall) {
  1438. console.warn('[一键外呼] IPCC断开(通话中),后台重连恢复通话')
  1439. this._persistActiveCallSnapshot()
  1440. this._cancelRemoteHangupCheck()
  1441. this._ipccRecovering = true
  1442. this._refreshInCallStatusIndicator()
  1443. this._scheduleIpccReconnect(true)
  1444. return
  1445. }
  1446. this.isCallingReady = false
  1447. this.isReady = false
  1448. if (this.ccConnectingReject) {
  1449. this.ccConnectingReject(new Error('IPCC WebSocket连接断开'))
  1450. this.ccConnectingReject = null
  1451. }
  1452. })
  1453. this.on(EventList.ASR_RESULT_GENERATE, () => {
  1454. this._lastAsrAt = Date.now()
  1455. this._cancelRemoteHangupCheck()
  1456. if (this.callState === 'ringing' && this._isInCallFlow()) {
  1457. this.callState = 'talking'
  1458. this.stateText = '通话中'
  1459. this.statusClass = 'status-talking'
  1460. this._callEstablishedAt = Date.now()
  1461. if (!this.callStartTime) {
  1462. this.callStartTime = Date.now()
  1463. this.startTimer()
  1464. }
  1465. }
  1466. if (this._ipccRecovering && this.callState === 'talking') {
  1467. this._ipccRecovering = false
  1468. this._refreshInCallStatusIndicator()
  1469. }
  1470. })
  1471. this.on(EventList.STATUS_CHANGED, (msg) => {
  1472. if (msg?.object) {
  1473. const statusCode = msg.object.status
  1474. this._handleAgentPostCallStatus(statusCode)
  1475. const readyStatuses = [
  1476. AgentStatusEnum.BUSY,
  1477. AgentStatusEnum.BUSY_REST,
  1478. AgentStatusEnum.BUSY_MEETING,
  1479. AgentStatusEnum.BUSY_TRAINING,
  1480. AgentStatusEnum.FILL_FORM,
  1481. AgentStatusEnum.FREE
  1482. ]
  1483. this.isCallingReady = readyStatuses.includes(statusCode)
  1484. }
  1485. })
  1486. this.on(EventList.OUTBOUND_START, (msg) => {
  1487. this.callDirection = 'outbound'
  1488. this._startOutboundBridgeProtect('OUTBOUND_START')
  1489. this.callState = 'connecting'
  1490. this.stateText = '正在呼叫...'
  1491. this.statusClass = 'status-connecting'
  1492. this.setStepStatus('call', 'success')
  1493. this.isReady = true
  1494. if (msg?.object?.uuid) {
  1495. this.currentCallUuid = msg.object.uuid
  1496. this._syncCallUuidToSdk(this.currentCallUuid)
  1497. }
  1498. if (this.callStartResolve) this.callStartResolve()
  1499. })
  1500. this.on(EventList.CALLEE_RINGING, () => {
  1501. this.callState = 'ringing'
  1502. this.stateText = '振铃中...'
  1503. this.statusClass = 'status-ringing'
  1504. if (this.callDirection === 'outbound') {
  1505. this._startOutboundBridgeProtect('CALLEE_RINGING')
  1506. }
  1507. this.startAudioHealthCheck()
  1508. })
  1509. const applyTalkingState = (msg) => {
  1510. if (msg?.object?.uuid) {
  1511. this.currentCallUuid = msg.object.uuid
  1512. this._syncCallUuidToSdk(this.currentCallUuid)
  1513. }
  1514. this.callState = 'talking'
  1515. this._callEstablishedAt = Date.now()
  1516. this._suppressIpccHangupUntil = Date.now() + 8000
  1517. this.callStartTime = Date.now()
  1518. this.startTimer()
  1519. this._refreshInCallStatusIndicator()
  1520. if (this.phone) {
  1521. this.isSpeakerMuted = false
  1522. this.isMicMuted = false
  1523. this.phone.ensureLocalStream()
  1524. .then(() => {
  1525. this.syncVolumeToPhone()
  1526. this.phone.resumeRemoteAudio()
  1527. })
  1528. .catch(() => console.warn('[一键外呼] 通话中麦克风不可用'))
  1529. }
  1530. this.startAudioHealthCheck()
  1531. }
  1532. this.on(EventList.CALLER_ANSWERED, (msg) => {
  1533. if (msg?.object?.uuid) {
  1534. this.currentCallUuid = msg.object.uuid
  1535. this._syncCallUuidToSdk(this.currentCallUuid)
  1536. }
  1537. if (this.callDirection === 'outbound') {
  1538. this.callState = 'ringing'
  1539. this.stateText = '客户振铃中...'
  1540. this.statusClass = 'status-ringing'
  1541. this._startOutboundBridgeProtect('CALLER_ANSWERED')
  1542. this.startAudioHealthCheck()
  1543. return
  1544. }
  1545. applyTalkingState(msg)
  1546. })
  1547. this.on(EventList.CALLEE_ANSWERED, (msg) => {
  1548. applyTalkingState(msg)
  1549. })
  1550. const handleIpccHangup = (msg) => {
  1551. if (this._completingHangup) return
  1552. this._suppressIpccHangupUntil = 0
  1553. this._bridgeStartedAt = 0
  1554. if (msg?.object?.uuid) this.currentCallUuid = msg.object.uuid
  1555. this._completeCallHangup('ipcc')
  1556. }
  1557. this.on(EventList.CALLER_HANGUP, handleIpccHangup)
  1558. this.on(EventList.CALLEE_HANGUP, handleIpccHangup)
  1559. this.on(EventList.OUTBOUND_FINISHED, () => {
  1560. if (this.callState === 'idle' || this.callState === 'ended') {
  1561. console.debug('[一键外呼] 611(外呼任务结束),空闲态忽略')
  1562. return
  1563. }
  1564. if (this._isMobileGatewayLine() && this.callState === 'talking') {
  1565. console.debug('[一键外呼] 手机线路 611,复核是否已挂机')
  1566. this._scheduleMobileRemoteHangupSync('611_talking', 500)
  1567. return
  1568. }
  1569. console.debug('[一键外呼] 611外呼任务结束,不参与挂机,当前态=', this.callState)
  1570. })
  1571. this.on(EventList.ASR_PROCESS_END_CUSTOMER, () => {
  1572. if (this._isMobileGatewayLine() && this.callState === 'talking') {
  1573. console.debug('[一键外呼] 手机线路 ASR 结束,复核是否已挂机')
  1574. this._scheduleMobileRemoteHangupSync('asr_customer_end', 800)
  1575. }
  1576. })
  1577. this.on(EventList.CALLER_RESPOND_TIMEOUT, () => {
  1578. if (this._isRingingBridgeProtected()) {
  1579. console.debug('[一键外呼] 分机超时在桥接保护期内,忽略')
  1580. return
  1581. }
  1582. if (this.isHangingUp) return
  1583. // IPCC 闪断恢复中或 JsSIP 仍在通:不自动挂断
  1584. if (this._ipccRecovering || !this.ccSocketConnected) {
  1585. console.warn('[一键外呼] 分机响应超时(IPCC恢复中),保持通话并重连')
  1586. this._persistActiveCallSnapshot()
  1587. this._scheduleIpccReconnect(true)
  1588. return
  1589. }
  1590. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  1591. if (sipAlive && this._isInCallFlow()) {
  1592. console.warn('[一键外呼] 分机响应超时但JsSIP仍在通,忽略自动挂断')
  1593. return
  1594. }
  1595. this._completeCallHangup('ipcc')
  1596. })
  1597. this.on(EventList.EXTENSION_OFF_LINE, () => {
  1598. if (this.callState === 'idle' || this.callState === 'ended') return
  1599. // 关键:IPCC 断开/重连时分机可能短暂离线,绝不能打断 JsSIP 通话
  1600. if (this._ipccRecovering || !this.ccSocketConnected) {
  1601. console.warn('[一键外呼] 分机离线(IPCC恢复中),保持JsSIP并后台重连')
  1602. this._persistActiveCallSnapshot()
  1603. this._ipccRecovering = true
  1604. this._scheduleIpccReconnect(true)
  1605. return
  1606. }
  1607. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  1608. if (sipAlive && this._isInCallFlow()) {
  1609. console.warn('[一键外呼] 分机离线但JsSIP仍在通,后台重连IPCC,不自动挂断')
  1610. this._ipccRecovering = true
  1611. this._persistActiveCallSnapshot()
  1612. this._refreshInCallStatusIndicator()
  1613. this._scheduleIpccReconnect(true)
  1614. return
  1615. }
  1616. if (this._isMobileGatewayLine() && ['talking', 'ringing'].includes(this.callState)) {
  1617. console.warn('[一键外呼] 手机线路分机离线,同步挂机')
  1618. this._forceReleaseIpccCallState()
  1619. this._completeCallHangup('ipcc')
  1620. return
  1621. }
  1622. console.warn('[一键外呼] 分机离线')
  1623. this._completeCallHangup('ipcc')
  1624. })
  1625. this.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
  1626. this.isOnHold = true
  1627. this.stateText = '已暂停'
  1628. this.statusClass = 'status-hold'
  1629. })
  1630. this.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
  1631. this.isOnHold = false
  1632. this.stateText = '通话中'
  1633. this.statusClass = 'status-talking'
  1634. })
  1635. this.on(EventList.CALLER_BUSY, () => {
  1636. this.isHangingUp = true
  1637. this.callState = 'ended'
  1638. this.stateText = '线路忙'
  1639. this.statusClass = 'status-failed'
  1640. this.$message.warning('上一通电话未挂机,请稍后再试')
  1641. this.stopTimer()
  1642. this.startSyncAndClose(this.currentCallUuid)
  1643. })
  1644. },
  1645. bindSipEvents() {
  1646. this.unbindSipEvents()
  1647. if (!this.phone) return
  1648. this.sipEventHandlers = {
  1649. onAnswered: () => {
  1650. if (this.callDirection === 'outbound' && this.callState !== 'talking') return
  1651. if (this.phone) {
  1652. this.syncVolumeToPhone()
  1653. this.phone.resumeRemoteAudio()
  1654. }
  1655. },
  1656. onSessionClosed: (payload) => {
  1657. if (this.isHangingUp || this._completingHangup || payload?.intentional) return
  1658. if (this._ipccRecovering && this._ipcCallAlive()) {
  1659. console.debug('[一键外呼] SIP结束但IPCC恢复中,继续等待')
  1660. return
  1661. }
  1662. if (this.callState === 'talking') {
  1663. if (this._isMobileGatewayLine()) {
  1664. if (this._syncMobileRemoteHangupIfNeeded('sip_session_closed')) return
  1665. this._scheduleMobileRemoteHangupSync('mobile_sip_end', 400)
  1666. return
  1667. }
  1668. if (this._isRemoteCallAlive()) {
  1669. console.debug('[一键外呼] SIP结束但IPCC仍活跃,继续')
  1670. return
  1671. }
  1672. this._completeCallHangup('sip')
  1673. return
  1674. }
  1675. if (this.callState === 'ringing' || this.callState === 'connecting') {
  1676. if (this._isRingingBridgeProtected()) {
  1677. this._scheduleRemoteHangupCheck('sip_ringing_bridge', 2500)
  1678. return
  1679. }
  1680. if (this._isRemoteCallAlive()) return
  1681. this._completeCallHangup('sip')
  1682. }
  1683. },
  1684. onIceMediaIssue: ({ state, timedOut } = {}) => {
  1685. if (this.callState !== 'talking') return
  1686. if (this._ipccRecovering && this._ipcCallAlive()) return
  1687. const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
  1688. if (sipAlive) {
  1689. this.phone.resumeRemoteAudio()
  1690. return
  1691. }
  1692. if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('ice_media')) return
  1693. if (this._isRemoteCallAlive()) return
  1694. if (timedOut || state === 'failed' || state === 'disconnected') {
  1695. this._completeCallHangup('sip')
  1696. }
  1697. }
  1698. }
  1699. this.phone.On('OnAnswered', this.sipEventHandlers.onAnswered)
  1700. this.phone.On('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
  1701. this.phone.On('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
  1702. },
  1703. unbindSipEvents() {
  1704. if (this.phone && this.sipEventHandlers) {
  1705. try {
  1706. this.phone.Off('OnAnswered', this.sipEventHandlers.onAnswered)
  1707. this.phone.Off('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
  1708. this.phone.Off('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
  1709. } catch (e) {}
  1710. }
  1711. this.sipEventHandlers = null
  1712. },
  1713. startAudioHealthCheck() {
  1714. this.stopAudioHealthCheck()
  1715. const intervalMs = this._isMobileGatewayLine() ? 1000 : 2000
  1716. this._audioHealthTimer = setInterval(() => {
  1717. if (!['talking', 'ringing', 'connecting'].includes(this.callState) || !this.phone) {
  1718. this.stopAudioHealthCheck()
  1719. return
  1720. }
  1721. if (this.isHangingUp || this._completingHangup) return
  1722. if (this._ipccRecovering && this._ipcCallAlive()) return
  1723. if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('health_poll')) return
  1724. const sipAlive = this.phone.hasActiveCall?.() || this.phone.hasSipSession?.()
  1725. if (!sipAlive) {
  1726. if (this._isMobileGatewayLine()) {
  1727. if (this._syncMobileRemoteHangupIfNeeded('health_sip_gone')) return
  1728. } else if (this._isRemoteCallAlive()) return
  1729. if (this.callState === 'talking') {
  1730. this._completeCallHangup('sip')
  1731. return
  1732. }
  1733. if (this._isRingingBridgeProtected()) return
  1734. this._completeCallHangup('sip')
  1735. return
  1736. }
  1737. if (this.callState === 'talking') {
  1738. this.phone.resumeRemoteAudio()
  1739. }
  1740. }, intervalMs)
  1741. },
  1742. stopAudioHealthCheck() {
  1743. if (this._audioHealthTimer) {
  1744. clearInterval(this._audioHealthTimer)
  1745. this._audioHealthTimer = null
  1746. }
  1747. },
  1748. rejectCCConnect(err) {
  1749. if (this.ccConnectingReject) {
  1750. this.ccConnectingReject(err)
  1751. this.ccConnectingReject = null
  1752. this.ccConnectingResolve = null
  1753. }
  1754. },
  1755. async startSIP() {
  1756. if (this.phone) {
  1757. this.unbindSipEvents()
  1758. try { this.phone.destroy() } catch (e) {}
  1759. this.phone = null
  1760. }
  1761. const userProfile = this.profileManager.getCurrentUserProfile()
  1762. if (!userProfile?.user || !userProfile?.domain || !userProfile?.password) {
  1763. throw new Error('SIP账号配置不完整')
  1764. }
  1765. // 请求麦克风权限,确保音频可用
  1766. try {
  1767. await checkMicrophonePermission()
  1768. } catch (err) {
  1769. throw new Error('麦克风权限未授予,无法进行通话')
  1770. }
  1771. const settings = this.profileManager.getSettings()
  1772. this.phone = new WebPhone(userProfile, settings)
  1773. return new Promise((resolve, reject) => {
  1774. this.sipReject = reject
  1775. const timeoutId = setTimeout(() => {
  1776. this.setStepStatus('jssip', 'error', 'JsSIP注册超时')
  1777. reject(new Error('JsSIP注册超时'))
  1778. }, TIMEOUT_SIP_REGISTER)
  1779. this.phone.On('OnRegister', (event) => {
  1780. if (event.registered) {
  1781. this.sipRegistered = true
  1782. this.sipReject = null
  1783. clearTimeout(timeoutId)
  1784. this.syncVolumeToPhone()
  1785. if (this.ccPhoneBar && this.ccSocketConnected) {
  1786. this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY)
  1787. }
  1788. resolve()
  1789. }
  1790. })
  1791. this.phone.On('OnReconnectStatus', ({ failed }) => {
  1792. if (failed) {
  1793. clearTimeout(timeoutId)
  1794. this.sipReject = null
  1795. reject(new Error('JsSIP重连超时'))
  1796. }
  1797. })
  1798. this.bindSipEvents()
  1799. this.phone.Start(settings.reconnect)
  1800. })
  1801. },
  1802. syncVolumeToPhone() {
  1803. if (!this.phone) return
  1804. try {
  1805. this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
  1806. this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
  1807. } catch (err) {
  1808. console.warn('[一键外呼] 同步音量失败:', err)
  1809. }
  1810. },
  1811. async waitForCallingReady(maxWaitMs = TIMEOUT_AGENT_READY) {
  1812. if (this.isCallingReady) return
  1813. const startTime = Date.now()
  1814. return new Promise((resolve, reject) => {
  1815. const check = () => {
  1816. if (this.initCancelled) return reject(new Error('初始化已取消'))
  1817. if (this.isCallingReady) return resolve()
  1818. if (Date.now() - startTime >= maxWaitMs) {
  1819. this.setStepStatus('call', 'error', '坐席就绪等待超时')
  1820. reject(new Error('坐席就绪等待超时'))
  1821. } else {
  1822. setTimeout(check, POLL_INTERVAL_AGENT)
  1823. }
  1824. }
  1825. check()
  1826. })
  1827. },
  1828. async autoCall() {
  1829. if (!this.ccPhoneBar || !this.ccSocketConnected) {
  1830. throw new Error('IPCC未连接')
  1831. }
  1832. if (!this.isCallingReady) {
  1833. throw new Error('坐席未就绪,请稍候')
  1834. }
  1835. if (!this.decryptedPhone || this.decryptedPhone.length < 3) {
  1836. throw new Error('号码无效')
  1837. }
  1838. this._forceReleaseIpccCallState()
  1839. if (this.phone) {
  1840. try {
  1841. await this.phone.ensureLocalStream()
  1842. this.isSpeakerMuted = false
  1843. this.isMicMuted = false
  1844. this.syncVolumeToPhone()
  1845. this.phone.resumeRemoteAudio()
  1846. } catch (e) {
  1847. throw new Error('麦克风不可用,无法进行通话')
  1848. }
  1849. }
  1850. try {
  1851. const started = this.ccPhoneBar.call(this.decryptedPhone, this.effectiveDialType, VideoLevels.HD.levelId)
  1852. if (started === false) {
  1853. this._forceReleaseIpccCallState()
  1854. throw new Error('外呼被拒绝,请稍后重试')
  1855. }
  1856. } catch (err) {
  1857. this.setStepStatus('call', 'error', err.message)
  1858. throw err
  1859. }
  1860. return new Promise((resolve) => {
  1861. this.callStartResolve = () => {
  1862. this.callStartResolve = null
  1863. resolve()
  1864. }
  1865. })
  1866. },
  1867. handlePause() {
  1868. if (!this.ccPhoneBar?.holdCall) return
  1869. if (!this.ccSocketConnected || this._ipccRecovering) {
  1870. console.warn('[一键外呼] IPCC未就绪,暂不可暂停')
  1871. return
  1872. }
  1873. try { this.ccPhoneBar.holdCall() } catch (err) {
  1874. console.warn('[一键外呼] 暂停失败:', err && err.message)
  1875. }
  1876. },
  1877. handleResume() {
  1878. if (!this.ccPhoneBar?.unHoldCall) return
  1879. if (!this.ccSocketConnected || this._ipccRecovering) {
  1880. console.warn('[一键外呼] IPCC未就绪,暂不可恢复')
  1881. return
  1882. }
  1883. try { this.ccPhoneBar.unHoldCall() } catch (err) {
  1884. console.warn('[一键外呼] 恢复失败:', err && err.message)
  1885. }
  1886. },
  1887. toggleMicMute() {
  1888. if (!this.phone) return
  1889. this.isMicMuted = !this.isMicMuted
  1890. this.phone.ToggleMicPhone()
  1891. if (!this.isMicMuted) {
  1892. this.micVolume = this.savedMicVolume || 80
  1893. } else {
  1894. this.savedMicVolume = this.micVolume
  1895. }
  1896. this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
  1897. },
  1898. toggleSpeakerMute() {
  1899. if (!this.phone) return
  1900. this.isSpeakerMuted = !this.isSpeakerMuted
  1901. if (!this.isSpeakerMuted) {
  1902. this.speakerVolume = this.savedSpeakerVolume || 80
  1903. } else {
  1904. this.savedSpeakerVolume = this.speakerVolume
  1905. }
  1906. this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
  1907. },
  1908. onMicVolumeChange(e) {
  1909. const vol = parseInt(e.target.value, 10)
  1910. if (isNaN(vol)) return
  1911. this.micVolume = vol
  1912. if (this.isMicMuted && vol > 0) this.isMicMuted = false
  1913. if (this.phone) this.phone.SetMicPhone(this.isMicMuted, vol / 100)
  1914. },
  1915. onSpeakerVolumeChange(e) {
  1916. const vol = parseInt(e.target.value, 10)
  1917. if (isNaN(vol)) return
  1918. this.speakerVolume = vol
  1919. if (this.isSpeakerMuted && vol > 0) this.isSpeakerMuted = false
  1920. if (this.phone) this.phone.SetSpeaker(this.isSpeakerMuted, vol / 100)
  1921. },
  1922. handleHangup() {
  1923. if (this.isHangingUp || this.callState === 'ended') return
  1924. this.isHangingUp = true
  1925. this.clearHangupLockTimer()
  1926. this.hangupLockTimer = setTimeout(() => {
  1927. this.isHangingUp = false
  1928. this.hangupLockTimer = null
  1929. }, HANGUP_LOCK_TIMEOUT)
  1930. this._completeCallHangup('user')
  1931. },
  1932. clearHangupLock() {
  1933. this.isHangingUp = false
  1934. this.clearHangupLockTimer()
  1935. },
  1936. clearHangupLockTimer() {
  1937. if (this.hangupLockTimer) {
  1938. clearTimeout(this.hangupLockTimer)
  1939. this.hangupLockTimer = null
  1940. }
  1941. },
  1942. _recoverAfterPageLoad() {
  1943. try {
  1944. // 刷新后强制解锁,避免上次异常导致下次无法外呼
  1945. this._resetCallAvailability()
  1946. this._initActiveGen = 0
  1947. const rawSnap = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
  1948. if (!rawSnap) {
  1949. this._needReleaseStaleIpccSession = true
  1950. return
  1951. }
  1952. const snap = JSON.parse(rawSnap)
  1953. const age = Date.now() - (snap && snap.savedAt ? Number(snap.savedAt) : 0)
  1954. const uuid = normalizeCallUuid(snap && snap.uuid)
  1955. this._clearActiveCallSnapshot()
  1956. this._needReleaseStaleIpccSession = true
  1957. if (uuid && age >= 0 && age <= ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS) {
  1958. console.warn('[一键外呼] recover stale call, background sync:', uuid)
  1959. scheduleGlobalCallSync(uuid)
  1960. } else {
  1961. console.warn('[一键外呼] clear expired call snapshot')
  1962. }
  1963. } catch (e) {
  1964. this._clearActiveCallSnapshot()
  1965. this._needReleaseStaleIpccSession = true
  1966. }
  1967. },
  1968. _clearHangupCloseFailSafe() {
  1969. if (this._hangupCloseFailSafe) {
  1970. clearTimeout(this._hangupCloseFailSafe)
  1971. this._hangupCloseFailSafe = null
  1972. }
  1973. if (this._failCloseTimer) {
  1974. clearTimeout(this._failCloseTimer)
  1975. this._failCloseTimer = null
  1976. }
  1977. },
  1978. _clearInitWatchdog() {
  1979. if (this._initWatchdogTimer) {
  1980. clearTimeout(this._initWatchdogTimer)
  1981. this._initWatchdogTimer = null
  1982. }
  1983. },
  1984. _armInitWatchdog(gen) {
  1985. this._clearInitWatchdog()
  1986. this._initWatchdogTimer = setTimeout(() => {
  1987. this._initWatchdogTimer = null
  1988. if (!this.isInitializing || gen !== this._initGeneration) return
  1989. // 选线路等待用户操作,不算卡住,顺延一轮
  1990. if (this.awaitingGatewaySelect) {
  1991. this._armInitWatchdog(gen)
  1992. return
  1993. }
  1994. console.warn('[一键外呼] init timeout, force unlock')
  1995. this.cancelInit()
  1996. this._resetCallAvailability()
  1997. this.globalErrorMsg = '外呼初始化超时,请重试'
  1998. this.$message.error(this.globalErrorMsg)
  1999. this.destroyAll(true, null, { forceReset: true }).catch(() => {})
  2000. this._failCloseTimer = setTimeout(() => {
  2001. this._failCloseTimer = null
  2002. this.doClose()
  2003. }, CLOSE_AFTER_FAIL_MS)
  2004. }, INIT_WATCHDOG_MS)
  2005. },
  2006. _forceUnlockCallGuards({ soft = false } = {}) {
  2007. this.clearHangupLock()
  2008. this._completingHangup = false
  2009. this.isHangingUp = false
  2010. this.isSyncPending = false
  2011. this.isAutoClosing = false
  2012. this.syncPhase = 'idle'
  2013. this.syncPhaseText = ''
  2014. this.gatewayConfirming = false
  2015. this._clearHangupCloseFailSafe()
  2016. this.cancelAutoClose()
  2017. if (this.callState === 'ended') this.callState = 'idle'
  2018. if (!soft) {
  2019. this.isInitializing = false
  2020. this.isReady = false
  2021. this.awaitingGatewaySelect = false
  2022. this._initActiveGen = 0
  2023. this._clearInitWatchdog()
  2024. }
  2025. },
  2026. _normalizeCallUuid(uuid) {
  2027. return normalizeCallUuid(uuid)
  2028. },
  2029. _canCloseDialog() {
  2030. if (this.isAutoClosing) return false
  2031. if (this.callState === 'ended') return false
  2032. if (['talking', 'ringing', 'connecting'].includes(this.callState)) return false
  2033. // 选线路阶段允许关闭,关窗后必须能再次外呼
  2034. if (this.isInitializing && !this.awaitingGatewaySelect) return false
  2035. return true
  2036. },
  2037. _closeBlockReason() {
  2038. if (this.callState === 'ended' || this.isAutoClosing) return '通话收尾中,请稍候'
  2039. if (['talking', 'ringing', 'connecting'].includes(this.callState)) return '通话中请先挂断'
  2040. if (this.isInitializing && !this.awaitingGatewaySelect) return '正在连接外呼,请稍候'
  2041. return '当前不可关闭'
  2042. },
  2043. beforeDialogClose(done) {
  2044. if (!this._canCloseDialog()) {
  2045. this.$message.warning(this._closeBlockReason())
  2046. done(false)
  2047. return
  2048. }
  2049. done()
  2050. },
  2051. /**
  2052. * 挂断收尾:前端只展示统一文案;2秒内关窗;3秒后后台同步话单(不展示同步细节)
  2053. */
  2054. startSyncAndClose(uuid) {
  2055. const id = this._normalizeCallUuid(uuid)
  2056. this.cancelAutoClose()
  2057. this._clearHangupCloseFailSafe()
  2058. this.isSyncPending = false
  2059. this.isAutoClosing = true
  2060. this.syncPhase = 'closing'
  2061. this.syncPhaseText = ''
  2062. this.callState = 'ended'
  2063. this.stateText = '通话结束,系统回收资源中'
  2064. this.statusClass = 'status-ended'
  2065. // 后台延迟 3s 同步,与弹窗生命周期解耦,避免关窗丢同步 / 死锁
  2066. scheduleGlobalCallSync(id)
  2067. // 尽快回收连接资源(短超时,不拖住关窗)
  2068. this.cleanupConnection(true).catch((e) => {
  2069. console.warn('[一键外呼] 挂断后回收异常:', e && e.message)
  2070. })
  2071. // 最多 2 秒必须关窗(不展示同步进度)
  2072. this._hangupCloseFailSafe = setTimeout(() => {
  2073. this._hangupCloseFailSafe = null
  2074. this.cancelAutoClose()
  2075. this._resetCallAvailability()
  2076. this.doClose()
  2077. }, CLOSE_AFTER_HANGUP_MS)
  2078. },
  2079. cancelAutoClose() {
  2080. if (this.autoCloseTimer) {
  2081. clearInterval(this.autoCloseTimer)
  2082. this.autoCloseTimer = null
  2083. }
  2084. this.autoCloseCountdown = 0
  2085. this.isAutoClosing = false
  2086. },
  2087. doClose() {
  2088. this.cancelAutoClose()
  2089. this._clearHangupCloseFailSafe()
  2090. this.isAutoClosing = false
  2091. this.isSyncPending = false
  2092. this.syncPhase = 'idle'
  2093. this._resetCallAvailability()
  2094. this.$emit('update:sipVisible', false)
  2095. },
  2096. startTimer() {
  2097. this.stopTimer()
  2098. this._lastCallTimerTickAt = Date.now()
  2099. this.timerInterval = setInterval(() => {
  2100. if (this.callStartTime) {
  2101. const elapsed = Math.floor((Date.now() - this.callStartTime) / 1000)
  2102. const mins = Math.floor(elapsed / 60).toString().padStart(2, '0')
  2103. const secs = (elapsed % 60).toString().padStart(2, '0')
  2104. this.callTimer = `${mins}:${secs}`
  2105. this._lastCallTimerTickAt = Date.now()
  2106. }
  2107. }, 1000)
  2108. },
  2109. stopTimer() {
  2110. if (this.timerInterval) {
  2111. clearInterval(this.timerInterval)
  2112. this.timerInterval = null
  2113. }
  2114. },
  2115. handleClose() {
  2116. // 关窗动画迟到回调:若已重新打开新会话,绝不能再动新流程
  2117. if (this.sipVisible) {
  2118. console.warn('[一键外呼] 忽略迟到的 close 回调,避免打断新外呼')
  2119. return
  2120. }
  2121. if (['talking', 'ringing', 'connecting'].includes(this.callState)) {
  2122. try { this.handleHangup() } catch (e) {}
  2123. }
  2124. this._resetCallAvailability()
  2125. this.destroyAll(true, this._openRunId, { forceReset: true }).catch(() => {})
  2126. },
  2127. handleBeforeUnload() {
  2128. const uuid = this._normalizeCallUuid(this.currentCallUuid)
  2129. if (uuid && !GLOBAL_SYNCED_UUIDS.has(uuid) && !GLOBAL_SYNCING_UUIDS.has(uuid)) {
  2130. const syncData = { uuid }
  2131. if (navigator.sendBeacon) {
  2132. const blob = new Blob([JSON.stringify(syncData)], { type: 'application/json' })
  2133. navigator.sendBeacon('/api/aiSipCall/callEndSyncByUuid', blob)
  2134. markGlobalSynced(uuid)
  2135. } else {
  2136. scheduleGlobalCallSync(uuid)
  2137. }
  2138. try {
  2139. sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
  2140. uuid,
  2141. callState: this.callState,
  2142. callDirection: this.callDirection,
  2143. savedAt: Date.now()
  2144. }))
  2145. } catch (e) {}
  2146. }
  2147. if (this.ccPhoneBar && this.callState !== 'ended' && this.callState !== 'idle') {
  2148. if (this.ccSocketConnected) {
  2149. try { this.ccPhoneBar.hangup() } catch (e) {}
  2150. }
  2151. }
  2152. try { this._forceReleaseIpccCallState() } catch (e) {}
  2153. },
  2154. async cleanupConnection(fast = false) {
  2155. this.stopAudioHealthCheck()
  2156. this._cancelRemoteHangupCheck()
  2157. this._clearIpccReconnectState()
  2158. this.unbindSipEvents()
  2159. if (this.phone) {
  2160. try { this.phone.releaseLocalStream() } catch (e) {}
  2161. try { this.phone.destroy() } catch (e) {}
  2162. this.phone = null
  2163. }
  2164. if (this.ccPhoneBar) {
  2165. this._forceReleaseIpccCallState()
  2166. const waitMs = fast ? 400 : (IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
  2167. await this._gracefulDisconnectIpcc(waitMs)
  2168. this.ccPhoneBar = null
  2169. }
  2170. },
  2171. async destroyAll(skipSync = false, sessionToken = null, { forceReset = false } = {}) {
  2172. const isStale = () => sessionToken != null && sessionToken !== this._openRunId
  2173. if (isStale()) return
  2174. this.stopTimer()
  2175. this.cancelAutoClose()
  2176. this.clearHangupLockTimer()
  2177. // 仅清理本次开始时的连接引用,避免超时后误杀新会话
  2178. const phoneRef = this.phone
  2179. const ccRef = this.ccPhoneBar
  2180. const inCall = ['talking', 'ringing', 'connecting'].includes(this.callState)
  2181. if (!skipSync && inCall && !this.isHangingUp && ccRef) {
  2182. this.isHangingUp = true
  2183. if (this.ccSocketConnected) {
  2184. try { ccRef.hangup() } catch (e) {}
  2185. } else {
  2186. this._pendingIpccHangup = true
  2187. this._persistActiveCallSnapshot()
  2188. try { this._scheduleIpccReconnect(true, 0) } catch (e) {}
  2189. }
  2190. if (this.currentCallUuid) {
  2191. scheduleGlobalCallSync(this.currentCallUuid)
  2192. }
  2193. }
  2194. if (isStale()) return
  2195. try {
  2196. await Promise.race([
  2197. this._cleanupConnectionRefs(phoneRef, ccRef),
  2198. new Promise((resolve) => setTimeout(resolve, 2500))
  2199. ])
  2200. } catch (e) {
  2201. console.warn('[一键外呼] cleanupConnection 异常:', e && e.message)
  2202. if (this.phone === phoneRef) this.phone = null
  2203. if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
  2204. }
  2205. // 超时竞态:若已进入新初始化,禁止迟到的 destroy 清空步骤
  2206. if (isStale()) return
  2207. // 关窗强制复位;进行中的新会话(弹窗可见)才跳过,避免打断选线路/通话
  2208. if (!forceReset && this.sipVisible && (this.isInitializing || this.isReady || this.awaitingGatewaySelect)) {
  2209. console.warn('[一键外呼] destroyAll 跳过 resetState,避免打断进行中的外呼流程')
  2210. return
  2211. }
  2212. this.resetStateWithoutCancelSync()
  2213. if (forceReset) {
  2214. this._releaseBootLocks()
  2215. this._recoverAfterCancelCount = 0
  2216. this.initCancelled = false
  2217. this.isInitializing = false
  2218. this.awaitingGatewaySelect = false
  2219. this.gatewayConfirming = false
  2220. this._initActiveGen = 0
  2221. }
  2222. },
  2223. async _cleanupConnectionRefs(phoneRef, ccRef) {
  2224. this.stopAudioHealthCheck()
  2225. this._cancelRemoteHangupCheck()
  2226. this._clearIpccReconnectState()
  2227. this.unbindSipEvents()
  2228. if (phoneRef && this.phone === phoneRef) {
  2229. try { phoneRef.releaseLocalStream() } catch (e) {}
  2230. try { phoneRef.destroy() } catch (e) {}
  2231. if (this.phone === phoneRef) this.phone = null
  2232. }
  2233. if (ccRef && this.ccPhoneBar === ccRef) {
  2234. this._forceReleaseIpccCallState()
  2235. await this._gracefulDisconnectIpcc(IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
  2236. if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
  2237. }
  2238. },
  2239. },
  2240. beforeDestroy() {
  2241. if (this.beforeUnloadHandler) {
  2242. window.removeEventListener('beforeunload', this.beforeUnloadHandler)
  2243. }
  2244. this._clearInitWatchdog()
  2245. this._clearHangupCloseFailSafe()
  2246. this.cancelInit()
  2247. this._resetCallAvailability()
  2248. this.destroyAll(true, null, { forceReset: true })
  2249. }
  2250. }
  2251. </script>
  2252. <style scoped>
  2253. .call-content { padding: 0; }
  2254. .dialog-title {
  2255. display: flex;
  2256. align-items: center;
  2257. gap: 6px;
  2258. font-size: 15px;
  2259. font-weight: 600;
  2260. color: #303133;
  2261. }
  2262. .title-icon { color: #409eff; }
  2263. .progress-bar-container { padding: 4px 0 12px; }
  2264. .progress-track {
  2265. height: 3px;
  2266. background: #ebeef5;
  2267. border-radius: 2px;
  2268. margin-bottom: 12px;
  2269. overflow: hidden;
  2270. }
  2271. .progress-fill {
  2272. height: 100%;
  2273. background: linear-gradient(90deg, #409eff, #67c23a);
  2274. transition: width 0.4s ease;
  2275. }
  2276. .progress-steps { display: flex; justify-content: space-between; gap: 4px; }
  2277. .progress-step {
  2278. display: flex;
  2279. flex-direction: column;
  2280. align-items: center;
  2281. gap: 4px;
  2282. flex: 1;
  2283. }
  2284. .step-dot {
  2285. width: 24px;
  2286. height: 24px;
  2287. border-radius: 50%;
  2288. display: flex;
  2289. align-items: center;
  2290. justify-content: center;
  2291. transition: all 0.3s ease;
  2292. }
  2293. .step-pending .step-dot { background: #f5f7fa; border: 2px solid #dcdfe6; }
  2294. .step-loading .step-dot { background: #ecf5ff; border: 2px solid #409eff; }
  2295. .step-success .step-dot { background: #67c23a; border: 2px solid #67c23a; }
  2296. .step-error .step-dot { background: #f56c6c; border: 2px solid #f56c6c; }
  2297. .step-num { font-size: 10px; font-weight: 600; color: #909399; }
  2298. .step-dot .el-icon-loading { font-size: 12px; color: #409eff; }
  2299. .step-label { font-size: 9px; font-weight: 500; white-space: nowrap; }
  2300. .step-pending .step-label,
  2301. .step-error .step-label { color: #c0c4cc; }
  2302. .step-loading .step-label { color: #409eff; }
  2303. .step-success .step-label { color: #303133; }
  2304. .error-bar {
  2305. display: flex;
  2306. align-items: center;
  2307. justify-content: center;
  2308. gap: 6px;
  2309. padding: 6px 10px;
  2310. margin-bottom: 8px;
  2311. background: #fef0f0;
  2312. border-radius: 6px;
  2313. color: #f56c6c;
  2314. font-size: 11px;
  2315. }
  2316. .retry-btn {
  2317. background: #f56c6c;
  2318. color: white;
  2319. border: none;
  2320. border-radius: 4px;
  2321. padding: 2px 8px;
  2322. cursor: pointer;
  2323. font-size: 11px;
  2324. }
  2325. .call-area {
  2326. display: flex;
  2327. flex-direction: column;
  2328. align-items: center;
  2329. padding: 8px 0 4px;
  2330. }
  2331. .phone-number {
  2332. font-size: 22px;
  2333. font-weight: 700;
  2334. color: #1a1a2e;
  2335. letter-spacing: 2px;
  2336. margin-bottom: 14px;
  2337. }
  2338. .phone-icon-wrap { margin-bottom: 12px; }
  2339. .phone-icon-bg {
  2340. position: relative;
  2341. width: 64px;
  2342. height: 64px;
  2343. border-radius: 50%;
  2344. display: flex;
  2345. align-items: center;
  2346. justify-content: center;
  2347. background: #f0f5ff;
  2348. }
  2349. .icon-talking .phone-icon-bg { background: #f0f9eb; }
  2350. .pulse-ring-1, .pulse-ring-2 {
  2351. position: absolute;
  2352. inset: 0;
  2353. border-radius: 50%;
  2354. border: 2px solid #409eff;
  2355. opacity: 0;
  2356. animation: pulse-expand 1.8s ease-out infinite;
  2357. }
  2358. .pulse-ring-2 { animation-delay: 0.6s; }
  2359. @keyframes pulse-expand {
  2360. 0% { transform: scale(0.85); opacity: 0.6; }
  2361. 100% { transform: scale(1.5); opacity: 0; }
  2362. }
  2363. .icon-pulsing svg { animation: icon-bounce 1s ease-in-out infinite; }
  2364. .icon-ringing svg { animation: icon-ring 0.5s ease-in-out infinite; }
  2365. @keyframes icon-bounce {
  2366. 0%, 100% { transform: translateY(0); }
  2367. 50% { transform: translateY(-3px); }
  2368. }
  2369. @keyframes icon-ring {
  2370. 0%, 100% { transform: rotate(0deg); }
  2371. 25% { transform: rotate(6deg); }
  2372. 75% { transform: rotate(-6deg); }
  2373. }
  2374. .wave-bar {
  2375. position: absolute;
  2376. bottom: 6px;
  2377. width: 2px;
  2378. background: #67c23a;
  2379. border-radius: 1px;
  2380. animation: wave-jump 0.8s ease-in-out infinite;
  2381. }
  2382. .w1 { height: 8px; left: calc(50% - 12px); animation-delay: 0s; }
  2383. .w2 { height: 13px; left: calc(50% - 5px); animation-delay: 0.15s; }
  2384. .w3 { height: 8px; left: calc(50% + 8px); animation-delay: 0.3s; }
  2385. @keyframes wave-jump {
  2386. 0%, 100% { transform: scaleY(0.6); }
  2387. 50% { transform: scaleY(1.2); }
  2388. }
  2389. .call-state-text {
  2390. text-align: center;
  2391. font-size: 13px;
  2392. font-weight: 600;
  2393. margin-bottom: 12px;
  2394. }
  2395. .call-timer {
  2396. font-size: 16px;
  2397. font-family: monospace;
  2398. color: #303133;
  2399. display: block;
  2400. margin-top: 2px;
  2401. }
  2402. .status-connecting { color: #e6a23c; }
  2403. .status-ringing { color: #409eff; }
  2404. .status-talking { color: #4caf50; }
  2405. .status-ended { color: #909399; }
  2406. .status-hold { color: #e6a23c; }
  2407. .volume-controls {
  2408. display: flex;
  2409. gap: 16px;
  2410. padding: 6px 12px;
  2411. margin-bottom: 10px;
  2412. background: #f8fafc;
  2413. border-radius: 20px;
  2414. width: 100%;
  2415. justify-content: center;
  2416. }
  2417. .volume-item {
  2418. display: flex;
  2419. align-items: center;
  2420. gap: 6px;
  2421. flex: 1;
  2422. }
  2423. .volume-icon {
  2424. width: 26px;
  2425. height: 26px;
  2426. border-radius: 50%;
  2427. display: flex;
  2428. align-items: center;
  2429. justify-content: center;
  2430. background: #ecf5ff;
  2431. cursor: pointer;
  2432. color: #409eff;
  2433. flex-shrink: 0;
  2434. }
  2435. .volume-icon.muted { background: #fef0f0; color: #f56c6c; }
  2436. .volume-slider {
  2437. flex: 1;
  2438. height: 3px;
  2439. -webkit-appearance: none;
  2440. background: #e4e7ed;
  2441. border-radius: 2px;
  2442. outline: none;
  2443. }
  2444. .volume-slider::-webkit-slider-thumb {
  2445. -webkit-appearance: none;
  2446. width: 12px;
  2447. height: 12px;
  2448. border-radius: 50%;
  2449. background: #409eff;
  2450. cursor: pointer;
  2451. }
  2452. .volume-value { font-size: 10px; color: #909399; width: 32px; text-align: right; }
  2453. .call-actions {
  2454. display: flex;
  2455. align-items: center;
  2456. justify-content: center;
  2457. gap: 20px;
  2458. margin: 4px 0;
  2459. }
  2460. .action-btn {
  2461. display: flex;
  2462. flex-direction: column;
  2463. align-items: center;
  2464. gap: 4px;
  2465. background: none;
  2466. border: none;
  2467. cursor: pointer;
  2468. color: #fff;
  2469. transition: transform 0.1s;
  2470. }
  2471. .action-btn:active { transform: scale(0.96); }
  2472. .action-btn span { font-size: 10px; margin-top: 2px; }
  2473. .action-btn svg { width: 36px; height: 36px; padding: 8px; border-radius: 50%; }
  2474. .hangup-btn svg { background: #f56c6c; color: #fff; box-shadow: 0 2px 8px rgba(245,108,108,0.3); }
  2475. .pause-btn svg { background: #e6a23c; box-shadow: 0 2px 8px rgba(230,162,60,0.3); }
  2476. .resume-btn svg { background: #67c23a; box-shadow: 0 2px 8px rgba(103,194,58,0.3); }
  2477. .ended-notice {
  2478. display: flex;
  2479. align-items: center;
  2480. justify-content: center;
  2481. gap: 5px;
  2482. margin-top: 8px;
  2483. font-size: 11px;
  2484. color: #909399;
  2485. }
  2486. .ended-notice svg {
  2487. animation: notice-pulse 2s ease-in-out infinite;
  2488. }
  2489. @keyframes notice-pulse {
  2490. 0%, 100% { opacity: 0.6; }
  2491. 50% { opacity: 1; }
  2492. }
  2493. .gateway-picker {
  2494. margin-top: 4px;
  2495. padding: 12px;
  2496. background: linear-gradient(180deg, #f3f8ff 0%, #fafcff 100%);
  2497. border: 1px solid #d9e8ff;
  2498. border-radius: 12px;
  2499. }
  2500. .gateway-picker-head { margin-bottom: 10px; }
  2501. .gateway-picker-title {
  2502. font-size: 15px;
  2503. font-weight: 600;
  2504. color: #1f2d3d;
  2505. }
  2506. .gateway-picker-sub {
  2507. margin-top: 4px;
  2508. font-size: 12px;
  2509. color: #8a97a8;
  2510. }
  2511. .gateway-list {
  2512. max-height: 240px;
  2513. overflow-y: auto;
  2514. display: flex;
  2515. flex-direction: column;
  2516. gap: 8px;
  2517. }
  2518. .gateway-card {
  2519. display: flex;
  2520. align-items: center;
  2521. justify-content: space-between;
  2522. gap: 10px;
  2523. padding: 10px 12px;
  2524. background: #fff;
  2525. border: 1px solid #e4eaf2;
  2526. border-radius: 10px;
  2527. cursor: pointer;
  2528. transition: all .18s ease;
  2529. }
  2530. .gateway-card:hover { border-color: #9ec5ff; box-shadow: 0 2px 8px rgba(31,111,235,.08); }
  2531. .gateway-card.selected {
  2532. border-color: #1f6feb;
  2533. background: linear-gradient(90deg, #eef5ff 0%, #ffffff 70%);
  2534. box-shadow: inset 3px 0 0 #1f6feb;
  2535. }
  2536. .gateway-card.theme-mobile.selected { box-shadow: inset 3px 0 0 #12b886; border-color: #69db9d; }
  2537. .gateway-name {
  2538. font-size: 13px;
  2539. font-weight: 600;
  2540. color: #1f2d3d;
  2541. line-height: 1.35;
  2542. }
  2543. .gateway-meta {
  2544. margin-top: 4px;
  2545. display: flex;
  2546. flex-wrap: wrap;
  2547. gap: 8px;
  2548. font-size: 12px;
  2549. color: #909399;
  2550. }
  2551. .gateway-side {
  2552. display: flex;
  2553. align-items: center;
  2554. gap: 8px;
  2555. flex-shrink: 0;
  2556. }
  2557. .gateway-badge {
  2558. padding: 2px 8px;
  2559. border-radius: 999px;
  2560. font-size: 11px;
  2561. background: #eef2f7;
  2562. color: #606266;
  2563. }
  2564. .gateway-card.theme-mobile .gateway-badge { background: #e6fcf5; color: #0ca678; }
  2565. .gateway-card.theme-landline .gateway-badge { background: #e7f1ff; color: #1f6feb; }
  2566. .gateway-check { color: #1f6feb; font-size: 16px; font-weight: 700; }
  2567. .gateway-actions {
  2568. margin-top: 12px;
  2569. display: flex;
  2570. align-items: center;
  2571. justify-content: space-between;
  2572. gap: 10px;
  2573. }
  2574. .gateway-selected-tip {
  2575. flex: 1;
  2576. font-size: 12px;
  2577. color: #1f6feb;
  2578. overflow: hidden;
  2579. text-overflow: ellipsis;
  2580. white-space: nowrap;
  2581. }
  2582. .gateway-confirm-btn {
  2583. border: none;
  2584. outline: none;
  2585. height: 36px;
  2586. padding: 0 16px;
  2587. border-radius: 8px;
  2588. background: linear-gradient(135deg, #1f6feb 0%, #3b82f6 100%);
  2589. color: #fff;
  2590. font-size: 13px;
  2591. font-weight: 600;
  2592. cursor: pointer;
  2593. display: inline-flex;
  2594. align-items: center;
  2595. gap: 6px;
  2596. box-shadow: 0 4px 12px rgba(31,111,235,.28);
  2597. }
  2598. .gateway-confirm-btn:disabled {
  2599. opacity: .55;
  2600. cursor: not-allowed;
  2601. box-shadow: none;
  2602. }
  2603. </style>
  2604. <style>
  2605. .call-phone-dialog { border-radius: 12px; overflow: hidden; }
  2606. .call-phone-dialog .el-dialog__header { padding: 14px 20px 10px; border-bottom: 1px solid #f0f0f0; }
  2607. .call-phone-dialog .el-dialog__body { padding: 12px 20px 18px; }
  2608. .call-phone-dialog .el-dialog__headerbtn { top: 14px; right: 18px; }
  2609. </style>