<template>
  <el-dialog
    :visible.sync="dialogVisible"
    :width="awaitingGatewaySelect ? '560px' : '440px'"
    append-to-body
    :close-on-click-modal="false"
    :close-on-press-escape="false"
    :show-close="canManualClose"
    :before-close="beforeDialogClose"
    @opened="onDialogOpened"
    @close="handleClose"
    custom-class="call-phone-dialog"
  >
    <template slot="title">
      <div class="dialog-title">
        <svg class="title-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
        </svg>
        <span>一键外呼</span>
      </div>
    </template>

    <div class="call-content">
      <!-- 横向进度条 -->
      <div class="progress-bar-container">
        <div class="progress-track">
          <div class="progress-fill" :style="{ width: progressPercent + '%' }" />
        </div>
        <div class="progress-steps">
          <div
            v-for="(step, index) in flowSteps"
            :key="step.key"
            class="progress-step"
            :class="'step-' + step.status"
          >
            <div class="step-dot">
              <span class="step-num" v-if="step.status === 'pending'">{{ index + 1 }}</span>
              <i class="el-icon-loading" v-else-if="step.status === 'loading'"></i>
              <svg v-else-if="step.status === 'success'" class="step-check" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3">
                <polyline points="20 6 9 17 4 12"/>
              </svg>
              <svg v-else-if="step.status === 'error'" class="step-cross" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3">
                <line x1="18" y1="6" x2="6" y2="18"/>
                <line x1="6" y1="6" x2="18" y2="18"/>
              </svg>
            </div>
            <span class="step-label">{{ step.title }}</span>
          </div>
        </div>
      </div>

      <!-- 错误提示与重试 -->
      <div class="error-bar" v-if="hasStepError && globalErrorMsg">
        <i class="el-icon-warning" />
        <span>{{ globalErrorMsg }}</span>
        <button class="retry-btn" @click="retryInit" v-if="callState === 'idle'">重试</button>
      </div>

      <!-- 线路选择（必须选择后才能继续） -->
      <div class="gateway-picker" v-if="awaitingGatewaySelect" v-loading="gatewayListLoading">
        <div class="gateway-picker-head">
          <div class="gateway-picker-title">选择外呼线路</div>
          <div class="gateway-picker-sub">请选择线路后再继续外呼，未选择无法发起通话</div>
        </div>
        <div class="gateway-list">
          <div
            v-for="item in gatewayList"
            :key="item.id"
            class="gateway-card"
            :class="{
              selected: String(selectedGatewayId) === String(item.id),
              ['theme-' + getGatewayBadge(item).theme]: true
            }"
            @click="selectGateway(item.id)"
          >
            <div class="gateway-card-main">
              <div class="gateway-name">{{ item.gwDesc || item.gwName || ('线路 ' + item.id) }}</div>
              <div class="gateway-meta" v-if="item.caller || item.gwName">
                <span v-if="item.gwName && item.gwDesc !== item.gwName">{{ item.gwName }}</span>
                <span v-if="item.caller">主叫 {{ item.caller }}</span>
              </div>
            </div>
            <div class="gateway-side">
              <span class="gateway-badge">{{ getGatewayBadge(item).text }}</span>
              <i class="el-icon-check gateway-check" v-if="String(selectedGatewayId) === String(item.id)"></i>
            </div>
          </div>
        </div>
        <div class="gateway-actions">
          <div class="gateway-selected-tip" v-if="selectedGatewayTitle">
            已选：{{ selectedGatewayTitle }}
          </div>
          <button
            class="gateway-confirm-btn"
            :disabled="!selectedGatewayId || gatewayConfirming"
            @click="confirmGatewaySelect"
          >
            <i class="el-icon-loading" v-if="gatewayConfirming"></i>
            <span>{{ gatewayConfirming ? '连接中...' : '确认线路并外呼' }}</span>
          </button>
        </div>
      </div>

      <!-- 外呼号码显示区域 -->
      <div class="call-area" v-if="isReady && !awaitingGatewaySelect">
        <div class="phone-number">{{ maskedPhone }}</div>

        <!-- 电话图标 + 动画 -->
        <div class="phone-icon-wrap" :class="iconAnimClass">
          <div class="phone-icon-bg">
            <span class="pulse-ring-1" v-if="callState === 'connecting' || callState === 'ringing'" />
            <span class="pulse-ring-2" v-if="callState === 'connecting' || callState === 'ringing'" />
            <span class="wave-bar w1" v-if="callState === 'talking' && !isOnHold" />
            <span class="wave-bar w2" v-if="callState === 'talking' && !isOnHold" />
            <span class="wave-bar w3" v-if="callState === 'talking' && !isOnHold" />
            <svg viewBox="0 0 24 24" width="32" height="32" fill="none" :stroke="iconStroke" stroke-width="2">
              <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
            </svg>
          </div>
        </div>

        <!-- 通话状态文字 -->
        <div class="call-state-text" :class="statusClass">
          <span>{{ stateText }}</span>
          <span class="call-timer" v-if="callTimer && callState === 'talking'">{{ callTimer }}</span>
        </div>

        <!-- 音量控制（紧凑单行布局） -->
        <div class="volume-controls" v-if="callState === 'talking' && !isOnHold">
          <div class="volume-item">
            <div class="volume-icon" :class="{ muted: isMicMuted }" @click="toggleMicMute">
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
                <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
                <line v-if="isMicMuted" x1="1" y1="1" x2="23" y2="23"/>
              </svg>
            </div>
            <input type="range" class="volume-slider" min="0" max="100" :value="isMicMuted ? 0 : micVolume" @input="onMicVolumeChange" />
            <span class="volume-value">{{ isMicMuted ? 0 : micVolume }}%</span>
          </div>
          <div class="volume-item">
            <div class="volume-icon" :class="{ muted: isSpeakerMuted }" @click="toggleSpeakerMute">
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
                <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
                <line v-if="isSpeakerMuted || speakerVolume === 0" x1="23" y1="9" x2="17" y2="15"/>
                <line v-if="isSpeakerMuted || speakerVolume === 0" x1="17" y1="9" x2="23" y2="15"/>
                <path v-else d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
              </svg>
            </div>
            <input type="range" class="volume-slider" min="0" max="100" :value="isSpeakerMuted ? 0 : speakerVolume" @input="onSpeakerVolumeChange" />
            <span class="volume-value">{{ isSpeakerMuted ? 0 : speakerVolume }}%</span>
          </div>
        </div>

        <!-- 操作按钮 -->
        <div class="call-actions">
          <template v-if="callState === 'connecting' || callState === 'ringing'">
            <button class="action-btn hangup-btn" @click="handleHangup">
              <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
                <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>
              </svg>
              <span>挂断</span>
            </button>
          </template>

          <template v-if="callState === 'talking'">
            <button class="action-btn hangup-btn" @click="handleHangup">
              <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
                <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" transform="rotate(135 12 12)"/>
              </svg>
              <span>挂断</span>
            </button>
            <button class="action-btn" :class="isOnHold ? 'resume-btn' : 'pause-btn'" @click="isOnHold ? handleResume() : handlePause()">
              <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
                <rect v-if="!isOnHold" x="6" y="4" width="4" height="16" rx="1"/>
                <rect v-if="!isOnHold" x="14" y="4" width="4" height="16" rx="1"/>
                <polygon v-else points="5 3 19 12 5 21 5 3"/>
              </svg>
              <span>{{ isOnHold ? '恢复' : '暂停' }}</span>
            </button>
          </template>
        </div>

        <!-- 通话已结束：统一文案，后台同步不展示细节 -->
        <div class="ended-notice" v-if="callState === 'ended'">
          <i class="el-icon-loading"></i>
          <span>通话结束，系统回收资源中</span>
        </div>
      </div>
    </div>
  </el-dialog>
</template>

<script>
import axios from 'axios'
import { WebPhone, ProfileManager, checkMicrophonePermission, IPCC_DEFAULTS, JS_SIP_DEFAULTS } from '@/api/aiSipCall/softPhone.js'
import ccPhoneBarSocket from '@/assets/callCenterPhoneBarSdk/ccPhoneBarSocket.js'
import { EventList, VideoLevels, AgentStatusEnum } from '@/assets/callCenterPhoneBarSdk/constants.js'
import { myCallUser, getToolbarBasicParam} from '@/api/aiSipCall/aiSipCallUser.js'
import {remoteGatewayList} from '@/api/aiSipCall/aiSipCallGateway.js'
import { encryptMobile, callEndSyncByUuid } from '@/api/aiSipCall/aiSipCallOutboundCdr.js'

const IPCC_CONFIG = IPCC_DEFAULTS
const JS_SIP_CONFIG = JS_SIP_DEFAULTS

const TIMEOUT_DECRYPT = 6000
const TIMEOUT_IPCC_CONNECT = IPCC_CONFIG.CONNECT_TIMEOUT || 8000
const TIMEOUT_SIP_REGISTER = 10000
const TIMEOUT_AGENT_READY = 10000
const POLL_INTERVAL_AGENT = 150
const DELAY_STEP_TRANSITION = 400
/** 正常挂断后延迟同步话单 */
const DELAY_SYNC_CALL_RECORD = 3000
/** 挂断后最多多久必须关窗 */
const CLOSE_AFTER_HANGUP_MS = 2000
const HANGUP_LOCK_TIMEOUT = 30000
const OUTBOUND_BRIDGE_PROTECT_MS = 45000
const IPCC_GATEWAY_RELEASE_DELAY_MS = IPCC_CONFIG.GATEWAY_RELEASE_DELAY_MS || 500
const MOBILE_GATEWAY_MATCH = ['润天售后', '润天售后外呼', '手机外呼']
const ACTIVE_CALL_STORAGE_KEY = 'sip_call_dialog_active_call_v1'
const GLOBAL_SYNCED_UUIDS = new Set()
const GLOBAL_SYNCING_UUIDS = new Set()
const GLOBAL_SYNCED_UUID_MAX = 300
const ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS = 2 * 60 * 1000
const INIT_WATCHDOG_MS = 60000
/** 流程中断后尽快关窗 */
const CLOSE_AFTER_FAIL_MS = 1500
/** 同一次弹窗内，取消后自动恢复启动的最大次数，防止死循环 */
const MAX_RECOVER_AFTER_CANCEL = 1

function normalizeCallUuid(uuid) {
  if (uuid == null || uuid === '') return ''
  return String(uuid).trim()
}

function markGlobalSynced(id) {
  if (!id) return
  GLOBAL_SYNCED_UUIDS.add(id)
  GLOBAL_SYNCING_UUIDS.delete(id)
  while (GLOBAL_SYNCED_UUIDS.size > GLOBAL_SYNCED_UUID_MAX) {
    const first = GLOBAL_SYNCED_UUIDS.values().next().value
    GLOBAL_SYNCED_UUIDS.delete(first)
  }
}

/** 弹窗关闭后仍可后台同步，避免关窗导致话单丢失 / 死锁 */
function scheduleGlobalCallSync(uuid) {
  const id = normalizeCallUuid(uuid)
  if (!id) return
  if (GLOBAL_SYNCED_UUIDS.has(id) || GLOBAL_SYNCING_UUIDS.has(id)) return
  GLOBAL_SYNCING_UUIDS.add(id)
  setTimeout(() => {
    callEndSyncByUuid({ uuid: id })
      .then((res) => {
        markGlobalSynced(id)
      })
      .catch((err) => {
        markGlobalSynced(id)
        console.warn('[一键外呼] 后台同步失败(已忽略):', id, err && err.message)
      })
  }, DELAY_SYNC_CALL_RECORD)
}

export default {
  name: 'CallPhoneDialog',
  props: {
    /** 弹窗显隐，配合 .sync / update:sipVisible */
    sipVisible: { type: Boolean, default: false },
    /** 加密手机号（与 sipPlainPhone 二选一，优先加密） */
    sipEncryptedPhone: { type: String, default: '' },
    sipEncryptedUserId: { type: Number, default: '' },
    /** 明文手机号（无加密号时使用，跳过解密步骤） */
    sipPlainPhone: { type: String, default: '' },
    /** 通话类型 audio/video */
    sipDialType: { type: String, default: 'audio' },
    /** 业务关联 ID，挂断后通过 sip-call-ended 回传 */
    sipRecordId: { type: [String, Number], default: '' }
  },
  data() {
    return {
      flowSteps: [
        { key: 'decrypt', title: '解析号码', status: 'pending', errorMsg: '' },
        { key: 'gateway', title: '选择线路', status: 'pending', errorMsg: '' },
        { key: 'ipcc', title: '连接座席', status: 'pending', errorMsg: '' },
        { key: 'jssip', title: '注册分机', status: 'pending', errorMsg: '' },
        { key: 'call', title: '发起外呼', status: 'pending', errorMsg: '' }
      ],
      ipccConnected: false,
      sipRegistered: false,
      isReady: false,
      globalErrorMsg: '',
      decryptedPhone: '',
      maskedPhone: '',
      isDecrypting: false,
      decryptCancelTokenSource: null,
      callState: 'idle',
      isOnHold: false,
      callTimer: '',
      stateText: '',
      statusClass: '',
      timerInterval: null,
      callStartTime: null,
      currentCallUuid: '',
      ccPhoneBar: null,
      phone: null,
      profileManager: null,
      ccSocketConnected: false,
      ccConnectingResolve: null,
      ccConnectingReject: null,
      ccConnectingPromise: null,
      isCallingReady: false,
      currentUserDisplay: '',
      autoCloseCountdown: 0,
      autoCloseTimer: null,
      isAutoClosing: false,
      _hangupCloseFailSafe: null,
      isSyncPending: false,
      syncPhase: 'idle',
      syncPhaseText: '',
      micVolume: 80,
      speakerVolume: 80,
      isMicMuted: false,
      isSpeakerMuted: false,
      savedMicVolume: 80,
      savedSpeakerVolume: 80,
      initCancelled: false,
      isInitializing: false,
      sipReject: null,
      callStartResolve: null,
      isHangingUp: false,
      hangupLockTimer: null,
      ccEventRefs: [],
      sipEventHandlers: null,
      callDirection: '',
      _bridgeStartedAt: 0,
      _suppressIpccHangupUntil: 0,
      _callEstablishedAt: 0,
      _lastAsrAt: 0,
      _ipccRecovering: false,
      _completingHangup: false,
      _audioHealthTimer: null,
      _remoteHangupCheckTimer: null,
      _lastCallTimerTickAt: 0,
      _ipccReconnectTimer: null,
      _ipccReconnecting: false,
      _ipccReconnectFailCount: 0,
      _ccConnectingTimeoutId: null,
      _ccConnectStartedAt: 0,
      _ipccConnectedAt: 0,
      _pendingIpccHangup: false,
      gatewayList: [],
      connectedGatewayIds: [],
      selectedGatewayId: null,
      awaitingGatewaySelect: false,
      gatewayConfirming: false,
      gatewayListLoading: false,
      _gatewaySelectResolve: null,
      _gatewaySelectReject: null,
      _pendingExtInfo: null,
      beforeUnloadHandler: null,
      _initGeneration: 0,
      _openRunId: 0,
      _syncLockFailSafeTimer: null,
      _initWatchdogTimer: null,
      _needReleaseStaleIpccSession: false,
      /** 防止 opened / watch / mounted 重复启动 */
      _bootStarted: false,
      _booting: false,
      /** 当前正在执行的 initComponent 代数；cancel 时清零，避免 finally 与抬代互相卡死 */
      _initActiveGen: 0,
      /** 本次弹窗内 cancel 后自动恢复次数 */
      _recoverAfterCancelCount: 0
    }
  },
  computed: {
    dialogVisible: {
      get() { return this.sipVisible },
      set(val) {
        if (!val && !this._canCloseDialog()) {
          this.$message.warning(this._closeBlockReason())
          return
        }
        this.$emit('update:sipVisible', val)
      }
    },
    canManualClose() {
      return this._canCloseDialog()
    },
    effectiveDialType() { return this.sipDialType || 'audio' },
    effectiveRecordId() { return this.sipRecordId },
    selectedGateway() {
      if (!this.selectedGatewayId) return null
      return this.gatewayList.find(g => String(g.id) === String(this.selectedGatewayId)) || null
    },
    selectedGatewayTitle() {
      const g = this.selectedGateway
      if (!g) return ''
      return g.gwDesc || g.gwName || ('线路 ' + g.id)
    },
    hasStepError() { return this.flowSteps.some(s => s.status === 'error') },
    progressPercent() {
      const done = this.flowSteps.filter(s => s.status === 'success').length
      const total = this.flowSteps.length
      const current = this.flowSteps.findIndex(s => s.status === 'loading')
      if (current >= 0) return ((current + 0.5) / total) * 100
      return (done / total) * 100
    },
    iconAnimClass() {
      if (this.callState === 'connecting') return 'icon-pulsing'
      if (this.callState === 'ringing') return 'icon-ringing'
      if (this.callState === 'talking') return this.isOnHold ? 'icon-hold' : 'icon-talking'
      return ''
    },
    iconStroke() {
      if (this.callState === 'talking' && !this.isOnHold) return '#4caf50'
      if (this.callState === 'connecting' || this.callState === 'ringing') return '#409eff'
      return '#409eff'
    }
  },
  watch: {
    sipVisible(val, oldVal) {
      // 关闭时清理；打开启动改由 @opened / mounted 触发，避免 watch 与弹窗生命周期竞态
      if (!val && oldVal === true) {
        this._teardownCallSession()
      }
    }
  },
  created() {
    // Vue2 不会把 data 里 `_` 开头字段代理到 this，必须挂到实例上，否则 ++undefined => NaN
    this._openRunId = 0
    this._initGeneration = 0
    this._initActiveGen = 0
    this._bootStarted = false
    this._booting = false
    this._recoverAfterCancelCount = 0
    this._gatewaySelectResolve = null
    this._gatewaySelectReject = null
    this._pendingExtInfo = null
    this._recoverAfterPageLoad()
  },
  mounted() {
    this.beforeUnloadHandler = (event) => {
      this.handleBeforeUnload()
    }
    window.addEventListener('beforeunload', this.beforeUnloadHandler)
    // v-if 挂载且可见时：部分 Element 版本不一定立刻抛 opened，这里兜底启动
    if (this.sipVisible) {
      this.$nextTick(() => {
        this._healStaleBootLocks('mounted')
        this._scheduleBoot('mounted')
      })
    }
  },
  methods: {
    setStepStatus(key, status, errorMsg = '') {
      const step = this.flowSteps.find(s => s.key === key)
      if (step) {
        step.status = status
        if (errorMsg) step.errorMsg = errorMsg
      }
    },

    /** 弹窗动画完成后再启动，保证 DOM 已就绪 */
    onDialogOpened() {
      this._healStaleBootLocks('opened')
      this._scheduleBoot('opened')
    },

    /**
     * 打开时自愈：清理「无真实流程却占着启动锁」的残留，避免永远跳过启动
     */
    _healStaleBootLocks(from) {
      if (!this.sipVisible) return
      const inLiveFlow = this.awaitingGatewaySelect || this.isReady ||
        ['talking', 'ringing', 'connecting', 'ended'].includes(this.callState)
      if (inLiveFlow) return
      // 真实 init / boot 进行中不干预
      if (this._initActiveGen || this._booting) return
      if (this._bootStarted || this.isInitializing || this.initCancelled) {
        console.warn('[一键外呼] 清除残留启动锁, from=', from)
        this._bootStarted = false
        this.isInitializing = false
        this.initCancelled = false
        this.gatewayConfirming = false
      }
    },

    /**
     * 统一启动入口（防重入）
     * @param {string} from 调用来源，便于排查
     */
    _scheduleBoot(from) {
      if (!this.sipVisible) return
      if (this._bootStarted || this._booting || this.isInitializing) {
        return
      }
      // 已进入选线路 / 通话中则不再重启
      if (this.awaitingGatewaySelect || this.isReady) return
      this._bootStarted = true
      this._bootCallSession()
    },

    /** 打开弹窗后启动流程 */
    async _bootCallSession() {
      if (this._booting) return
      this._booting = true
      const token = ++this._openRunId
      // 注意：打开时不要 cancelInit，否则会把即将开始的初始化标成已取消
      this.initCancelled = false
      this._recoverAfterCancelCount = 0
      this._clearHangupCloseFailSafe()
      this._forceUnlockCallGuards({ soft: true })

      // 同步立刻更新 UI，避免“只弹窗、步骤全 pending”的观感
      this.setStepStatus('decrypt', 'loading')

      try {
        // 无残留连接时跳过 destroy，避免 Promise.race 超时后迟到的 cleanup 打断初始化
        const needCleanup = !!(this.phone || this.ccPhoneBar || this.isReady)
        if (needCleanup) {
          await this._destroyAllWithTimeout(true, 3000, token)
        }
        if (token !== this._openRunId || !this.sipVisible) {
          console.warn('[一键外呼] 启动已失效, token=', token, 'openRunId=', this._openRunId, 'visible=', this.sipVisible)
          // 仅本轮 token 仍有效时才释锁，避免误伤已经开始的新会话
          if (token === this._openRunId) this._releaseBootLocks()
          return
        }
        this.initCancelled = false
        await this.initComponent()
      } catch (err) {
        if (token !== this._openRunId || !this.sipVisible) return
        console.error('[一键外呼] 启动失败:', err && err.message)
        this.globalErrorMsg = (err && err.message) || '外呼启动失败'
        this.setStepStatus('decrypt', 'error', this.globalErrorMsg)
        this.$message.error(this.globalErrorMsg)
        this._forceUnlockCallGuards({ soft: false })
        // 允许用户点重试
        this._releaseBootLocks()
      } finally {
        // 绝不能清掉新一轮 boot 的 _booting
        if (token === this._openRunId) {
          this._booting = false
        }
      }
    },

    /** 释放启动防重入锁，保证关窗后可再次一键外呼 */
    _releaseBootLocks() {
      this._bootStarted = false
      this._booting = false
    },

    /**
     * 关窗/异常后恢复「可再次外呼」的全部守卫
     * 选线路中关闭、流程异常、超时都必须走到这里
     */
    _resetCallAvailability() {
      this._releaseBootLocks()
      this._recoverAfterCancelCount = 0
      this.initCancelled = false
      this._forceUnlockCallGuards({ soft: false })
      this._clearHangupCloseFailSafe()
    },

    /** 关闭弹窗：取消初始化并释放资源，必须恢复可再次外呼 */
    _teardownCallSession() {
      this._openRunId++
      this.cancelInit()
      this._resetCallAvailability()
      this._destroyAllWithTimeout(true, 3000, this._openRunId, { forceReset: true }).catch(() => {})
    },

    async _destroyAllWithTimeout(skipSync = true, ms = 3000, sessionToken = null, options = {}) {
      try {
        await Promise.race([
          this.destroyAll(skipSync, sessionToken, options),
          new Promise((resolve) => setTimeout(resolve, ms))
        ])
      } catch (e) {
        console.warn('[一键外呼] destroyAll 异常，继续启动:', e && e.message)
      }
    },

    async initComponent() {
      // 绑定本次弹窗会话，避免旧 init 的取消回调误伤新一轮外呼
      const sessionToken = this._openRunId
      const gen = ++this._initGeneration
      this._initActiveGen = gen
      this.isInitializing = true
      this.initCancelled = false
      this.resetState()
      this._armInitWatchdog(gen)

      const assertAlive = () => {
        if (this.initCancelled || gen !== this._initGeneration) {
          throw new Error('初始化已取消')
        }
      }

      try {
        this.setStepStatus('decrypt', 'loading')
        await this.resolveDialPhone()
        assertAlive()
        this.setStepStatus('decrypt', 'success')

        this.setStepStatus('gateway', 'loading')
        await this.loadGatewayOptions()
        assertAlive()
        // 选线路是用户操作，暂停 watchdog，避免选太久被误判超时关窗
        this._clearInitWatchdog()
        this.awaitingGatewaySelect = true
        await this.waitForGatewayConfirm()
        assertAlive()
        this.awaitingGatewaySelect = false
        this.setStepStatus('gateway', 'success')
        this._armInitWatchdog(gen)

        this.setStepStatus('ipcc', 'loading')
        await this.connectIPCC()
        assertAlive()
        this.setStepStatus('ipcc', 'success')

        this.setStepStatus('jssip', 'loading')
        await this.startSIP()
        assertAlive()
        this.setStepStatus('jssip', 'success')

        await new Promise(r => setTimeout(r, DELAY_STEP_TRANSITION))
        assertAlive()

        this.setStepStatus('call', 'loading')
        await this.waitForCallingReady(TIMEOUT_AGENT_READY)
        assertAlive()
        await this.autoCall()
      } catch (err) {
        // 会话已切到新一轮：旧 init 只退出，禁止动任何锁/恢复逻辑
        if (sessionToken !== this._openRunId) {
          return
        }
        if (err && err.message === '初始化已取消') {
          console.warn('[一键外呼] 初始化已取消')
          // 只放开 _bootStarted；_booting 由外层 _bootCallSession.finally 负责，避免竞态踩踏
          this._bootStarted = false
          // 弹窗仍开着：关窗动画与 reopen 竞态时自动恢复（限次）
          if (
            this.sipVisible &&
            this._recoverAfterCancelCount < MAX_RECOVER_AFTER_CANCEL
          ) {
            this._recoverAfterCancelCount++
            this.initCancelled = false
            this.$nextTick(() => {
              if (
                sessionToken === this._openRunId &&
                this.sipVisible &&
                !this._bootStarted &&
                !this._booting &&
                !this.isInitializing &&
                !this.awaitingGatewaySelect &&
                !this.isReady
              ) {
                this._scheduleBoot('recover-after-cancel')
              }
            })
          }
        } else if (gen === this._initGeneration) {
          console.error('[一键外呼] 初始化失败:', err && err.message)
          this.globalErrorMsg = (err && err.message) || '连接失败'
          this.$message.error(this.globalErrorMsg)
          this.awaitingGatewaySelect = false
          this.isReady = false
          this._bootStarted = false
          // 流程中断：尽快回收并关窗，避免卡死下次外呼
          this._forceUnlockCallGuards({ soft: false })
          this.destroyAll(true, null, { forceReset: true }).catch(() => {})
          clearTimeout(this._failCloseTimer)
          this._failCloseTimer = setTimeout(() => {
            this._failCloseTimer = null
            if (sessionToken === this._openRunId) this.doClose()
          }, CLOSE_AFTER_FAIL_MS)
        }
      } finally {
        this._clearInitWatchdog()
        // 用 _initActiveGen 判定：cancelInit 抬代后仍能释放本轮锁，且不会误清新一轮 init
        if (this._initActiveGen === gen) {
          this.isInitializing = false
          this.gatewayConfirming = false
          this._initActiveGen = 0
        }
      }
    },
    retryInit() {
      if (this.isInitializing || this._booting) return
      this._clearHangupCloseFailSafe()
      this._releaseBootLocks()
      this._recoverAfterCancelCount = 0
      this.initCancelled = false
      this.destroyAll(true, null, { forceReset: true }).then(() => {
        if (this.sipVisible) this._scheduleBoot('retry')
      })
    },

    cancelInit() {
      this.initCancelled = true
      this._initGeneration++
      // 标记当前没有活跃 init，让旧 init 的 finally 能解锁，并立刻放开 isInitializing
      this._initActiveGen = 0
      this.isInitializing = false
      this.awaitingGatewaySelect = false
      this.gatewayConfirming = false
      this.rejectCCConnect(new Error('初始化已取消'))
      if (this._gatewaySelectReject) {
        this._gatewaySelectReject(new Error('初始化已取消'))
        this._gatewaySelectReject = null
        this._gatewaySelectResolve = null
      }
      if (this.sipReject) {
        this.sipReject(new Error('初始化已取消'))
        this.sipReject = null
      }
      this.cancelDecryptRequest()
      this._clearInitWatchdog()
    },

    resetState() {
      this.initCancelled = false
      this.sipReject = null
      this.callStartResolve = null
      this.isHangingUp = false
      this.clearHangupLockTimer()
      this.stopAudioHealthCheck()
      this._cancelRemoteHangupCheck()
      this.unbindCCEvents()
      this.unbindSipEvents()
      this.cancelDecryptRequest()
      this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
      this.cancelAutoClose()
      this.ipccConnected = false
      this.sipRegistered = false
      this.isReady = false
      this.globalErrorMsg = ''
      this.callState = 'idle'
      this.isOnHold = false
      this.callTimer = ''
      this.stateText = ''
      this.statusClass = ''
      this.callStartTime = null
      this.currentCallUuid = ''
      this.callDirection = ''
      this.isCallingReady = false
      this.isAutoClosing = false
      this.isSyncPending = false
      this.syncPhase = 'idle'
      this.syncPhaseText = ''
      this._clearHangupCloseFailSafe()
      this.awaitingGatewaySelect = false
      this.gatewayConfirming = false
      this.selectedGatewayId = null
      this._pendingExtInfo = null
      if (this._gatewaySelectReject) {
        const rej = this._gatewaySelectReject
        this._gatewaySelectReject = null
        this._gatewaySelectResolve = null
        try { rej(new Error('初始化已取消')) } catch (e) {}
      } else {
        this._gatewaySelectResolve = null
        this._gatewaySelectReject = null
      }
      this._bridgeStartedAt = 0
      this._suppressIpccHangupUntil = 0
      this._callEstablishedAt = 0
      this._lastAsrAt = 0
      this._ipccRecovering = false
      this._completingHangup = false
      this._lastCallTimerTickAt = 0
      this._clearIpccReconnectState()
      this.gatewayList = []
      this.connectedGatewayIds = []
      this.stopTimer()
    },

    resetStateWithoutCancelSync() {
      this.initCancelled = false
      this.sipReject = null
      this.callStartResolve = null
      this.isHangingUp = false
      this.clearHangupLockTimer()
      this.stopAudioHealthCheck()
      this._cancelRemoteHangupCheck()
      this.unbindCCEvents()
      this.unbindSipEvents()
      this.cancelDecryptRequest()
      this.flowSteps.forEach(s => { s.status = 'pending'; s.errorMsg = '' })
      this.cancelAutoClose()
      this._clearHangupCloseFailSafe()
      this.ipccConnected = false
      this.sipRegistered = false
      this.isReady = false
      this.globalErrorMsg = ''
      this.callState = 'idle'
      this.isOnHold = false
      this.callTimer = ''
      this.stateText = ''
      this.statusClass = ''
      this.callStartTime = null
      this.currentCallUuid = ''
      this.callDirection = ''
      this.isCallingReady = false
      this.isAutoClosing = false
      this.isSyncPending = false
      this.syncPhase = 'idle'
      this.syncPhaseText = ''
      this._bridgeStartedAt = 0
      this._suppressIpccHangupUntil = 0
      this._callEstablishedAt = 0
      this._lastAsrAt = 0
      this._ipccRecovering = false
      this._completingHangup = false
      this._lastCallTimerTickAt = 0
      this._clearIpccReconnectState()
      this.stopTimer()
    },

    // 解密请求优化：增加超时和取消令牌
    // 解析拨号号码：优先加密号解密，否则使用明文号
    async resolveDialPhone() {
      if (this.sipEncryptedPhone) {
        await this.decryptPhone()
        return
      }
      if (this.sipPlainPhone) {
        const phone = String(this.sipPlainPhone).trim()
        if (!phone || phone.length < 3) {
          const errMsg = '明文手机号无效'
          this.setStepStatus('decrypt', 'error', errMsg)
          throw new Error(errMsg)
        }
        this.decryptedPhone = phone
        this.maskedPhone = this.maskPhone(phone)
        return
      }
      const errMsg = '未提供手机号（sipEncryptedPhone / sipPlainPhone）'
      this.setStepStatus('decrypt', 'error', errMsg)
      throw new Error(errMsg)
    },

    // 解密请求优化：增加超时和取消令牌
    async decryptPhone() {
      if (!this.sipEncryptedPhone) {
        const errMsg = '未提供加密手机号'
        this.setStepStatus('decrypt', 'error', errMsg)
        throw new Error(errMsg)
      }

      this.isDecrypting = true
      this.cancelDecryptRequest()

      const CancelToken = axios.CancelToken
      this.decryptCancelTokenSource = CancelToken.source()

      try {
        const random = Math.floor(100000 + Math.random() * 900000).toString()
        const combined = this.sipEncryptedPhone + random
        const userId = this.sipEncryptedUserId
        const data={
          phone:combined,
          id:userId,
        }

        const response = await Promise.race([
          encryptMobile(data, { cancelToken: this.decryptCancelTokenSource.token }),
          new Promise((_, reject) => setTimeout(() => reject(new Error('号码解密超时')), TIMEOUT_DECRYPT))
        ])

        if (this.initCancelled) throw new Error('已取消')

        if (response.code === 200) {
          const privateKey = process.env.VUE_APP_SIP_PHONE_ENCRYPT_PRIVATE_KEY
          if (!privateKey) {
            throw new Error('解密私钥未配置')
          }
          const resultData = response.data.slice(0, -6)
          this.decryptedPhone = this.xorDecrypt(resultData, privateKey)
          this.maskedPhone = this.maskPhone(this.decryptedPhone)
        } else {
          throw new Error(response.message || response.msg || '号码解密失败')
        }
      } catch (error) {
        if (axios.isCancel(error)) {
          throw new Error('解密已取消')
        }
        const errMsg = error.message || '号码解密异常'
        this.setStepStatus('decrypt', 'error', errMsg)
        throw error
      } finally {
        this.isDecrypting = false
        this.decryptCancelTokenSource = null
      }
    },
    cancelDecryptRequest() {
      if (this.decryptCancelTokenSource) {
        this.decryptCancelTokenSource.cancel('解密已取消')
        this.decryptCancelTokenSource = null
      }
    },

    xorDecrypt(base64Str, privateKey) {
      const binaryStr = atob(base64Str)
      const keyBytes = privateKey.split('').map(ch => ch.charCodeAt(0))
      let result = ''
      for (let i = 0; i < binaryStr.length; i++) {
        result += String.fromCharCode(binaryStr.charCodeAt(i) ^ keyBytes[i % keyBytes.length])
      }
      return result
    },

    maskPhone(phone) {
      if (!phone || phone.length < 7) return phone
      return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4)
    },

    _isInCallFlow() {
      return ['talking', 'ringing', 'connecting'].includes(this.callState)
    },
    _isMobileGatewayLine() {
      const gwId = this.connectedGatewayIds[0]
      if (!gwId || !this.gatewayList.length) {
        return this.gatewayList.some((gw) => this._gatewayIsMobileLine(gw))
      }
      const gw = this.gatewayList.find((g) => String(g.id) === String(gwId))
      return this._gatewayIsMobileLine(gw)
    },
    _gatewayIsMobileLine(gateway) {
      if (!gateway) return false
      const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
      return MOBILE_GATEWAY_MATCH.some((key) => text.includes(key))
    },
    _shouldTreatMobileLineAsEnded() {
      if (!this._isMobileGatewayLine()) return false
      if (!['talking', 'ringing'].includes(this.callState)) return false
      if (!this._ipcCallSessionActive()) return true
      if (this.callState === 'ringing') return false
      const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
      const asrRecent = this._lastAsrAt && Date.now() - this._lastAsrAt < 3000
      const timerStale = this._lastCallTimerTickAt && Date.now() - this._lastCallTimerTickAt > 3500
      if (timerStale && !asrRecent) return true
      if (!sipAlive && !asrRecent) return true
      return false
    },
    _syncMobileRemoteHangupIfNeeded(reason) {
      if (!this._shouldTreatMobileLineAsEnded()) return false
      if (this._completingHangup || this.isHangingUp) return false
      console.warn('[一键外呼] 手机线路远端已挂机，同步:', reason)
      this._forceReleaseIpccCallState()
      this._completeCallHangup('ipcc')
      return true
    },
    _handleAgentPostCallStatus(statusCode) {
      if (statusCode !== AgentStatusEnum.FILL_FORM) return
      if (!this._isInCallFlow()) return
      if (this._completingHangup) return
      this._forceReleaseIpccCallState()
      this._completeCallHangup('ipcc')
    },
    _scheduleMobileRemoteHangupSync(reason, delayMs = 600) {
      if (!this._isMobileGatewayLine()) return
      if (!['talking', 'ringing'].includes(this.callState)) return
      this._scheduleRemoteHangupCheck(reason, delayMs)
    },
    _startOutboundBridgeProtect(reason = '') {
      const now = Date.now()
      this._bridgeStartedAt = now
      this._suppressIpccHangupUntil = now + OUTBOUND_BRIDGE_PROTECT_MS
      if (this.ccPhoneBar && typeof this.ccPhoneBar.setCallConnected === 'function') {
        this.ccPhoneBar.setCallConnected(true)
      }
      if (reason) console.debug('[一键外呼] 桥接保护启动', reason)
    },

    _isRingingBridgeProtected() {
      if (!['ringing', 'connecting'].includes(this.callState) || !this.currentCallUuid) return false
      if (this._ipccRecovering) return true
      if (!this.ccPhoneBar || !this.ccSocketConnected) return false
      if (this._suppressIpccHangupUntil && Date.now() < this._suppressIpccHangupUntil) return true
      if (this._bridgeStartedAt && Date.now() - this._bridgeStartedAt < OUTBOUND_BRIDGE_PROTECT_MS) return true
      if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true
      return this._ipcCallAlive()
    },

    _ipcCallAlive() {
      if (this._ipcCallSessionActive()) return true
      if (this._ipccRecovering && this._lastAsrAt && Date.now() - this._lastAsrAt < 5000) return true
      // IPCC 恢复期间 JsSIP 仍在通，视为通话未结束
      if (this._ipccRecovering) {
        const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
        if (sipAlive) return true
      }
      return false
    },



    _ipcCallSessionActive() {
      if (!this.ccPhoneBar) return false;
      if (this.ccPhoneBar.getCallConnected && this.ccPhoneBar.getCallConnected()) return true;
      if (this.ccPhoneBar.getActiveCallUuid && this.ccPhoneBar.getActiveCallUuid()) return true;
      return false;
    },

    _isRemoteCallAlive() {
      if (!this.ccPhoneBar) return false
      // IPCC 断开恢复中：有会话快照或 JsSIP 仍在，禁止据此自动挂断
      if (this._ipccRecovering) {
        if (this._ipcCallSessionActive()) return true
        const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
        if (sipAlive) return true
        return false
      }
      if (!this.ccSocketConnected) return false
      if (this._isMobileGatewayLine()) {
        if (this._shouldTreatMobileLineAsEnded()) return false
        return this._ipcCallSessionActive()
      }
      return this._ipcCallSessionActive() && this.callState === 'talking'
    },

    _forceReleaseIpccCallState() {
      if (!this.ccPhoneBar) return
      this.ccPhoneBar.clearActiveCallUuid()
      if (typeof this.ccPhoneBar.setCallConnected === 'function') {
        this.ccPhoneBar.setCallConnected(false)
      }
      if (typeof this.ccPhoneBar.resetOutboundLock === 'function') {
        this.ccPhoneBar.resetOutboundLock()
      }
    },

    async _gracefulDisconnectIpcc(waitMs) {
      if (!this.ccPhoneBar) return
      const disconnectWait = waitMs || IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000
      try {
        if (typeof this.ccPhoneBar.forceDisconnect === 'function') {
          this.ccPhoneBar.forceDisconnect()
          await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
        } else if (typeof this.ccPhoneBar.disconnectAndWait === 'function') {
          await this.ccPhoneBar.disconnectAndWait(disconnectWait)
        } else {
          this.ccPhoneBar.disconnect()
          await new Promise((r) => setTimeout(r, Math.min(disconnectWait, 1200)))
        }
      } catch (e) {}
      await new Promise((r) => setTimeout(r, IPCC_GATEWAY_RELEASE_DELAY_MS))
    },

    _syncCallUuidToSdk(uuid) {
      if (uuid && this.ccPhoneBar && typeof this.ccPhoneBar.setActiveCallUuid === 'function') {
        this.ccPhoneBar.setActiveCallUuid(uuid)
      }
    },

    _refreshInCallStatusIndicator() {
      // 通话中 IPCC 闪断重连：界面保持正常通话态，异常只打日志，避免用户感知断开
      if (this.callState === 'talking') {
        this.stateText = this.isOnHold ? '已暂停' : (this._isMobileGatewayLine() ? '通话中（音频在手机）' : '通话中')
        this.statusClass = this.isOnHold ? 'status-hold' : 'status-talking'
      } else if (this.callState === 'ringing') {
        this.stateText = this.callDirection === 'outbound' ? '客户振铃中...' : '振铃中...'
        this.statusClass = 'status-ringing'
      }
    },

    _persistActiveCallSnapshot() {
      if (!this.currentCallUuid || this.callState === 'idle' || this.callState === 'ended') return
      try {
        sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
          uuid: this.currentCallUuid,
          callState: this.callState,
          callDirection: this.callDirection,
          savedAt: Date.now()
        }))
      } catch (e) {}
    },

    _restoreActiveCallSnapshot() {
      try {
        const raw = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
        if (!raw) return
        const snap = JSON.parse(raw)
        if (!snap?.uuid) return
        if (!this.currentCallUuid) this.currentCallUuid = snap.uuid
        this._syncCallUuidToSdk(this.currentCallUuid)
      } catch (e) {}
    },

    _clearActiveCallSnapshot() {
      try { sessionStorage.removeItem(ACTIVE_CALL_STORAGE_KEY) } catch (e) {}
    },

    _getIpccConnectTimeout(isReconnect = false) {
      return isReconnect
        ? (IPCC_CONFIG.RECONNECT_CONNECT_TIMEOUT || 3000)
        : (IPCC_CONFIG.CONNECT_TIMEOUT || TIMEOUT_IPCC_CONNECT)
    },

    _clearCcConnectingState() {
      if (this._ccConnectingTimeoutId) {
        clearTimeout(this._ccConnectingTimeoutId)
        this._ccConnectingTimeoutId = null
      }
      this.ccConnectingResolve = null
      this.ccConnectingReject = null
      this.ccConnectingPromise = null
      this._ipccReconnecting = false
      this._ccConnectStartedAt = 0
    },

    _finishCcConnecting(success, err) {
      const resolve = this.ccConnectingResolve
      const reject = this.ccConnectingReject
      if (this._ccConnectingTimeoutId) {
        clearTimeout(this._ccConnectingTimeoutId)
        this._ccConnectingTimeoutId = null
      }
      this.ccConnectingResolve = null
      this.ccConnectingReject = null
      this.ccConnectingPromise = null
      this._ipccReconnecting = false
      this._ccConnectStartedAt = 0
      if (success && resolve) resolve()
      else if (!success && reject) reject(err || new Error('IPCC连接失败'))
    },

    async _reconnectExistingIpcc() {
      if (this._ipccReconnecting && this.ccConnectingPromise) {
        return this.ccConnectingPromise
      }
      this._ipccReconnecting = true
      this._ccConnectStartedAt = Date.now()
      this.ccConnectingPromise = new Promise((resolve, reject) => {
        this.ccConnectingResolve = resolve
        this.ccConnectingReject = reject
        try {
          if (this.ccPhoneBar.resetKickedState) this.ccPhoneBar.resetKickedState()
          if (typeof this.ccPhoneBar.reconnect === 'function') {
            this.ccPhoneBar.reconnect()
          } else {
            this.ccPhoneBar.connect()
          }
          this._ccConnectingTimeoutId = setTimeout(() => {
            if (!this.ccSocketConnected) {
              this._finishCcConnecting(false, new Error('IPCC重连超时'))
            }
          }, this._getIpccConnectTimeout(true))
        } catch (err) {
          this._finishCcConnecting(false, err)
        }
      })
      return this.ccConnectingPromise
    },

    _scheduleIpccReconnect(duringCall, extraDelayMs = 0) {
      if (!duringCall && this.callState === 'idle' &&
        this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) {
        return
      }
      if (this._ipccReconnectTimer) {
        if (duringCall) return
        clearTimeout(this._ipccReconnectTimer)
        this._ipccReconnectTimer = null
      }
      if (this._ipccReconnecting) return
      if (this.ccConnectingPromise) {
        const elapsed = this._ccConnectStartedAt ? Date.now() - this._ccConnectStartedAt : 0
        const reconnectTimeout = this._getIpccConnectTimeout(true)
        if (elapsed < reconnectTimeout + 500) return
        console.warn('[一键外呼] IPCC连接Promise超时未结束，强制清理后重试')
        this._finishCcConnecting(false, new Error('连接状态超时'))
      }
      const delay = (duringCall
        ? (IPCC_CONFIG.RECONNECT_DELAY_IN_CALL_MS || 150)
        : (IPCC_CONFIG.RECONNECT_DELAY_MS || 100)) + (extraDelayMs || 0)
      this._ipccReconnectTimer = setTimeout(async () => {
        this._ipccReconnectTimer = null
        if (this.ccSocketConnected && !this._ipccRecovering && !this._pendingIpccHangup) return
        try {
          if (this.ccPhoneBar) {
            await this._reconnectExistingIpcc()
          }
          if (!this._pendingIpccHangup) {
            this._ipccRecovering = false
            this._refreshInCallStatusIndicator()
          }
        } catch (err) {
          if (duringCall && this._isInCallFlow()) {
            if (this._ipccReconnectFailCount == null) this._ipccReconnectFailCount = 0
            this._ipccReconnectFailCount += 1
            const backoff = this._ipccReconnectFailCount <= 8
              ? 0
              : Math.min(5000, 350 * (this._ipccReconnectFailCount - 8))
            console.debug('[一键外呼] 通话中IPCC重连失败，继续重试:', err.message || err)
            this._scheduleIpccReconnect(true, backoff)
          }
        }
      }, delay)
    },

    _clearIpccReconnectState() {
      if (this._ipccReconnectTimer) {
        clearTimeout(this._ipccReconnectTimer)
        this._ipccReconnectTimer = null
      }
      this._pendingIpccHangup = false
      this._ipccReconnectFailCount = 0
      this._clearCcConnectingState()
      this._clearActiveCallSnapshot()
    },

    _cancelRemoteHangupCheck() {
      if (this._remoteHangupCheckTimer) {
        clearTimeout(this._remoteHangupCheckTimer)
        this._remoteHangupCheckTimer = null
      }
    },

    _scheduleRemoteHangupCheck(reason, delayMs = 8000) {
      if (!['talking', 'ringing'].includes(this.callState)) return
      if (this._remoteHangupCheckTimer) return
      const delay = this.callState === 'talking' ? Math.min(delayMs, 1500) : delayMs
      this._remoteHangupCheckTimer = setTimeout(() => {
        this._remoteHangupCheckTimer = null
        if (this.callState === 'ended' || this.callState === 'idle') return
        if (this._isRingingBridgeProtected()) {
          this._scheduleRemoteHangupCheck(reason, 2000)
          return
        }
        if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded(reason)) return
        if (this._ipccRecovering && this._ipcCallAlive()) return
        const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
        if (sipAlive) return
        if (this._isRemoteCallAlive()) return
        console.warn('[一键外呼] 远端挂机确认:', reason)
        this._completeCallHangup('sip')
      }, delay)
    },

    _completeCallHangup(source) {
      if (this.callState === 'ended' || this.callState === 'idle') return
      if (this._completingHangup) return
      this._completingHangup = true
      const uuid = this.currentCallUuid
      try {
        this._cancelRemoteHangupCheck()
        this.stopAudioHealthCheck()

        const needIpccHangup = source !== 'ipcc'
        if (needIpccHangup && this.ccPhoneBar && this.ccSocketConnected) {
          try { this.ccPhoneBar.hangup() } catch (e) {}
        } else if (source === 'ipcc') {
          this._forceReleaseIpccCallState()
        } else if (needIpccHangup && this.ccPhoneBar && !this.ccSocketConnected && this._isInCallFlow()) {
          this._pendingIpccHangup = true
          this._persistActiveCallSnapshot()
          this._scheduleIpccReconnect(true, 0)
        }

        if (this.phone) {
          if (typeof this.phone.markIntentionalHangup === 'function') {
            this.phone.markIntentionalHangup()
          }
          try { this.phone.Terminate() } catch (e) {}
          try { this.phone.releaseLocalStream() } catch (e) {}
        }

        this.callState = 'ended'
        this.stateText = source === 'sip' ? '对方已挂机' : '已挂断'
        this.statusClass = 'status-ended'
        this.isOnHold = false
        this.stopTimer()
        this.callTimer = ''
        this._bridgeStartedAt = 0
        this._suppressIpccHangupUntil = 0
        if (!this._pendingIpccHangup) {
          this._clearActiveCallSnapshot()
        }
        this.$emit('sip-call-ended', this.effectiveRecordId)
        this.startSyncAndClose(uuid)
      } finally {
        this._completingHangup = false
        this.clearHangupLock()
      }
    },

    _isSyncNotFoundError(msg) {
      return /不存在|not found|找不到|未找到/i.test(msg || '')
    },

    _isDuplicateSyncError(msg) {
      return /duplicate|PRIMARY|已存在|重复/i.test(msg || '')
    },

    async loadGatewayOptions() {
      this.gatewayListLoading = true
      try {
        const extRes = await myCallUser()
        if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
          throw new Error('未查询到分机号信息，请先在外呼配置中绑定分机')
        }
        const { extNum, extPass, gatewayIds: myGateway } = extRes.data
        this._pendingExtInfo = { extNum, extPass, myGateway }

        let rows = []
        try {
          const gwRes = await remoteGatewayList({
            pageNum: 1,
            pageSize: 50,
            isAICall: false,
            params: { purposes: [1, 3] }
          })
          rows = gwRes.rows || []
        } catch (e) {
          console.warn('[一键外呼] remoteGatewayList 失败，尝试工具条网关列表', e && e.message)
        }

        if (!rows.length) {
          const basicRes = await getToolbarBasicParam({ extNum, myGateway })
          if (basicRes.code !== 0) throw new Error(basicRes.message || '获取线路失败')
          rows = (basicRes.data && basicRes.data.gatewayList) || []
        }

        this.gatewayList = rows
        if (!this.gatewayList.length) {
          throw new Error('暂无可用外呼线路')
        }

        // 预选：优先绑定线路中的第一条，否则选列表第一条
        const boundIds = myGateway
          ? String(myGateway).split(',').map(s => s.trim()).filter(Boolean)
          : []
        const preferred = boundIds.find(id => this.gatewayList.some(g => String(g.id) === String(id)))
        this.selectedGatewayId = preferred || this.gatewayList[0].id
      } finally {
        this.gatewayListLoading = false
      }
    },

    waitForGatewayConfirm() {
      return new Promise((resolve, reject) => {
        this._gatewaySelectResolve = resolve
        this._gatewaySelectReject = reject
      })
    },

    selectGateway(gatewayId) {
      if (this.gatewayConfirming || !this.awaitingGatewaySelect) return
      if (gatewayId == null || gatewayId === '') return
      this.selectedGatewayId = gatewayId
    },

    confirmGatewaySelect() {
      if (this.gatewayConfirming) return
      if (!this.selectedGatewayId) {
        this.$message.warning('请先选择外呼线路')
        return
      }
      const exists = this.gatewayList.some(g => String(g.id) === String(this.selectedGatewayId))
      if (!exists) {
        this.$message.error('所选线路无效，请重新选择')
        return
      }
      this.gatewayConfirming = true
      if (this._gatewaySelectResolve) {
        const resolve = this._gatewaySelectResolve
        this._gatewaySelectResolve = null
        this._gatewaySelectReject = null
        resolve()
      }
    },

    getGatewayBadge(gateway) {
      if (!gateway) return { text: '线路', theme: 'default' }
      const text = `${gateway.gwName || ''}${gateway.gwDesc || ''}${gateway.caller || ''}`
      if (MOBILE_GATEWAY_MATCH.some(k => text.includes(k))) {
        return { text: '手机线路', theme: 'mobile' }
      }
      return { text: '固话线路', theme: 'landline' }
    },

    async connectIPCC() {
      try {
        if (!this.selectedGatewayId) {
          throw new Error('请先选择外呼线路')
        }
        let extNum, extPass, myGateway
        if (this._pendingExtInfo && this._pendingExtInfo.extNum) {
          extNum = this._pendingExtInfo.extNum
          extPass = this._pendingExtInfo.extPass
          myGateway = this.selectedGatewayId
        } else {
          const extRes = await myCallUser()
          if (extRes.code !== 200 || !extRes.data || !extRes.data.extNum) {
            throw new Error('未查询到分机号信息')
          }
          extNum = extRes.data.extNum
          extPass = extRes.data.extPass
          myGateway = this.selectedGatewayId
        }
        this.connectedGatewayIds = [String(this.selectedGatewayId)]

        this.setupProfile(extNum, extPass)

        const basicRes = await getToolbarBasicParam({ extNum, myGateway: String(myGateway) })
        if (basicRes.code !== 0) throw new Error(basicRes.message || '获取配置失败')
        const configData = basicRes.data
        if (!configData.loginToken) throw new Error('登录令牌无效')
        this.gatewayList = configData.gatewayList || []

        const callConfig = {
          useDefaultUi: false,
          loginToken: configData.loginToken,
          ipccServer: IPCC_CONFIG.SERVER_PROD,
          gatewayList: configData.gatewayList,
          gatewayEncrypted: false,
          extPassword: configData.encryptPsw,
          extnum: extNum,
          opnum: configData.opNum || configData.userName,
          enableWss: true,
          enableHeartBeat: true,
          heartBeatIntervalSecs: 16
        }

        this.ccPhoneBar = new ccPhoneBarSocket()
        this.ccPhoneBar.initConfig(callConfig)
        this.bindCCEvents()
        this.ccPhoneBar.connect()

        await new Promise((resolve, reject) => {
          const timeoutId = setTimeout(() => reject(new Error('IPCC连接超时')), TIMEOUT_IPCC_CONNECT)
          this.ccConnectingResolve = () => { clearTimeout(timeoutId); resolve() }
          this.ccConnectingReject = (err) => { clearTimeout(timeoutId); reject(err) }
        })
      } catch (err) {
        this.setStepStatus('ipcc', 'error', err.message)
        throw err
      }
    },

    setupProfile(extNum, extPass) {
      this.profileManager = new ProfileManager()
      let profile = this.profileManager.getProfile()
      if (!profile.users) profile.users = {}

      let existingUserId = null
      for (const [id, user] of Object.entries(profile.users)) {
        if (user.user === String(extNum).trim()) {
          existingUserId = id
          break
        }
      }

      const userData = {
        note: String(extNum).trim(),
        user: String(extNum).trim(),
        domain: JS_SIP_CONFIG.DOMAIN,
        password: String(extPass).trim(),
        display_name: String(extNum).trim(),
        server: JS_SIP_CONFIG.SERVER,
        transport: JS_SIP_CONFIG.TRANSPORT,
        auto_answer: true
      }

      if (existingUserId) {
        this.profileManager.updateUser(existingUserId, userData)
      } else {
        this.profileManager.addUser(userData)
      }

      const updatedProfile = this.profileManager.getProfile()
      for (const [id, user] of Object.entries(updatedProfile.users || {})) {
        if (user.user === String(extNum).trim()) {
          this.profileManager.switchUser(id)
          break
        }
      }
      this.currentUserDisplay = String(extNum).trim()
    },

    on(event, callback) {
      if (!this.ccPhoneBar) return
      this.ccPhoneBar.on(event, callback)
      this.ccEventRefs.push({ event, callback })
    },

    unbindCCEvents() {
      if (this.ccPhoneBar) {
        this.ccEventRefs.forEach(({ event, callback }) => {
          try { this.ccPhoneBar.off(event, callback) } catch (e) {}
        })
      }
      this.ccEventRefs = []
    },

    bindCCEvents() {
      if (!this.ccPhoneBar) return

      this.on(EventList.WS_CONNECTED, () => {
        this.ipccConnected = true
        this.ccSocketConnected = true
        this._ipccConnectedAt = Date.now()
        if (this._isInCallFlow() || this._ipccRecovering || this._pendingIpccHangup) {
          this._restoreActiveCallSnapshot()
          if (this.ccPhoneBar && !this._pendingIpccHangup) {
            this.ccPhoneBar.restoreCallSession()
          }
        }
        if (this._pendingIpccHangup) {
          this._pendingIpccHangup = false
          this._ipccRecovering = false
          try { this.ccPhoneBar.hangup() } catch (e) {}
          this._forceReleaseIpccCallState()
          this._clearActiveCallSnapshot()
        } else {
          this._ipccRecovering = false
          this._ipccReconnectFailCount = 0
          this._refreshInCallStatusIndicator()
        }
        if (this.ccConnectingPromise) {
          this._finishCcConnecting(true)
        } else if (this.ccConnectingResolve) {
          this.ccConnectingResolve()
          this.ccConnectingResolve = null
        }
      })

      this.on(EventList.WS_DISCONNECTED, () => {
        this.ipccConnected = false
        this.ccSocketConnected = false
        const inCall = this._isInCallFlow()
        if (inCall) {
          console.warn('[一键外呼] IPCC断开(通话中)，后台重连恢复通话')
          this._persistActiveCallSnapshot()
          this._cancelRemoteHangupCheck()
          this._ipccRecovering = true
          this._refreshInCallStatusIndicator()
          this._scheduleIpccReconnect(true)
          return
        }
        this.isCallingReady = false
        this.isReady = false
        if (this.ccConnectingReject) {
          this.ccConnectingReject(new Error('IPCC WebSocket连接断开'))
          this.ccConnectingReject = null
        }
      })

      this.on(EventList.ASR_RESULT_GENERATE, () => {
        this._lastAsrAt = Date.now()
        this._cancelRemoteHangupCheck()
        if (this.callState === 'ringing' && this._isInCallFlow()) {
          this.callState = 'talking'
          this.stateText = '通话中'
          this.statusClass = 'status-talking'
          this._callEstablishedAt = Date.now()
          if (!this.callStartTime) {
            this.callStartTime = Date.now()
            this.startTimer()
          }
        }
        if (this._ipccRecovering && this.callState === 'talking') {
          this._ipccRecovering = false
          this._refreshInCallStatusIndicator()
        }
      })

      this.on(EventList.STATUS_CHANGED, (msg) => {
        if (msg?.object) {
          const statusCode = msg.object.status
          this._handleAgentPostCallStatus(statusCode)
          const readyStatuses = [
            AgentStatusEnum.BUSY,
            AgentStatusEnum.BUSY_REST,
            AgentStatusEnum.BUSY_MEETING,
            AgentStatusEnum.BUSY_TRAINING,
            AgentStatusEnum.FILL_FORM,
            AgentStatusEnum.FREE
          ]
          this.isCallingReady = readyStatuses.includes(statusCode)
        }
      })

      this.on(EventList.OUTBOUND_START, (msg) => {
        this.callDirection = 'outbound'
        this._startOutboundBridgeProtect('OUTBOUND_START')
        this.callState = 'connecting'
        this.stateText = '正在呼叫...'
        this.statusClass = 'status-connecting'
        this.setStepStatus('call', 'success')
        this.isReady = true
        if (msg?.object?.uuid) {
          this.currentCallUuid = msg.object.uuid
          this._syncCallUuidToSdk(this.currentCallUuid)
        }
        if (this.callStartResolve) this.callStartResolve()
      })

      this.on(EventList.CALLEE_RINGING, () => {
        this.callState = 'ringing'
        this.stateText = '振铃中...'
        this.statusClass = 'status-ringing'
        if (this.callDirection === 'outbound') {
          this._startOutboundBridgeProtect('CALLEE_RINGING')
        }
        this.startAudioHealthCheck()
      })

      const applyTalkingState = (msg) => {
        if (msg?.object?.uuid) {
          this.currentCallUuid = msg.object.uuid
          this._syncCallUuidToSdk(this.currentCallUuid)
        }
        this.callState = 'talking'
        this._callEstablishedAt = Date.now()
        this._suppressIpccHangupUntil = Date.now() + 8000
        this.callStartTime = Date.now()
        this.startTimer()
        this._refreshInCallStatusIndicator()
        if (this.phone) {
          this.isSpeakerMuted = false
          this.isMicMuted = false
          this.phone.ensureLocalStream()
            .then(() => {
              this.syncVolumeToPhone()
              this.phone.resumeRemoteAudio()
            })
            .catch(() => console.warn('[一键外呼] 通话中麦克风不可用'))
        }
        this.startAudioHealthCheck()
      }

      this.on(EventList.CALLER_ANSWERED, (msg) => {
        if (msg?.object?.uuid) {
          this.currentCallUuid = msg.object.uuid
          this._syncCallUuidToSdk(this.currentCallUuid)
        }
        if (this.callDirection === 'outbound') {
          this.callState = 'ringing'
          this.stateText = '客户振铃中...'
          this.statusClass = 'status-ringing'
          this._startOutboundBridgeProtect('CALLER_ANSWERED')
          this.startAudioHealthCheck()
          return
        }
        applyTalkingState(msg)
      })

      this.on(EventList.CALLEE_ANSWERED, (msg) => {
        applyTalkingState(msg)
      })

      const handleIpccHangup = (msg) => {
        if (this._completingHangup) return
        this._suppressIpccHangupUntil = 0
        this._bridgeStartedAt = 0
        if (msg?.object?.uuid) this.currentCallUuid = msg.object.uuid
        this._completeCallHangup('ipcc')
      }

      this.on(EventList.CALLER_HANGUP, handleIpccHangup)
      this.on(EventList.CALLEE_HANGUP, handleIpccHangup)

      this.on(EventList.OUTBOUND_FINISHED, () => {
        if (this.callState === 'idle' || this.callState === 'ended') {
          console.debug('[一键外呼] 611(外呼任务结束)，空闲态忽略')
          return
        }
        if (this._isMobileGatewayLine() && this.callState === 'talking') {
          console.debug('[一键外呼] 手机线路 611，复核是否已挂机')
          this._scheduleMobileRemoteHangupSync('611_talking', 500)
          return
        }
        console.debug('[一键外呼] 611外呼任务结束，不参与挂机，当前态=', this.callState)
      })

      this.on(EventList.ASR_PROCESS_END_CUSTOMER, () => {
        if (this._isMobileGatewayLine() && this.callState === 'talking') {
          console.debug('[一键外呼] 手机线路 ASR 结束，复核是否已挂机')
          this._scheduleMobileRemoteHangupSync('asr_customer_end', 800)
        }
      })

      this.on(EventList.CALLER_RESPOND_TIMEOUT, () => {
        if (this._isRingingBridgeProtected()) {
          console.debug('[一键外呼] 分机超时在桥接保护期内，忽略')
          return
        }
        if (this.isHangingUp) return
        // IPCC 闪断恢复中或 JsSIP 仍在通：不自动挂断
        if (this._ipccRecovering || !this.ccSocketConnected) {
          console.warn('[一键外呼] 分机响应超时(IPCC恢复中)，保持通话并重连')
          this._persistActiveCallSnapshot()
          this._scheduleIpccReconnect(true)
          return
        }
        const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
        if (sipAlive && this._isInCallFlow()) {
          console.warn('[一键外呼] 分机响应超时但JsSIP仍在通，忽略自动挂断')
          return
        }
        this._completeCallHangup('ipcc')
      })

      this.on(EventList.EXTENSION_OFF_LINE, () => {
        if (this.callState === 'idle' || this.callState === 'ended') return
        // 关键：IPCC 断开/重连时分机可能短暂离线，绝不能打断 JsSIP 通话
        if (this._ipccRecovering || !this.ccSocketConnected) {
          console.warn('[一键外呼] 分机离线(IPCC恢复中)，保持JsSIP并后台重连')
          this._persistActiveCallSnapshot()
          this._ipccRecovering = true
          this._scheduleIpccReconnect(true)
          return
        }
        const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
        if (sipAlive && this._isInCallFlow()) {
          console.warn('[一键外呼] 分机离线但JsSIP仍在通，后台重连IPCC，不自动挂断')
          this._ipccRecovering = true
          this._persistActiveCallSnapshot()
          this._refreshInCallStatusIndicator()
          this._scheduleIpccReconnect(true)
          return
        }
        if (this._isMobileGatewayLine() && ['talking', 'ringing'].includes(this.callState)) {
          console.warn('[一键外呼] 手机线路分机离线，同步挂机')
          this._forceReleaseIpccCallState()
          this._completeCallHangup('ipcc')
          return
        }
        console.warn('[一键外呼] 分机离线')
        this._completeCallHangup('ipcc')
      })

      this.on(EventList.CUSTOMER_CHANNEL_HOLD, () => {
        this.isOnHold = true
        this.stateText = '已暂停'
        this.statusClass = 'status-hold'
      })

      this.on(EventList.CUSTOMER_CHANNEL_UNHOLD, () => {
        this.isOnHold = false
        this.stateText = '通话中'
        this.statusClass = 'status-talking'
      })

      this.on(EventList.CALLER_BUSY, () => {
        this.isHangingUp = true
        this.callState = 'ended'
        this.stateText = '线路忙'
        this.statusClass = 'status-failed'
        this.$message.warning('上一通电话未挂机，请稍后再试')
        this.stopTimer()
        this.startSyncAndClose(this.currentCallUuid)
      })
    },

    bindSipEvents() {
      this.unbindSipEvents()
      if (!this.phone) return
      this.sipEventHandlers = {
        onAnswered: () => {
          if (this.callDirection === 'outbound' && this.callState !== 'talking') return
          if (this.phone) {
            this.syncVolumeToPhone()
            this.phone.resumeRemoteAudio()
          }
        },
        onSessionClosed: (payload) => {
          if (this.isHangingUp || this._completingHangup || payload?.intentional) return
          if (this._ipccRecovering && this._ipcCallAlive()) {
            console.debug('[一键外呼] SIP结束但IPCC恢复中，继续等待')
            return
          }
          if (this.callState === 'talking') {
            if (this._isMobileGatewayLine()) {
              if (this._syncMobileRemoteHangupIfNeeded('sip_session_closed')) return
              this._scheduleMobileRemoteHangupSync('mobile_sip_end', 400)
              return
            }
            if (this._isRemoteCallAlive()) {
              console.debug('[一键外呼] SIP结束但IPCC仍活跃，继续')
              return
            }
            this._completeCallHangup('sip')
            return
          }
          if (this.callState === 'ringing' || this.callState === 'connecting') {
            if (this._isRingingBridgeProtected()) {
              this._scheduleRemoteHangupCheck('sip_ringing_bridge', 2500)
              return
            }
            if (this._isRemoteCallAlive()) return
            this._completeCallHangup('sip')
          }
        },
        onIceMediaIssue: ({ state, timedOut } = {}) => {
          if (this.callState !== 'talking') return
          if (this._ipccRecovering && this._ipcCallAlive()) return
          const sipAlive = this.phone?.hasActiveCall?.() || this.phone?.hasSipSession?.()
          if (sipAlive) {
            this.phone.resumeRemoteAudio()
            return
          }
          if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('ice_media')) return
          if (this._isRemoteCallAlive()) return
          if (timedOut || state === 'failed' || state === 'disconnected') {
            this._completeCallHangup('sip')
          }
        }
      }
      this.phone.On('OnAnswered', this.sipEventHandlers.onAnswered)
      this.phone.On('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
      this.phone.On('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
    },

    unbindSipEvents() {
      if (this.phone && this.sipEventHandlers) {
        try {
          this.phone.Off('OnAnswered', this.sipEventHandlers.onAnswered)
          this.phone.Off('OnSessionClosed', this.sipEventHandlers.onSessionClosed)
          this.phone.Off('OnIceMediaIssue', this.sipEventHandlers.onIceMediaIssue)
        } catch (e) {}
      }
      this.sipEventHandlers = null
    },

    startAudioHealthCheck() {
      this.stopAudioHealthCheck()
      const intervalMs = this._isMobileGatewayLine() ? 1000 : 2000
      this._audioHealthTimer = setInterval(() => {
        if (!['talking', 'ringing', 'connecting'].includes(this.callState) || !this.phone) {
          this.stopAudioHealthCheck()
          return
        }
        if (this.isHangingUp || this._completingHangup) return
        if (this._ipccRecovering && this._ipcCallAlive()) return
        if (this._isMobileGatewayLine() && this._syncMobileRemoteHangupIfNeeded('health_poll')) return
        const sipAlive = this.phone.hasActiveCall?.() || this.phone.hasSipSession?.()
        if (!sipAlive) {
          if (this._isMobileGatewayLine()) {
            if (this._syncMobileRemoteHangupIfNeeded('health_sip_gone')) return
          } else if (this._isRemoteCallAlive()) return
          if (this.callState === 'talking') {
            this._completeCallHangup('sip')
            return
          }
          if (this._isRingingBridgeProtected()) return
          this._completeCallHangup('sip')
          return
        }
        if (this.callState === 'talking') {
          this.phone.resumeRemoteAudio()
        }
      }, intervalMs)
    },

    stopAudioHealthCheck() {
      if (this._audioHealthTimer) {
        clearInterval(this._audioHealthTimer)
        this._audioHealthTimer = null
      }
    },



    rejectCCConnect(err) {
      if (this.ccConnectingReject) {
        this.ccConnectingReject(err)
        this.ccConnectingReject = null
        this.ccConnectingResolve = null
      }
    },

    async startSIP() {
      if (this.phone) {
        this.unbindSipEvents()
        try { this.phone.destroy() } catch (e) {}
        this.phone = null
      }

      const userProfile = this.profileManager.getCurrentUserProfile()
      if (!userProfile?.user || !userProfile?.domain || !userProfile?.password) {
        throw new Error('SIP账号配置不完整')
      }

      // 请求麦克风权限，确保音频可用
      try {
        await checkMicrophonePermission()
      } catch (err) {
        throw new Error('麦克风权限未授予，无法进行通话')
      }

      const settings = this.profileManager.getSettings()
      this.phone = new WebPhone(userProfile, settings)

      return new Promise((resolve, reject) => {
        this.sipReject = reject
        const timeoutId = setTimeout(() => {
          this.setStepStatus('jssip', 'error', 'JsSIP注册超时')
          reject(new Error('JsSIP注册超时'))
        }, TIMEOUT_SIP_REGISTER)

        this.phone.On('OnRegister', (event) => {
          if (event.registered) {
            this.sipRegistered = true
            this.sipReject = null
            clearTimeout(timeoutId)
            this.syncVolumeToPhone()
            if (this.ccPhoneBar && this.ccSocketConnected) {
              this.ccPhoneBar.setStatus(AgentStatusEnum.BUSY)
            }
            resolve()
          }
        })

        this.phone.On('OnReconnectStatus', ({ failed }) => {
          if (failed) {
            clearTimeout(timeoutId)
            this.sipReject = null
            reject(new Error('JsSIP重连超时'))
          }
        })

        this.bindSipEvents()
        this.phone.Start(settings.reconnect)
      })
    },

    syncVolumeToPhone() {
      if (!this.phone) return
      try {
        this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
        this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
      } catch (err) {
        console.warn('[一键外呼] 同步音量失败:', err)
      }
    },

    async waitForCallingReady(maxWaitMs = TIMEOUT_AGENT_READY) {
      if (this.isCallingReady) return
      const startTime = Date.now()
      return new Promise((resolve, reject) => {
        const check = () => {
          if (this.initCancelled) return reject(new Error('初始化已取消'))
          if (this.isCallingReady) return resolve()
          if (Date.now() - startTime >= maxWaitMs) {
            this.setStepStatus('call', 'error', '坐席就绪等待超时')
            reject(new Error('坐席就绪等待超时'))
          } else {
            setTimeout(check, POLL_INTERVAL_AGENT)
          }
        }
        check()
      })
    },

    async autoCall() {
      if (!this.ccPhoneBar || !this.ccSocketConnected) {
        throw new Error('IPCC未连接')
      }
      if (!this.isCallingReady) {
        throw new Error('坐席未就绪，请稍候')
      }
      if (!this.decryptedPhone || this.decryptedPhone.length < 3) {
        throw new Error('号码无效')
      }

      this._forceReleaseIpccCallState()
      if (this.phone) {
        try {
          await this.phone.ensureLocalStream()
          this.isSpeakerMuted = false
          this.isMicMuted = false
          this.syncVolumeToPhone()
          this.phone.resumeRemoteAudio()
        } catch (e) {
          throw new Error('麦克风不可用，无法进行通话')
        }
      }

      try {
        const started = this.ccPhoneBar.call(this.decryptedPhone, this.effectiveDialType, VideoLevels.HD.levelId)
        if (started === false) {
          this._forceReleaseIpccCallState()
          throw new Error('外呼被拒绝，请稍后重试')
        }
      } catch (err) {
        this.setStepStatus('call', 'error', err.message)
        throw err
      }

      return new Promise((resolve) => {
        this.callStartResolve = () => {
          this.callStartResolve = null
          resolve()
        }
      })
    },

    handlePause() {
      if (!this.ccPhoneBar?.holdCall) return
      if (!this.ccSocketConnected || this._ipccRecovering) {
        console.warn('[一键外呼] IPCC未就绪，暂不可暂停')
        return
      }
      try { this.ccPhoneBar.holdCall() } catch (err) {
        console.warn('[一键外呼] 暂停失败:', err && err.message)
      }
    },

    handleResume() {
      if (!this.ccPhoneBar?.unHoldCall) return
      if (!this.ccSocketConnected || this._ipccRecovering) {
        console.warn('[一键外呼] IPCC未就绪，暂不可恢复')
        return
      }
      try { this.ccPhoneBar.unHoldCall() } catch (err) {
        console.warn('[一键外呼] 恢复失败:', err && err.message)
      }
    },

    toggleMicMute() {
      if (!this.phone) return
      this.isMicMuted = !this.isMicMuted
      this.phone.ToggleMicPhone()
      if (!this.isMicMuted) {
        this.micVolume = this.savedMicVolume || 80
      } else {
        this.savedMicVolume = this.micVolume
      }
      this.phone.SetMicPhone(this.isMicMuted, this.micVolume / 100)
    },

    toggleSpeakerMute() {
      if (!this.phone) return
      this.isSpeakerMuted = !this.isSpeakerMuted
      if (!this.isSpeakerMuted) {
        this.speakerVolume = this.savedSpeakerVolume || 80
      } else {
        this.savedSpeakerVolume = this.speakerVolume
      }
      this.phone.SetSpeaker(this.isSpeakerMuted, this.speakerVolume / 100)
    },

    onMicVolumeChange(e) {
      const vol = parseInt(e.target.value, 10)
      if (isNaN(vol)) return
      this.micVolume = vol
      if (this.isMicMuted && vol > 0) this.isMicMuted = false
      if (this.phone) this.phone.SetMicPhone(this.isMicMuted, vol / 100)
    },

    onSpeakerVolumeChange(e) {
      const vol = parseInt(e.target.value, 10)
      if (isNaN(vol)) return
      this.speakerVolume = vol
      if (this.isSpeakerMuted && vol > 0) this.isSpeakerMuted = false
      if (this.phone) this.phone.SetSpeaker(this.isSpeakerMuted, vol / 100)
    },

    handleHangup() {
      if (this.isHangingUp || this.callState === 'ended') return
      this.isHangingUp = true
      this.clearHangupLockTimer()
      this.hangupLockTimer = setTimeout(() => {
        this.isHangingUp = false
        this.hangupLockTimer = null
      }, HANGUP_LOCK_TIMEOUT)
      this._completeCallHangup('user')
    },

    clearHangupLock() {
      this.isHangingUp = false
      this.clearHangupLockTimer()
    },

    clearHangupLockTimer() {
      if (this.hangupLockTimer) {
        clearTimeout(this.hangupLockTimer)
        this.hangupLockTimer = null
      }
    },

    _recoverAfterPageLoad() {
      try {
        // 刷新后强制解锁，避免上次异常导致下次无法外呼
        this._resetCallAvailability()
        this._initActiveGen = 0
        const rawSnap = sessionStorage.getItem(ACTIVE_CALL_STORAGE_KEY)
        if (!rawSnap) {
          this._needReleaseStaleIpccSession = true
          return
        }
        const snap = JSON.parse(rawSnap)
        const age = Date.now() - (snap && snap.savedAt ? Number(snap.savedAt) : 0)
        const uuid = normalizeCallUuid(snap && snap.uuid)
        this._clearActiveCallSnapshot()
        this._needReleaseStaleIpccSession = true
        if (uuid && age >= 0 && age <= ACTIVE_CALL_SNAPSHOT_MAX_AGE_MS) {
          console.warn('[一键外呼] recover stale call, background sync:', uuid)
          scheduleGlobalCallSync(uuid)
        } else {
          console.warn('[一键外呼] clear expired call snapshot')
        }
      } catch (e) {
        this._clearActiveCallSnapshot()
        this._needReleaseStaleIpccSession = true
      }
    },

    _clearHangupCloseFailSafe() {
      if (this._hangupCloseFailSafe) {
        clearTimeout(this._hangupCloseFailSafe)
        this._hangupCloseFailSafe = null
      }
      if (this._failCloseTimer) {
        clearTimeout(this._failCloseTimer)
        this._failCloseTimer = null
      }
    },

    _clearInitWatchdog() {
      if (this._initWatchdogTimer) {
        clearTimeout(this._initWatchdogTimer)
        this._initWatchdogTimer = null
      }
    },

    _armInitWatchdog(gen) {
      this._clearInitWatchdog()
      this._initWatchdogTimer = setTimeout(() => {
        this._initWatchdogTimer = null
        if (!this.isInitializing || gen !== this._initGeneration) return
        // 选线路等待用户操作，不算卡住，顺延一轮
        if (this.awaitingGatewaySelect) {
          this._armInitWatchdog(gen)
          return
        }
        console.warn('[一键外呼] init timeout, force unlock')
        this.cancelInit()
        this._resetCallAvailability()
        this.globalErrorMsg = '外呼初始化超时，请重试'
        this.$message.error(this.globalErrorMsg)
        this.destroyAll(true, null, { forceReset: true }).catch(() => {})
        this._failCloseTimer = setTimeout(() => {
          this._failCloseTimer = null
          this.doClose()
        }, CLOSE_AFTER_FAIL_MS)
      }, INIT_WATCHDOG_MS)
    },

    _forceUnlockCallGuards({ soft = false } = {}) {
      this.clearHangupLock()
      this._completingHangup = false
      this.isHangingUp = false
      this.isSyncPending = false
      this.isAutoClosing = false
      this.syncPhase = 'idle'
      this.syncPhaseText = ''
      this.gatewayConfirming = false
      this._clearHangupCloseFailSafe()
      this.cancelAutoClose()
      if (this.callState === 'ended') this.callState = 'idle'
      if (!soft) {
        this.isInitializing = false
        this.isReady = false
        this.awaitingGatewaySelect = false
        this._initActiveGen = 0
        this._clearInitWatchdog()
      }
    },

    _normalizeCallUuid(uuid) {
      return normalizeCallUuid(uuid)
    },

    _canCloseDialog() {
      if (this.isAutoClosing) return false
      if (this.callState === 'ended') return false
      if (['talking', 'ringing', 'connecting'].includes(this.callState)) return false
      // 选线路阶段允许关闭，关窗后必须能再次外呼
      if (this.isInitializing && !this.awaitingGatewaySelect) return false
      return true
    },

    _closeBlockReason() {
      if (this.callState === 'ended' || this.isAutoClosing) return '通话收尾中，请稍候'
      if (['talking', 'ringing', 'connecting'].includes(this.callState)) return '通话中请先挂断'
      if (this.isInitializing && !this.awaitingGatewaySelect) return '正在连接外呼，请稍候'
      return '当前不可关闭'
    },

    beforeDialogClose(done) {
      if (!this._canCloseDialog()) {
        this.$message.warning(this._closeBlockReason())
        done(false)
        return
      }
      done()
    },

    /**
     * 挂断收尾：前端只展示统一文案；2秒内关窗；3秒后后台同步话单（不展示同步细节）
     */
    startSyncAndClose(uuid) {
      const id = this._normalizeCallUuid(uuid)
      this.cancelAutoClose()
      this._clearHangupCloseFailSafe()
      this.isSyncPending = false
      this.isAutoClosing = true
      this.syncPhase = 'closing'
      this.syncPhaseText = ''
      this.callState = 'ended'
      this.stateText = '通话结束，系统回收资源中'
      this.statusClass = 'status-ended'

      // 后台延迟 3s 同步，与弹窗生命周期解耦，避免关窗丢同步 / 死锁
      scheduleGlobalCallSync(id)

      // 尽快回收连接资源（短超时，不拖住关窗）
      this.cleanupConnection(true).catch((e) => {
        console.warn('[一键外呼] 挂断后回收异常:', e && e.message)
      })

      // 最多 2 秒必须关窗（不展示同步进度）
      this._hangupCloseFailSafe = setTimeout(() => {
        this._hangupCloseFailSafe = null
        this.cancelAutoClose()
        this._resetCallAvailability()
        this.doClose()
      }, CLOSE_AFTER_HANGUP_MS)
    },

    cancelAutoClose() {
      if (this.autoCloseTimer) {
        clearInterval(this.autoCloseTimer)
        this.autoCloseTimer = null
      }
      this.autoCloseCountdown = 0
      this.isAutoClosing = false
    },

    doClose() {
      this.cancelAutoClose()
      this._clearHangupCloseFailSafe()
      this.isAutoClosing = false
      this.isSyncPending = false
      this.syncPhase = 'idle'
      this._resetCallAvailability()
      this.$emit('update:sipVisible', false)
    },

    startTimer() {
      this.stopTimer()
      this._lastCallTimerTickAt = Date.now()
      this.timerInterval = setInterval(() => {
        if (this.callStartTime) {
          const elapsed = Math.floor((Date.now() - this.callStartTime) / 1000)
          const mins = Math.floor(elapsed / 60).toString().padStart(2, '0')
          const secs = (elapsed % 60).toString().padStart(2, '0')
          this.callTimer = `${mins}:${secs}`
          this._lastCallTimerTickAt = Date.now()
        }
      }, 1000)
    },

    stopTimer() {
      if (this.timerInterval) {
        clearInterval(this.timerInterval)
        this.timerInterval = null
      }
    },

    handleClose() {
      // 关窗动画迟到回调：若已重新打开新会话，绝不能再动新流程
      if (this.sipVisible) {
        console.warn('[一键外呼] 忽略迟到的 close 回调，避免打断新外呼')
        return
      }
      if (['talking', 'ringing', 'connecting'].includes(this.callState)) {
        try { this.handleHangup() } catch (e) {}
      }
      this._resetCallAvailability()
      this.destroyAll(true, this._openRunId, { forceReset: true }).catch(() => {})
    },

    handleBeforeUnload() {
      const uuid = this._normalizeCallUuid(this.currentCallUuid)
      if (uuid && !GLOBAL_SYNCED_UUIDS.has(uuid) && !GLOBAL_SYNCING_UUIDS.has(uuid)) {
        const syncData = { uuid }
        if (navigator.sendBeacon) {
          const blob = new Blob([JSON.stringify(syncData)], { type: 'application/json' })
          navigator.sendBeacon('/api/aiSipCall/callEndSyncByUuid', blob)
          markGlobalSynced(uuid)
        } else {
          scheduleGlobalCallSync(uuid)
        }
        try {
          sessionStorage.setItem(ACTIVE_CALL_STORAGE_KEY, JSON.stringify({
            uuid,
            callState: this.callState,
            callDirection: this.callDirection,
            savedAt: Date.now()
          }))
        } catch (e) {}
      }
      if (this.ccPhoneBar && this.callState !== 'ended' && this.callState !== 'idle') {
        if (this.ccSocketConnected) {
          try { this.ccPhoneBar.hangup() } catch (e) {}
        }
      }
      try { this._forceReleaseIpccCallState() } catch (e) {}
    },

    async cleanupConnection(fast = false) {
      this.stopAudioHealthCheck()
      this._cancelRemoteHangupCheck()
      this._clearIpccReconnectState()
      this.unbindSipEvents()
      if (this.phone) {
        try { this.phone.releaseLocalStream() } catch (e) {}
        try { this.phone.destroy() } catch (e) {}
        this.phone = null
      }
      if (this.ccPhoneBar) {
        this._forceReleaseIpccCallState()
        const waitMs = fast ? 400 : (IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
        await this._gracefulDisconnectIpcc(waitMs)
        this.ccPhoneBar = null
      }
    },

    async destroyAll(skipSync = false, sessionToken = null, { forceReset = false } = {}) {
      const isStale = () => sessionToken != null && sessionToken !== this._openRunId
      if (isStale()) return

      this.stopTimer()
      this.cancelAutoClose()
      this.clearHangupLockTimer()

      // 仅清理本次开始时的连接引用，避免超时后误杀新会话
      const phoneRef = this.phone
      const ccRef = this.ccPhoneBar

      const inCall = ['talking', 'ringing', 'connecting'].includes(this.callState)

      if (!skipSync && inCall && !this.isHangingUp && ccRef) {
        this.isHangingUp = true
        if (this.ccSocketConnected) {
          try { ccRef.hangup() } catch (e) {}
        } else {
          this._pendingIpccHangup = true
          this._persistActiveCallSnapshot()
          try { this._scheduleIpccReconnect(true, 0) } catch (e) {}
        }

        if (this.currentCallUuid) {
          scheduleGlobalCallSync(this.currentCallUuid)
        }
      }

      if (isStale()) return

      try {
        await Promise.race([
          this._cleanupConnectionRefs(phoneRef, ccRef),
          new Promise((resolve) => setTimeout(resolve, 2500))
        ])
      } catch (e) {
        console.warn('[一键外呼] cleanupConnection 异常:', e && e.message)
        if (this.phone === phoneRef) this.phone = null
        if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
      }

      // 超时竞态：若已进入新初始化，禁止迟到的 destroy 清空步骤
      if (isStale()) return
      // 关窗强制复位；进行中的新会话（弹窗可见）才跳过，避免打断选线路/通话
      if (!forceReset && this.sipVisible && (this.isInitializing || this.isReady || this.awaitingGatewaySelect)) {
        console.warn('[一键外呼] destroyAll 跳过 resetState，避免打断进行中的外呼流程')
        return
      }

      this.resetStateWithoutCancelSync()
      if (forceReset) {
        this._releaseBootLocks()
        this._recoverAfterCancelCount = 0
        this.initCancelled = false
        this.isInitializing = false
        this.awaitingGatewaySelect = false
        this.gatewayConfirming = false
        this._initActiveGen = 0
      }
    },

    async _cleanupConnectionRefs(phoneRef, ccRef) {
      this.stopAudioHealthCheck()
      this._cancelRemoteHangupCheck()
      this._clearIpccReconnectState()
      this.unbindSipEvents()
      if (phoneRef && this.phone === phoneRef) {
        try { phoneRef.releaseLocalStream() } catch (e) {}
        try { phoneRef.destroy() } catch (e) {}
        if (this.phone === phoneRef) this.phone = null
      }
      if (ccRef && this.ccPhoneBar === ccRef) {
        this._forceReleaseIpccCallState()
        await this._gracefulDisconnectIpcc(IPCC_CONFIG.DISCONNECT_WAIT_MS || 2000)
        if (this.ccPhoneBar === ccRef) this.ccPhoneBar = null
      }
    },
  },
  beforeDestroy() {
    if (this.beforeUnloadHandler) {
      window.removeEventListener('beforeunload', this.beforeUnloadHandler)
    }
    this._clearInitWatchdog()
    this._clearHangupCloseFailSafe()
    this.cancelInit()
    this._resetCallAvailability()
    this.destroyAll(true, null, { forceReset: true })
  }
}
</script>

<style scoped>
.call-content { padding: 0; }

.dialog-title {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 15px;
  font-weight: 600;
  color: #303133;
}
.title-icon { color: #409eff; }

.progress-bar-container { padding: 4px 0 12px; }
.progress-track {
  height: 3px;
  background: #ebeef5;
  border-radius: 2px;
  margin-bottom: 12px;
  overflow: hidden;
}
.progress-fill {
  height: 100%;
  background: linear-gradient(90deg, #409eff, #67c23a);
  transition: width 0.4s ease;
}
.progress-steps { display: flex; justify-content: space-between; gap: 4px; }
.progress-step {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  flex: 1;
}
.step-dot {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.3s ease;
}
.step-pending .step-dot { background: #f5f7fa; border: 2px solid #dcdfe6; }
.step-loading .step-dot { background: #ecf5ff; border: 2px solid #409eff; }
.step-success .step-dot { background: #67c23a; border: 2px solid #67c23a; }
.step-error .step-dot { background: #f56c6c; border: 2px solid #f56c6c; }
.step-num { font-size: 10px; font-weight: 600; color: #909399; }
.step-dot .el-icon-loading { font-size: 12px; color: #409eff; }
.step-label { font-size: 9px; font-weight: 500; white-space: nowrap; }
.step-pending .step-label,
.step-error .step-label { color: #c0c4cc; }
.step-loading .step-label { color: #409eff; }
.step-success .step-label { color: #303133; }

.error-bar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 6px 10px;
  margin-bottom: 8px;
  background: #fef0f0;
  border-radius: 6px;
  color: #f56c6c;
  font-size: 11px;
}
.retry-btn {
  background: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 2px 8px;
  cursor: pointer;
  font-size: 11px;
}

.call-area {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 8px 0 4px;
}
.phone-number {
  font-size: 22px;
  font-weight: 700;
  color: #1a1a2e;
  letter-spacing: 2px;
  margin-bottom: 14px;
}

.phone-icon-wrap { margin-bottom: 12px; }
.phone-icon-bg {
  position: relative;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f5ff;
}
.icon-talking .phone-icon-bg { background: #f0f9eb; }

.pulse-ring-1, .pulse-ring-2 {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid #409eff;
  opacity: 0;
  animation: pulse-expand 1.8s ease-out infinite;
}
.pulse-ring-2 { animation-delay: 0.6s; }
@keyframes pulse-expand {
  0% { transform: scale(0.85); opacity: 0.6; }
  100% { transform: scale(1.5); opacity: 0; }
}

.icon-pulsing svg { animation: icon-bounce 1s ease-in-out infinite; }
.icon-ringing svg { animation: icon-ring 0.5s ease-in-out infinite; }
@keyframes icon-bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-3px); }
}
@keyframes icon-ring {
  0%, 100% { transform: rotate(0deg); }
  25% { transform: rotate(6deg); }
  75% { transform: rotate(-6deg); }
}

.wave-bar {
  position: absolute;
  bottom: 6px;
  width: 2px;
  background: #67c23a;
  border-radius: 1px;
  animation: wave-jump 0.8s ease-in-out infinite;
}
.w1 { height: 8px; left: calc(50% - 12px); animation-delay: 0s; }
.w2 { height: 13px; left: calc(50% - 5px); animation-delay: 0.15s; }
.w3 { height: 8px; left: calc(50% + 8px); animation-delay: 0.3s; }
@keyframes wave-jump {
  0%, 100% { transform: scaleY(0.6); }
  50% { transform: scaleY(1.2); }
}

.call-state-text {
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 12px;
}
.call-timer {
  font-size: 16px;
  font-family: monospace;
  color: #303133;
  display: block;
  margin-top: 2px;
}
.status-connecting { color: #e6a23c; }
.status-ringing { color: #409eff; }
.status-talking { color: #4caf50; }
.status-ended { color: #909399; }
.status-hold { color: #e6a23c; }

.volume-controls {
  display: flex;
  gap: 16px;
  padding: 6px 12px;
  margin-bottom: 10px;
  background: #f8fafc;
  border-radius: 20px;
  width: 100%;
  justify-content: center;
}
.volume-item {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1;
}
.volume-icon {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #ecf5ff;
  cursor: pointer;
  color: #409eff;
  flex-shrink: 0;
}
.volume-icon.muted { background: #fef0f0; color: #f56c6c; }
.volume-slider {
  flex: 1;
  height: 3px;
  -webkit-appearance: none;
  background: #e4e7ed;
  border-radius: 2px;
  outline: none;
}
.volume-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #409eff;
  cursor: pointer;
}
.volume-value { font-size: 10px; color: #909399; width: 32px; text-align: right; }

.call-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
  margin: 4px 0;
}
.action-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  background: none;
  border: none;
  cursor: pointer;
  color: #fff;
  transition: transform 0.1s;
}
.action-btn:active { transform: scale(0.96); }
.action-btn span { font-size: 10px; margin-top: 2px; }
.action-btn svg { width: 36px; height: 36px; padding: 8px; border-radius: 50%; }
.hangup-btn svg { background: #f56c6c; color: #fff; box-shadow: 0 2px 8px rgba(245,108,108,0.3); }
.pause-btn svg { background: #e6a23c; box-shadow: 0 2px 8px rgba(230,162,60,0.3); }
.resume-btn svg { background: #67c23a; box-shadow: 0 2px 8px rgba(103,194,58,0.3); }

.ended-notice {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  margin-top: 8px;
  font-size: 11px;
  color: #909399;
}
.ended-notice svg {
  animation: notice-pulse 2s ease-in-out infinite;
}
@keyframes notice-pulse {
  0%, 100% { opacity: 0.6; }
  50% { opacity: 1; }
}

.gateway-picker {
  margin-top: 4px;
  padding: 12px;
  background: linear-gradient(180deg, #f3f8ff 0%, #fafcff 100%);
  border: 1px solid #d9e8ff;
  border-radius: 12px;
}
.gateway-picker-head { margin-bottom: 10px; }
.gateway-picker-title {
  font-size: 15px;
  font-weight: 600;
  color: #1f2d3d;
}
.gateway-picker-sub {
  margin-top: 4px;
  font-size: 12px;
  color: #8a97a8;
}
.gateway-list {
  max-height: 240px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.gateway-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 10px 12px;
  background: #fff;
  border: 1px solid #e4eaf2;
  border-radius: 10px;
  cursor: pointer;
  transition: all .18s ease;
}
.gateway-card:hover { border-color: #9ec5ff; box-shadow: 0 2px 8px rgba(31,111,235,.08); }
.gateway-card.selected {
  border-color: #1f6feb;
  background: linear-gradient(90deg, #eef5ff 0%, #ffffff 70%);
  box-shadow: inset 3px 0 0 #1f6feb;
}
.gateway-card.theme-mobile.selected { box-shadow: inset 3px 0 0 #12b886; border-color: #69db9d; }
.gateway-name {
  font-size: 13px;
  font-weight: 600;
  color: #1f2d3d;
  line-height: 1.35;
}
.gateway-meta {
  margin-top: 4px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  font-size: 12px;
  color: #909399;
}
.gateway-side {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}
.gateway-badge {
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 11px;
  background: #eef2f7;
  color: #606266;
}
.gateway-card.theme-mobile .gateway-badge { background: #e6fcf5; color: #0ca678; }
.gateway-card.theme-landline .gateway-badge { background: #e7f1ff; color: #1f6feb; }
.gateway-check { color: #1f6feb; font-size: 16px; font-weight: 700; }
.gateway-actions {
  margin-top: 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.gateway-selected-tip {
  flex: 1;
  font-size: 12px;
  color: #1f6feb;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gateway-confirm-btn {
  border: none;
  outline: none;
  height: 36px;
  padding: 0 16px;
  border-radius: 8px;
  background: linear-gradient(135deg, #1f6feb 0%, #3b82f6 100%);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-shadow: 0 4px 12px rgba(31,111,235,.28);
}
.gateway-confirm-btn:disabled {
  opacity: .55;
  cursor: not-allowed;
  box-shadow: none;
}
</style>

<style>
.call-phone-dialog { border-radius: 12px; overflow: hidden; }
.call-phone-dialog .el-dialog__header { padding: 14px 20px 10px; border-bottom: 1px solid #f0f0f0; }
.call-phone-dialog .el-dialog__body { padding: 12px 20px 18px; }
.call-phone-dialog .el-dialog__headerbtn { top: 14px; right: 18px; }
</style>
