Explorar el Código

Merge remote-tracking branch 'origin/master'

yuhongqi hace 1 día
padre
commit
3cff72e3c6

+ 9 - 0
src/api/aiAddwxSop/api.js

@@ -98,6 +98,15 @@ export function delTemplateNode(nodeId) {
   })
 }
 
+// 批量保存画布节点(含位置坐标)
+export function saveCanvasNodes(data) {
+  return request({
+    url: '/aiAddwxSop/templateNode/saveCanvasNodes',
+    method: 'post',
+    data: data
+  })
+}
+
 // ==================== 模板节点规则管理 ====================
 
 // 查询节点规则列表

+ 34 - 34
src/api/aiSipCall/aiSipCallGateway.js

@@ -2,61 +2,61 @@ import request from '@/utils/request'
 
 // 查询aiSIP外呼网关列表
 export function listAiSipCallGateway(query) {
-    return request({
-        url: '/company/aiSipCall/gateway/list',
-        method: 'get',
-        params: query
-    })
+  return request({
+    url: '/company/aiSipCall/gateway/list',
+    method: 'get',
+    params: query
+  })
 }
 
 // 查询aiSIP外呼网关列表
-export function remoteList(data) {
-    return request({
-        url: '/company/aiSipCall/gateway/remoteList',
-        method: 'post',
-        data: data
-    })
+export function remoteGatewayList(data) {
+  return request({
+    url: '/company/aiSipCall/gateway/remoteList',
+    method: 'post',
+    data: data
+  })
 }
 
 // 查询aiSIP外呼网关详细
 export function getAiSipCallGateway(id) {
-    return request({
-        url: '/company/aiSipCall/gateway/' + id,
-        method: 'get'
-    })
+  return request({
+    url: '/company/aiSipCall/gateway/' + id,
+    method: 'get'
+  })
 }
 
 // 新增aiSIP外呼网关
 export function addAiSipCallGateway(data) {
-    return request({
-        url: '/company/aiSipCall/gateway',
-        method: 'post',
-        data: data
-    })
+  return request({
+    url: '/company/aiSipCall/gateway',
+    method: 'post',
+    data: data
+  })
 }
 
 // 修改aiSIP外呼网关
 export function updateAiSipCallGateway(data) {
-    return request({
-        url: '/company/aiSipCall/gateway',
-        method: 'put',
-        data: data
-    })
+  return request({
+    url: '/company/aiSipCall/gateway',
+    method: 'put',
+    data: data
+  })
 }
 
 // 删除aiSIP外呼网关
 export function delAiSipCallGateway(id) {
-    return request({
-        url: '/company/aiSipCall/gateway/' + id,
-        method: 'delete'
-    })
+  return request({
+    url: '/company/aiSipCall/gateway/' + id,
+    method: 'delete'
+  })
 }
 
 // 导出aiSIP外呼网关
 export function exportAiSipCallGateway(query) {
-    return request({
-        url: '/company/aiSipCall/gateway/export',
-        method: 'get',
-        params: query
-    })
+  return request({
+    url: '/company/aiSipCall/gateway/export',
+    method: 'get',
+    params: query
+  })
 }

+ 22 - 13
src/api/aiSipCall/aiSipCallOutboundCdr.js

@@ -52,9 +52,9 @@ export function exportOutboundCdr(query) {
   })
 }
 // 取手动外呼客户沟通信息
-export function getCustCommunicationInfo(phoneNum,callType,uuid) {
+export function getCustCommunicationInfo(phoneNum,callType,uuid,dialMode) {
   return request({
-    url: '/company/aiSipCall/outboundCdr/getCustCommunicationInfo?phoneNum=' +  phoneNum + "&callType=" + callType + "&uuid=" + uuid,
+    url: '/company/aiSipCall/outboundCdr/getCustCommunicationInfo?phoneNum=' +  phoneNum + "&callType=" + callType + "&uuid=" + uuid + "&dialMode=" + dialMode,
     method: 'get'
   })
 }
@@ -67,6 +67,26 @@ export function addCustcallrecord(data) {
     data: data
   })
 }
+
+// 获取加密电话
+export function encryptMobile(data) {
+  return request({
+    url: '/company/aiSipCall/outboundCdr/encryptMobile',
+    method: 'post',
+    data: data
+  })
+}
+
+// 同步当前通话记录
+export function callEndSyncByUuid(data) {
+  return request({
+    url: '/company/aiSipCall/outboundCdr/callEndSyncByUuid',
+    method: 'post',
+    data: data,
+    headers: { silentError: true }
+  })
+}
+
 // 手动同步人工外呼通话记录
 export function manualPull() {
   return request({
@@ -75,14 +95,3 @@ export function manualPull() {
   })
 }
 
-/**
- * 根据 uuid 同步通话记录
- */
-export function syncByUuid(data) {
-    return request({
-        url: '/company/aiSipCall/outboundCdr/syncByUuid',
-        method: 'post',
-        data: data
-    })
-}
-

+ 44 - 0
src/api/aiSipCall/aiSipCallPhone.js

@@ -59,3 +59,47 @@ export function manualPull() {
   })
 }
 
+// 播放/下载录音(后端返回二进制流,RequestBody 取 wavfile)
+export function playAndDownload(data, config = {}) {
+  return request({
+    url: '/company/aiSipCall/phone/playAndDownload',
+    method: 'post',
+    data: data,
+    responseType: 'blob',
+    timeout: 600000,
+    ...config
+  })
+}
+// 查询sip执行详情
+export function listCVO(query) {
+  return request({
+    url: '/company/aiSipCall/phone/listCVO',
+    method: 'get',
+    params: query
+  })
+}
+
+// 统计详情列表
+export function statisticalManagement(data) {
+  return request({
+    url: '/company/aiSipCall/phone/statisticalManagement',
+    method: 'post',
+    data: data
+  })
+}
+// 导出aiSIP外呼通话记录
+export function exportStatisticalManagement(query) {
+  return request({
+    url: '/company/aiSipCall/phone/exportStatisticalManagement',
+    method: 'get',
+    params: query
+  })
+}
+// 自动化任务sip执行计划详情列表
+export function companySopList(query) {
+  return request({
+    url: '/company/aiSipCall/phone/companySopList',
+    method: 'get',
+    params: query
+  })
+}

+ 17 - 9
src/api/aiSipCall/aiSipCallUser.js

@@ -59,19 +59,27 @@ export function exportAiSipCallUser(query) {
   })
 }
 // 获取未绑定的分机列表
-export function getUnBindExtnum(query) {
+export function getUnBindExtnum(companyId) {
   return request({
-    url: '/company/aiSipCall/aiSipCallUser/getUnBindExtnum',
-    method: 'get',
-    params: query
+    url: '/company/aiSipCall/aiSipCallUser/getCompanyUnBindExtnum/' + companyId,
+    method: 'get'
   })
 }
 
 // 查询aiSIP工具条基础配置参数
 export function getToolbarBasicParam(data) {
-    return request({
-        url: '/company/aiSipCall/aiSipCallUser/getToolbarBasicParam',
-        method: 'post',
-        data: data
-    })
+  return request({
+    url: '/company/aiSipCall/aiSipCallUser/getToolbarBasicParam',
+    method: 'post',
+    data: data
+  })
+}
+
+// 坐席登录接口(WebSocket连接成功后调用)
+export function agentLogin(data) {
+  return request({
+    url: '/company/aiSipCall/aiSipCallUser/agentLogin',
+    method: 'post',
+    data: data
+  })
 }

+ 8 - 0
src/api/aiSipCall/aiSipCallVoiceTtsAliyun.js

@@ -1,5 +1,13 @@
 import request from '@/utils/request'
 
+// 查询aiSIP外呼阿里云音色列表
+export function remoteList(query) {
+  return request({
+    url: '/company/aiSipCall/voiceTtsAliyun/remoteList',
+    method: 'get',
+    params: query
+  })
+}
 // 查询aiSIP外呼阿里云音色列表
 export function listAiSipCallVoiceTtsAliyun(query) {
   return request({

+ 1001 - 0
src/api/aiSipCall/softPhone.js

@@ -0,0 +1,1001 @@
+// softPhone.js - WebPhone核心逻辑与配置管理
+import * as JsSIP from 'jssip';
+
+export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
+
+export const IPCC_DEFAULTS = {
+  SERVER_PROD: 'sip.ylrzcloud.com',
+  CONNECT_TIMEOUT: 8000,
+  RECONNECT_CONNECT_TIMEOUT: 3000,
+  RECONNECT_DELAY_MS: 100,
+  RECONNECT_DELAY_IN_CALL_MS: 150,
+  HEARTBEAT_INTERVAL: 16,
+  TOKEN_TTL_MS: 60 * 60 * 1000,
+  GATEWAY_RELEASE_DELAY_MS: 200,
+  SERVER_RELEASE_DELAY_MS: 300,
+  DISCONNECT_WAIT_MS: 800,
+  AGENT_READY_TIMEOUT_MS: 3000,
+  SIP_REGISTER_TIMEOUT_MS: 8000,
+  READY_RESTORE_INITIAL_MS: 50,
+  READY_RESTORE_INTERVAL_MS: 120,
+  READY_RESTORE_MAX_ATTEMPTS: 12,
+  STATUS_BUSY_DELAY_MS: 150,
+  POST_CALL_READY_DEADLINE_MS: 3000
+};
+const SESSION_CONFIRMED_STATUS = 9;
+/** JsSIP RTCSession.STATUS_ANSWERED — 已发送 200,不可再次 answer */
+const SESSION_ANSWERED_STATUS = 5;
+/** JsSIP RTCSession.STATUS_WAITING_FOR_ACK */
+const SESSION_WAITING_ACK_STATUS = 6;
+/** JsSIP RTCSession.STATUS_1XX_RECEIVED — 可 answer 的状态含 2/3/4 */
+const SESSION_ANSWERABLE_STATUSES = [2, 3, 4];
+
+export const JS_SIP_DEFAULTS = {
+  SERVER: 'wss://sip.ylrzcloud.com:8443',
+  DOMAIN: 'sip.ylrzcloud.com',
+  TRANSPORT: 'wss',
+  USER_AGENT: 'JsSIP',
+  SESSION_EXPIRES: 180,
+  MIN_SESSION_EXPIRES: 90,
+  SPEAKER_VOLUME: 0.8,
+  MIC_VOLUME: 0.8,
+  RECONNECT_INTERVAL: 3,
+  RECONNECT_TOTAL_DURATION: 60000
+};
+
+const toBase64 = (str) => {
+  const bytes = new TextEncoder().encode(str);
+  return btoa(String.fromCharCode(...bytes));
+};
+const fromBase64 = (b64) => {
+  const binary = atob(b64);
+  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
+  return new TextDecoder().decode(bytes);
+};
+const encodePassword = (pwd) => {
+  if (!pwd) return '';
+  try {
+    const timestamp = Date.now().toString(36);
+    const pwdStr = String(pwd);
+    const encoded = toBase64(pwdStr);
+    return `${timestamp}:${encoded}`;
+  } catch (e) {
+    try { return btoa(String(pwd)); } catch (fallbackError) { return ''; }
+  }
+};
+const decodePassword = (encoded) => {
+  if (!encoded) return '';
+  try {
+    let base64Part = encoded;
+    if (encoded.includes(':')) base64Part = encoded.split(':')[1];
+    return fromBase64(base64Part);
+  } catch (e) { return ''; }
+};
+
+/** 优先约束:开启回声消除等增强;失败后自动降级 */
+const MIC_AUDIO_CONSTRAINTS_ENHANCED = {
+  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
+  video: false
+};
+/** 兼容约束:部分浏览器/驱动对增强项会报 Requested device not found */
+const MIC_AUDIO_CONSTRAINTS_BASIC = {
+  audio: true,
+  video: false
+};
+/** JsSIP answer/call 用的媒体约束(与 BASIC 对齐,避免二次失败) */
+export const MIC_MEDIA_CONSTRAINTS = {
+  audio: true,
+  video: false
+};
+
+/** 兼容旧浏览器:补齐 mediaDevices.getUserMedia */
+export function ensureMediaDevices() {
+  if (typeof navigator === 'undefined') return false;
+  if (!navigator.mediaDevices) {
+    navigator.mediaDevices = {};
+  }
+  if (!navigator.mediaDevices.getUserMedia) {
+    const legacy = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
+    if (!legacy) return false;
+    navigator.mediaDevices.getUserMedia = (constraints) => new Promise((resolve, reject) => {
+      legacy.call(navigator, constraints, resolve, reject);
+    });
+  }
+  return typeof navigator.mediaDevices.getUserMedia === 'function';
+}
+
+function formatMicError(err) {
+  const name = err?.name || '';
+  const msg = err?.message || String(err || '');
+  if (name === 'NotFoundError' || /Requested device not found/i.test(msg)) {
+    return '未检测到可用麦克风,请检查设备连接后重试';
+  }
+  if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || /Permission|Denied|Access/i.test(msg)) {
+    return '麦克风权限被拒绝,请在浏览器地址栏允许麦克风后刷新重试';
+  }
+  if (name === 'NotReadableError' || name === 'TrackStartError') {
+    return '麦克风被其他程序占用,请关闭后重试';
+  }
+  if (name === 'OverconstrainedError' || name === 'ConstraintNotSatisfiedError') {
+    return '当前麦克风不支持该音频参数,已尝试兼容模式';
+  }
+  if (/secure|HTTPS|getUserMedia/i.test(msg)) {
+    return '当前环境无法访问麦克风(需 HTTPS 或 localhost)';
+  }
+  return msg || '麦克风初始化失败';
+}
+
+export function releaseMediaStream(stream) {
+  if (!stream) return;
+  stream.getTracks().forEach((track) => {
+    try { track.stop(); } catch (e) {}
+  });
+}
+
+/**
+ * 申请麦克风:增强约束失败时自动降级为 audio:true,提升浏览器兼容性
+ */
+export async function requestMicrophoneAccess() {
+  if (!ensureMediaDevices()) {
+    throw new Error('浏览器不支持麦克风');
+  }
+  const attempts = [MIC_AUDIO_CONSTRAINTS_ENHANCED, MIC_AUDIO_CONSTRAINTS_BASIC];
+  let lastError = null;
+  for (let i = 0; i < attempts.length; i++) {
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia(attempts[i]);
+      if (stream?.getAudioTracks?.().length) {
+        return stream;
+      }
+      releaseMediaStream(stream);
+      lastError = new Error('未获取到麦克风音轨');
+    } catch (err) {
+      lastError = err;
+      // 权限明确拒绝时不再降级重试
+      const name = err?.name || '';
+      if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
+        break;
+      }
+    }
+  }
+  throw new Error(formatMicError(lastError));
+}
+
+export async function checkMicrophonePermission() {
+  if (!ensureMediaDevices()) return false;
+  try {
+    if (navigator.permissions?.query) {
+      const status = await navigator.permissions.query({ name: 'microphone' });
+      return status.state !== 'denied';
+    }
+    return true;
+  } catch (err) {
+    return true;
+  }
+}
+
+export class ProfileManager {
+  constructor() { this.profile = null; this.load(); }
+  load() {
+    try {
+      const json = localStorage.getItem('WebPhoneProfile');
+      if (!json) this.reset();
+      else {
+        this.profile = JSON.parse(json);
+        if (this.profile.users) {
+          Object.keys(this.profile.users).forEach(uid => {
+            const user = this.profile.users[uid];
+            if (user.password && typeof user.password === 'string') user.password = decodePassword(user.password);
+          });
+        }
+      }
+    } catch (error) { this.reset(); }
+  }
+  save() {
+    const toStore = JSON.parse(JSON.stringify(this.profile));
+    if (toStore.users) {
+      Object.keys(toStore.users).forEach(uid => {
+        const user = toStore.users[uid];
+        if (user.password && typeof user.password === 'string') user.password = encodePassword(user.password);
+      });
+    }
+    localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
+  }
+  reset() {
+    this.profile = {
+      users: {}, user: '', reconnect: true, reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
+      user_agent: JS_SIP_DEFAULTS.USER_AGENT, session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES, speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
+      mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME, speaker_paused: false, mic_paused: false,
+      auto_answer: true, stun: false, ice_server: ''
+    };
+    this.save();
+  }
+  getProfile() { return this.profile; }
+  getSettings() {
+    let reconnectInterval = this.profile.reconnect_interval || 15;
+    if (reconnectInterval > 1000) {
+      reconnectInterval = Math.floor(reconnectInterval / 1000);
+      this.profile.reconnect_interval = reconnectInterval;
+      this.save();
+    }
+    const currentUser = this.getCurrentUserProfile() || {};
+    return {
+      user_agent: this.profile.user_agent, session_expires: this.profile.session_expires,
+      min_session_expires: this.profile.min_session_expires, stun: this.profile.stun,
+      ice_server: this.profile.ice_server,
+      auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
+      reconnect: this.profile.reconnect, reconnect_interval: reconnectInterval
+    };
+  }
+  updateSettings(settings) { Object.assign(this.profile, settings); this.save(); }
+  resetSettings() {
+    this.profile.reconnect = true;
+    this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
+    this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
+    this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
+    this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
+    this.profile.auto_answer = false;
+    this.profile.stun = false;
+    this.profile.ice_server = '';
+    this.save();
+  }
+  getCurrentUserProfile() { return this.profile.users?.[this.profile.user] || null; }
+  addUser(profile) {
+    if (!profile.user || !profile.domain || !profile.password) throw new Error('登录名、域名和密码为必填项');
+    const userId = `${profile.user}@${profile.domain}`;
+    if (this.profile.users[userId]) this.profile.users[userId] = { ...this.profile.users[userId], ...profile, user: profile.user, domain: profile.domain };
+    else this.profile.users[userId] = profile;
+    this.profile.user = userId;
+    this.save();
+  }
+  updateUser(userId, updatedProfile) {
+    if (this.profile.users[userId]) {
+      const newUser = updatedProfile.user, newDomain = updatedProfile.domain;
+      const newUserId = `${newUser}@${newDomain}`;
+      if (newUserId !== userId) {
+        const merged = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
+        delete this.profile.users[userId];
+        this.profile.users[newUserId] = merged;
+        if (this.profile.user === userId) this.profile.user = newUserId;
+      } else this.profile.users[userId] = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
+      this.save();
+    }
+  }
+  deleteCurrentUser() { if (this.profile.user) delete this.profile.users[this.profile.user]; const keys = Object.keys(this.profile.users); this.profile.user = keys.length > 0 ? keys[0] : ''; this.save(); }
+  switchUser(userId) { if (this.profile.users[userId]) { this.profile.user = userId; this.save(); } }
+}
+
+export class WebPhone {
+  constructor(profile, settings) {
+    this.profile = profile; this.settings = settings; this.session = null; this.ua = null;
+    this.call_id = this.randomUUID(); this.events = {}; this.callTimerId = null; this.dtfmTimerId = null; this.audioCtx = null; this.oscillatorLow = null;
+    this.oscillatorHigh = null; this.gainNode = null; this.reconnectEnabled = settings.reconnect;
+    this.reconnectAttempts = 0; this.reconnectStartTime = null;
+    this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
+    this.isReconnecting = false; this.reconnectTimerId = null; this._isHandlingDisconnect = false;
+    this._deferredReconnectAfterCall = false;
+    this._intentionalTerminate = false;
+    this._intentionalTerminateUntil = 0;
+    this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL); this.ringbackMedia.loop = true;
+    this.remoteMedia = new Audio(); this.remoteMedia.autoplay = true;
+    this.remoteMedia.setAttribute('playsinline', 'true');
+    this.localMedia = new Audio(); this.peerConnection = null;
+    this._mountPlaybackElement(this.ringbackMedia, 'sip-ringback-audio');
+    this._mountPlaybackElement(this.remoteMedia, 'sip-remote-audio');
+    this.localStream = null;
+    this._speakerPaused = false;
+    this._speakerVolume = this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
+    this._answering = false;
+    this._iceIssueTimer = null;
+    this._remoteAudioSyncTimers = [];
+    this._remotePlayRetryTimer = null;
+    this.initUA();
+  }
+  _unmountPlaybackElement(id) {
+    if (typeof document === 'undefined' || !id) return;
+    const existing = document.getElementById(id);
+    if (existing) {
+      try {
+        existing.pause();
+        if (existing.srcObject) {
+          existing.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
+          existing.srcObject = null;
+        }
+      } catch (e) {}
+      existing.remove();
+    }
+  }
+  _mountPlaybackElement(el, id) {
+    if (!el || typeof document === 'undefined') return;
+    this._unmountPlaybackElement(id);
+    el.id = id;
+    el.setAttribute('playsinline', 'true');
+    el.setAttribute('webkit-playsinline', 'true');
+    el.preload = 'auto';
+    if (!el.isConnected) {
+      el.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
+      document.body.appendChild(el);
+    }
+  }
+  _clearPeerConnection() {
+    if (!this.peerConnection) return;
+    this.peerConnection.ontrack = null;
+    this.peerConnection.onaddstream = null;
+    this.peerConnection.oniceconnectionstatechange = null;
+    try { this.peerConnection.close(); } catch (err) {}
+    this.peerConnection = null;
+  }
+  _clearRemotePlayback({ stopTracks = true } = {}) {
+    this._clearRemoteAudioSyncTimers();
+    if (this._remotePlayRetryTimer) {
+      clearTimeout(this._remotePlayRetryTimer);
+      this._remotePlayRetryTimer = null;
+    }
+    this.pauseRingback();
+    if (this.remoteMedia) {
+      try { this.remoteMedia.pause(); } catch (e) {}
+      this.remoteMedia.muted = false;
+      if (this.remoteMedia.srcObject) {
+        if (stopTracks) {
+          this.remoteMedia.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
+        }
+        this.remoteMedia.srcObject = null;
+      }
+      try { this.remoteMedia.load(); } catch (e) {}
+    }
+    this._clearPeerConnection();
+  }
+  /** 通话中远端无声时尝试恢复,不终止当前 SIP 会话 */
+  async recoverRemotePlayback() {
+    if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
+    if (this.remoteMedia?.srcObject) {
+      this._pruneEndedRemoteTracks(this.remoteMedia.srcObject);
+    }
+    return this.resumeRemoteAudio();
+  }
+  /** 新外呼/来电前清理上一轮媒体,避免频繁通话后远端无声 */
+  async prepareForNewCall() {
+    this._answering = false;
+    this.clearIceIssueTimer();
+    this._clearRemotePlayback();
+    if (this.session) {
+      const status = this.session.status;
+      if (status !== 7 && status !== 8) {
+        try { this.session.terminate({ status_code: 486 }); } catch (e) {}
+      } else {
+        this.session = null;
+      }
+    }
+    try {
+      await this.ensureLocalStream();
+    } catch (e) {
+      console.warn('[音频] 新通话前麦克风初始化失败:', e.message || e);
+      // 向上抛出,便于界面提示;兼容降级已在 requestMicrophoneAccess 内完成
+      throw e;
+    }
+  }
+  _pruneEndedRemoteTracks(stream) {
+    if (!stream?.getAudioTracks) return stream;
+    stream.getAudioTracks().forEach((track) => {
+      if (track.readyState === 'ended') {
+        try { stream.removeTrack(track); } catch (e) {}
+      }
+    });
+    return stream;
+  }
+  _attachRemoteTrack(track) {
+    if (!track || track.kind !== 'audio' || track.readyState === 'ended' || !this.remoteMedia) return false;
+    let stream = this.remoteMedia.srcObject;
+    if (!stream) {
+      stream = new MediaStream();
+      this.remoteMedia.srcObject = stream;
+    } else {
+      this._pruneEndedRemoteTracks(stream);
+    }
+    if (!stream.getTracks().some((t) => t.id === track.id)) {
+      stream.addTrack(track);
+    }
+    track.enabled = true;
+    this.remoteMedia.autoplay = true;
+    this.remoteMedia.setAttribute('playsinline', 'true');
+    this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
+    this.remoteMedia.muted = false;
+    this.pauseRingback();
+    this._scheduleRemotePlayRetry(0);
+    return true;
+  }
+  _scheduleRemotePlayRetry(attempt = 0) {
+    if (this._remotePlayRetryTimer) {
+      clearTimeout(this._remotePlayRetryTimer);
+      this._remotePlayRetryTimer = null;
+    }
+    const delays = [0, 120, 300, 600, 1200, 2500];
+    const delay = delays[Math.min(attempt, delays.length - 1)];
+    this._remotePlayRetryTimer = setTimeout(async () => {
+      this._remotePlayRetryTimer = null;
+      if (!this.hasActiveCall() && !this.hasSipSession()) return;
+      const played = await this.resumeRemoteAudio();
+      if (!played && attempt < delays.length - 1 && (this.hasActiveCall() || this.hasSipSession())) {
+        if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
+        this._scheduleRemotePlayRetry(attempt + 1);
+      }
+    }, delay);
+  }
+  isRemotePlaybackPaused() {
+    if (!this.remoteMedia) return true;
+    if (this._speakerPaused) return false;
+    if (this.remoteMedia.paused) return true;
+    // MediaStream 播放时 readyState 可能长期为 0,不能作为暂停依据
+    if (this.remoteMedia.srcObject) return false;
+    return this.remoteMedia.readyState < 2;
+  }
+  isRemotePlaybackActive() {
+    if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
+    if (this._speakerPaused) return true;
+    return !this.remoteMedia.paused;
+  }
+  _clearRemoteAudioSyncTimers() {
+    this._remoteAudioSyncTimers.forEach((t) => clearTimeout(t));
+    this._remoteAudioSyncTimers = [];
+  }
+  _scheduleRemoteAudioSync() {
+    this._clearRemoteAudioSyncTimers();
+    [300, 800, 1500, 3000, 5000, 8000, 12000].forEach((delay) => {
+      const timerId = setTimeout(() => {
+        if (this.hasActiveCall() || this.hasSipSession()) {
+          if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
+          if (!this.isRemotePlaybackActive()) {
+            this.refreshRemoteAudio();
+          }
+        }
+      }, delay);
+      this._remoteAudioSyncTimers.push(timerId);
+    });
+  }
+  randomUUID() {
+    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID().replace(/-/g, '');
+    const arr = new Uint8Array(16);
+    if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') crypto.getRandomValues(arr);
+    else for (let i = 0; i < 16; i++) arr[i] = Math.floor(Math.random() * 256);
+    arr[6] = (arr[6] & 0x0f) | 0x40; arr[8] = (arr[8] & 0x3f) | 0x80;
+    return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
+  }
+  initUA() {
+    if (!this.profile.server || !this.profile.user || !this.profile.domain) {
+      this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
+      return;
+    }
+    const socket = new JsSIP.WebSocketInterface(this.profile.server);
+    if (String(this.profile.server || '').startsWith('wss://')) socket.via_transport = 'WS';
+    const user = String(this.profile.user || '');
+    const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
+    const password = this.profile.password ? String(this.profile.password) : '';
+    const server = String(this.profile.server || '');
+    const transport = 'ws';
+    const domain = String(this.profile.domain || '');
+    if (!user || !domain || !password) {
+      this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
+      return;
+    }
+    if (server.startsWith('wss://') && window.location.protocol === 'http:') {
+      console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容');
+      this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
+    }
+    const uri = new JsSIP.URI('sip', user, domain);
+    const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
+    this.configuration = {
+      sockets: [socket], authorization_user: user, user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
+      display_name: displayName || undefined, session_timers: false,
+      session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
+      session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
+      session_timers_force_refresher: false,
+      no_answer_timeout: 60, register: true, uri: uri.toAor(), contact_uri: contactUriStr, password: password,
+      log_level: 'error'
+    };
+  }
+  getPcConfig() {
+    if (!this.settings.stun) return undefined;
+    const iceUrl = (this.settings.ice_server || 'stun:stun.l.google.com:19302').trim();
+    if (!iceUrl) return undefined;
+    return { iceServers: [{ urls: iceUrl }] };
+  }
+  hasActiveCall() {
+    if (!this.session) return false;
+    const status = this.session.status;
+    return status === SESSION_CONFIRMED_STATUS || status === SESSION_WAITING_ACK_STATUS || status === SESSION_ANSWERED_STATUS;
+  }
+  hasSipSession() {
+    if (!this.session) return false;
+    const status = this.session.status;
+    return status !== 7 && status !== 8 && status !== 0;
+  }
+  createUA() {
+    this.ua = new JsSIP.UA(this.configuration);
+    this.ua.set('display_name', this.profile.display_name);
+    this.ua.on('connecting', this.connecting.bind(this));
+    this.ua.on('connected', this.connected.bind(this));
+    this.ua.on('disconnected', this.disconnected.bind(this));
+    this.ua.on('registered', this.registered.bind(this));
+    this.ua.on('unregistered', this.unregistered.bind(this));
+    this.ua.on('registrationFailed', this.registrationFailed.bind(this));
+    this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
+    this.ua.on('newRTCSession', this.newRTCSession.bind(this));
+    this.ua.on('transportError', this.transportError.bind(this));
+  }
+  On(event, callback) { this.events[event] = callback; }
+  Off(event) { if (this.events[event]) delete this.events[event]; }
+  emit(event, ...args) { if (this.events[event]) try { this.events[event](...args); } catch (e) { console.error(e); } }
+  resetReconnectState() {
+    if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
+    this.reconnectTimerId = null; this.isReconnecting = false; this.reconnectAttempts = 0; this.reconnectStartTime = null;
+    this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
+  }
+  Start(reconnect, isReconnect = false) {
+    console.log(`[jsSip] 启动 ${reconnect ? '正常连接' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
+    this.reconnectEnabled = reconnect;
+    if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
+    if (!isReconnect) this.resetReconnectState();
+    setTimeout(() => {
+      this.createUA();
+      if (this.ua.isRegistered()) { this.SetQueueIn(); return; }
+      if (this.ua.isConnected()) { this.Register(); return; }
+      try { this.ua.start(); } catch (error) {
+        this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
+        this.scheduleReconnect();
+      }
+    }, 50);
+  }
+  Register() { if (this.ua) this.ua.register(); }
+  UnRegister() {
+    this.reconnectEnabled = false; this.resetReconnectState();
+    if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
+    if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
+  }
+  scheduleReconnect() {
+    if (!this.reconnectEnabled || this.hasActiveCall() || this._isIntentionalHangupPhase()) return;
+    if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
+    if (this.isReconnecting) return;
+    const now = Date.now();
+    if (this.reconnectStartTime === null) this.reconnectStartTime = now;
+    const elapsed = now - this.reconnectStartTime;
+    if (elapsed >= this.reconnectTotalDuration) {
+      console.error('[jsSip] 重连超时(超过1分钟)');
+      this.isReconnecting = false;
+      this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
+      this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
+      return;
+    }
+    this.reconnectAttempts++;
+    const configuredSec = this.settings.reconnect_interval ?? JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
+    let interval = this.reconnectAttempts <= 1 ? 400 : Math.min(configuredSec * 1000, 3000);
+    if (this.reconnectAttempts > 5) interval = 2000;
+    if (elapsed + interval > this.reconnectTotalDuration) {
+      const remainingTime = this.reconnectTotalDuration - elapsed;
+      if (remainingTime < 500) {
+        this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
+        this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
+        return;
+      }
+      interval = Math.min(interval, remainingTime);
+    }
+    console.log(`[jsSip] ${Math.ceil(interval / 1000)}秒后重连 (第${this.reconnectAttempts}次)`);
+    this.isReconnecting = true;
+    this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
+    this.reconnectTimerId = setTimeout(() => {
+      this.isReconnecting = false;
+      this.reconnectTimerId = null;
+      this.Start(true, true);
+    }, interval);
+  }
+  transportError(err) {
+    const errorMessage = err?.message || err?.reason || '传输错误';
+    if (this.hasActiveCall() || this.hasSipSession()) {
+      console.debug('[SIP] 传输错误(通话中,保持会话):', errorMessage);
+      return;
+    }
+    console.error('[SIP] WebSocket传输错误:', errorMessage);
+    let errorText = 'WSS连接失败';
+    if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content')) errorText = 'WSS连接被浏览器阻止';
+    else if (err?.code === 1006) errorText = 'WSS连接异常关闭';
+    else if (err?.code === 1005) errorText = 'WSS连接被拒绝';
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
+    if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
+  connecting() { this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' }); }
+  connected() { this.Register(); this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' }); }
+  disconnected(e) {
+    if (this._isHandlingDisconnect) return;
+    this._isHandlingDisconnect = true;
+    const reason = e?.cause || e?.message || '未知原因';
+    const inCall = this.hasActiveCall() || this.hasSipSession();
+    if (this._isIntentionalHangupPhase()) {
+      console.debug(`[SIP] 主动挂机后信令变化,保持注册不重连: ${reason}`);
+      setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
+      return;
+    }
+    if (inCall) {
+      console.debug(`[SIP] 信令连接断开(通话中,保持会话): ${reason}`);
+      this._deferredReconnectAfterCall = true;
+    } else {
+      console.warn(`[SIP] 连接断开: ${reason}`);
+      this.emit('OnRegister', { registered: false });
+      if (!this.isReconnecting && this.reconnectEnabled) {
+        this.scheduleReconnect();
+      } else if (!this.reconnectEnabled && this.ua) {
+        try { this.ua.stop(); } catch (err) {}
+        this.ua = null;
+      }
+    }
+    setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
+  }
+  registered() {
+    this._clearIntentionalHangupPhase();
+    this.resetReconnectState();
+    this.emit('OnRegister', { registered: true });
+    this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
+    this.SetQueueIn();
+  }
+  unregistered() { this.emit('OnRegister', { registered: false }); this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' }); }
+  registrationFailed(e) {
+    const cause = e?.cause || e?.message || '未知原因';
+    const statusCode = e?.response?.status_code || '';
+    console.error('[jsSip] 注册失败:', cause);
+    let errorText = '注册失败';
+    if (cause === 'Connection Error') errorText = '注册失败: 无法连接服务器';
+    else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401')) errorText = '注册失败: 认证失败';
+    else if (cause.includes('404')) errorText = '注册失败: 用户不存在';
+    else if (cause.includes('408') || cause.includes('Timeout')) errorText = '注册失败: 请求超时';
+    this.emit('OnRegister', { registered: false });
+    this.emit('OnStatusMessage', { type: 'error', text: errorText });
+    if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
+  }
+  registrationExpiring() { this.Register(); }
+  releaseLocalStream() {
+    if (this.localStream) {
+      releaseMediaStream(this.localStream);
+      this.localStream = null;
+    }
+    if (this.localMedia) this.localMedia.srcObject = null;
+  }
+  async ensureLocalStream() {
+    if (this.localStream?.getAudioTracks().some(t => t.readyState === 'live')) {
+      return this.localStream;
+    }
+    this.releaseLocalStream();
+    const stream = await requestMicrophoneAccess();
+    this.localStream = stream;
+    this.localMedia.srcObject = stream;
+    this.localMedia.muted = true;
+    this.localMedia.volume = 0;
+    return stream;
+  }
+  _getSpeakerVolume() {
+    return this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
+  }
+  _getEffectiveSpeakerVolume() {
+    return this._speakerPaused ? 0 : (this._speakerVolume ?? this._getSpeakerVolume());
+  }
+  async Answer() {
+    if (!this.session || this._answering) return;
+    const status = this.session.status;
+    if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
+    if (!SESSION_ANSWERABLE_STATUSES.includes(status)) return;
+    try {
+      await this.ensureLocalStream();
+    } catch (e) {
+      console.warn('[SIP] 麦克风初始化失败:', e.message || e);
+      // 本地流失败时仍尝试用兼容约束接听,避免 JsSIP 再次用增强约束失败
+    }
+    const options = {
+      // 使用兼容约束,减少 Overconstrained / Requested device not found
+      mediaConstraints: { ...MIC_MEDIA_CONSTRAINTS }
+    };
+    if (this.localStream) options.mediaStream = this.localStream;
+    const pcConfig = this.getPcConfig();
+    if (pcConfig) options.pcConfig = pcConfig;
+    this._answering = true;
+    try {
+      this.session.answer(options);
+    } catch (err) {
+      console.warn('[SIP] answer 失败:', err.message || err);
+    } finally {
+      this._answering = false;
+    }
+  }
+  _scheduleAutoAnswer(attempt = 0) {
+    if (!this.session || attempt > 30) return;
+    const status = this.session.status;
+    if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
+    if (SESSION_ANSWERABLE_STATUSES.includes(status)) {
+      this.Answer();
+      return;
+    }
+    setTimeout(() => this._scheduleAutoAnswer(attempt + 1), 100);
+  }
+  markIntentionalHangup() {
+    this._intentionalTerminate = true;
+    this._intentionalTerminateUntil = Date.now() + 8000;
+    this._deferredReconnectAfterCall = false;
+    if (this.reconnectTimerId) {
+      clearTimeout(this.reconnectTimerId);
+      this.reconnectTimerId = null;
+    }
+    this.isReconnecting = false;
+    this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
+  }
+  _isIntentionalHangupPhase() {
+    return this._intentionalTerminate || Date.now() < this._intentionalTerminateUntil;
+  }
+  _clearIntentionalHangupPhase() {
+    this._intentionalTerminate = false;
+    this._intentionalTerminateUntil = 0;
+  }
+  Terminate(code) {
+    this.markIntentionalHangup();
+    if (!this.session) return;
+    if (code) this.session.terminate({ status_code: code });
+    else this.session.terminate();
+  }
+  ToggleHold() { if (this.session && this.session.isEstablished()) { if (this.session.isOnHold().local) this.session.unhold(); else this.session.hold(); } }
+  ToggleMicPhone() {
+    if (!this.session) return;
+    const muted = this.session.isMuted().audio;
+    if (muted) this.session.unmute({ audio: true });
+    else this.session.mute({ audio: true });
+  }
+  isMicMuted() { return this.session ? this.session.isMuted().audio : false; }
+  SetSpeaker(paused, volume) {
+    if (typeof paused === 'boolean') this._speakerPaused = paused;
+    if (typeof volume === 'number' && !Number.isNaN(volume)) {
+      this._speakerVolume = Math.min(1, Math.max(0, volume));
+    }
+    const vol = this._getEffectiveSpeakerVolume();
+    if (this.remoteMedia) this.remoteMedia.volume = vol;
+    if (this.ringbackMedia) this.ringbackMedia.volume = vol;
+  }
+  SetMicPhone(paused, volume) { this.localMedia.volume = paused ? 0 : volume; if (this.localStream) this.localStream.getAudioTracks().forEach((track) => { track.enabled = !paused; }); }
+  SetQueueIn() { if (this.ua && this.ua.isRegistered()) this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {}); }
+  SendDTMF(tone) { if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone); }
+  PlayDtmfTone(key) {
+    const DTMF_MAP = { '0': [697,1633],'1':[697,1209],'2':[697,1336],'3':[697,1477],'4':[770,1209],'5':[770,1336],'6':[770,1477],'7':[852,1209],'8':[852,1336],'9':[852,1477],'*':[697,1633],'#':[770,1633] };
+    const [lowFreq, highFreq] = DTMF_MAP[key] || [697,1209];
+    this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
+  }
+  generateDtmfTone(lowFreq, highFreq, duration, volume) {
+    if (this.dtfmTimerId) { clearTimeout(this.dtfmTimerId); if (this.oscillatorLow) try { this.oscillatorLow.stop(); } catch(e){} if (this.oscillatorHigh) try { this.oscillatorHigh.stop(); } catch(e){} }
+    if (!this.audioCtx || this.audioCtx.state === 'closed') this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+    this.gainNode = this.audioCtx.createGain();
+    this.oscillatorLow = this.audioCtx.createOscillator(); this.oscillatorLow.type = 'sine'; this.oscillatorLow.frequency.value = lowFreq;
+    this.oscillatorHigh = this.audioCtx.createOscillator(); this.oscillatorHigh.type = 'sine'; this.oscillatorHigh.frequency.value = highFreq;
+    this.oscillatorLow.connect(this.gainNode); this.oscillatorHigh.connect(this.gainNode);
+    this.gainNode.connect(this.audioCtx.destination);
+    this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
+    this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
+    this.oscillatorLow.start(); this.oscillatorHigh.start();
+    this.dtfmTimerId = setTimeout(() => { if (this.oscillatorLow) try { this.oscillatorLow.stop(); } catch(e){} if (this.oscillatorHigh) try { this.oscillatorHigh.stop(); } catch(e){} this.dtfmTimerId = null; }, duration * 1000);
+  }
+  IsOnHold() { return this.session ? this.session.isOnHold().local : false; }
+  pauseRingback() { if (this.ringbackMedia && !this.ringbackMedia.paused) this.ringbackMedia.pause(); }
+  playRingback() { if (this.ringbackMedia && this.ringbackMedia.paused) { this.ringbackMedia.currentTime = 0; this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败')); } }
+  newRTCSession(event) {
+    const incoming = event.session;
+    const outgoing = incoming.direction === 'outgoing';
+
+    if (this.session && this.session !== incoming && this.hasActiveCall() && !outgoing) {
+      try { incoming.terminate({ status_code: 486 }); } catch (e) {}
+      return;
+    }
+
+    const stale = this.session && this.session !== incoming ? this.session : null;
+    this._clearRemotePlayback();
+    this._answering = false;
+    this.session = incoming;
+    this._bindSessionEvents(incoming);
+
+    if (stale && stale.status !== 7 && stale.status !== 8) {
+      try { stale.terminate({ status_code: 486 }); } catch (e) {}
+    }
+
+    this.emit('OnSessionCreated', { outgoing, callee: incoming.remote_identity?.uri?.user || '', province: event.request?.getHeader('X-Province') || '', city: event.request?.getHeader('X-City') || '' });
+    if (!outgoing && this.settings.auto_answer !== false) {
+      this._scheduleAutoAnswer();
+    }
+    if (outgoing) this.playRingback();
+  }
+  _bindSessionEvents(session) {
+    session.on('progress', (e) => {
+      this.emit('OnRing', {
+        outgoing: session.direction === 'outgoing',
+        province: e.response?.getHeader('X-Province') || '',
+        city: e.response?.getHeader('X-City') || ''
+      });
+    });
+    session.on('confirmed', () => {
+      this.pauseRingback();
+      this.emit('OnAnswered', session.direction === 'outgoing');
+      // 外呼计时以客户真正接通为准,由 IPCC CALLEE_ANSWERED / 来电由 SIP confirmed 启动
+      if (session.direction !== 'outgoing') {
+        this.startCallTimer();
+      }
+      if (this.remoteMedia) {
+        this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
+      }
+      this.refreshRemoteAudio();
+      this._scheduleRemoteAudioSync();
+    });
+    session.on('ended', () => {
+      console.log('[SIP] session ended', session.direction);
+      this.sessionClosed(true, '', session);
+    });
+    session.on('failed', (e) => {
+      const cause = e?.cause || '';
+      console.warn('[SIP] session failed:', cause, session.direction);
+      this.sessionClosed(false, cause, session);
+    });
+    session.on('peerconnection', (data) => {
+      const pc = data?.peerconnection || data;
+      if (pc) this.registerRemoteMedia(pc);
+    });
+  }
+  registerRemoteMedia(connection) {
+    if (!connection) return;
+    this.peerConnection = connection;
+    const onRemoteTrack = (track) => this._attachRemoteTrack(track);
+    connection.ontrack = (e) => {
+      if (e.track) onRemoteTrack(e.track);
+      else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
+    };
+    if (typeof connection.addEventListener === 'function') {
+      connection.addEventListener('track', (e) => {
+        if (e.track) onRemoteTrack(e.track);
+        else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
+      });
+    }
+    connection.onaddstream = (e) => {
+      if (e.stream) e.stream.getAudioTracks().forEach(onRemoteTrack);
+    };
+    this.syncRemoteReceivers(connection);
+    this.registerRemoteMediaIceHandlers(connection);
+  }
+  syncRemoteReceivers(connection) {
+    if (!connection || !this.remoteMedia) return false;
+    let attached = false;
+    const attach = (track) => {
+      if (this._attachRemoteTrack(track)) attached = true;
+    };
+    if (typeof connection.getTransceivers === 'function') {
+      connection.getTransceivers().forEach((tr) => {
+        if (tr.receiver?.track) attach(tr.receiver.track);
+      });
+    }
+    if (typeof connection.getReceivers === 'function') {
+      connection.getReceivers().forEach((receiver) => {
+        if (receiver.track) attach(receiver.track);
+      });
+    }
+    return attached;
+  }
+  hasRemoteAudioTrack() {
+    const stream = this.remoteMedia?.srcObject;
+    if (stream?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled)) return true;
+    if (this.peerConnection) {
+      this.syncRemoteReceivers(this.peerConnection);
+      const refreshed = this.remoteMedia?.srcObject;
+      return !!refreshed?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled);
+    }
+    return false;
+  }
+  registerRemoteMediaIceHandlers(connection) {
+    if (!connection) return;
+    connection.oniceconnectionstatechange = () => {
+      const state = connection.iceConnectionState;
+      if (state === 'failed') {
+        console.warn('[SIP] ICE 状态: failed');
+        this.emit('OnIceMediaIssue', { state, timedOut: true });
+      } else if (state === 'disconnected') {
+        console.warn('[SIP] ICE 状态: disconnected');
+        if (this._iceIssueTimer) clearTimeout(this._iceIssueTimer);
+        this.emit('OnIceMediaIssue', { state, timedOut: false });
+        this._iceIssueTimer = setTimeout(() => {
+          this._iceIssueTimer = null;
+          const cur = connection.iceConnectionState;
+          if (cur === 'disconnected' || cur === 'failed') {
+            this.emit('OnIceMediaIssue', { state: cur, timedOut: true });
+          }
+        }, 12000);
+      } else if (state === 'connected' || state === 'completed') {
+        if (this._iceIssueTimer) {
+          clearTimeout(this._iceIssueTimer);
+          this._iceIssueTimer = null;
+        }
+        this.resumeRemoteAudio();
+        this._scheduleRemotePlayRetry(0);
+      }
+    };
+  }
+  stopCallTimer() {
+    if (this.callTimerId) {
+      clearInterval(this.callTimerId);
+      this.callTimerId = null;
+    }
+  }
+  clearIceIssueTimer() {
+    if (this._iceIssueTimer) {
+      clearTimeout(this._iceIssueTimer);
+      this._iceIssueTimer = null;
+    }
+  }
+  sessionClosed(succeed, reason, closedSession) {
+    if (!closedSession || this.session !== closedSession) return;
+    const intentional = this._isIntentionalHangupPhase();
+    const shouldReconnect = !intentional && this._deferredReconnectAfterCall;
+    this._deferredReconnectAfterCall = false;
+    this.clearIceIssueTimer();
+    this.stopCallTimer();
+    this._answering = false;
+    this._clearRemotePlayback();
+    this.session = null;
+    if (!intentional) {
+      this.releaseLocalStream();
+    }
+    this.emit('OnSessionClosed', { succeeded: succeed, reason, intentional });
+    if (shouldReconnect && this.reconnectEnabled && !this.isReconnecting) {
+      this.scheduleReconnect();
+    }
+  }
+  refreshRemoteAudio() {
+    if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
+    return this.resumeRemoteAudio();
+  }
+  async resumeRemoteAudio() {
+    if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
+    if (this.audioCtx && this.audioCtx.state === 'suspended') {
+      try { await this.audioCtx.resume(); } catch (e) {}
+    }
+    if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
+    this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
+    this.remoteMedia.muted = false;
+    try {
+      await this.remoteMedia.play();
+      return !this.remoteMedia.paused;
+    } catch (e) {
+      console.warn('[音频] 远端播放 resume 失败:', e.message || e);
+      return false;
+    }
+  }
+  startCallTimer() {
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    let seconds = 0;
+    this.callTimerId = setInterval(() => { seconds++; const mins = Math.floor(seconds / 60); const secs = seconds % 60; this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`); }, 1000);
+  }
+  destroy() {
+    this.reconnectEnabled = false;
+    this.clearIceIssueTimer();
+    this._clearRemotePlayback();
+    if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
+    if (this.callTimerId) clearInterval(this.callTimerId);
+    if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
+    if (this.session) {
+      try { this.session.terminate(); } catch (e) {}
+      this.session = null;
+    }
+    this.UnRegister();
+    const cleanupAudio = (audio) => { if (!audio) return; try { audio.pause(); audio.src = ''; if (audio.srcObject) { audio.srcObject.getTracks().forEach(t => t.stop()); audio.srcObject = null; } audio.load(); } catch(e) {} };
+    cleanupAudio(this.ringbackMedia); cleanupAudio(this.remoteMedia); cleanupAudio(this.localMedia);
+    this._unmountPlaybackElement('sip-ringback-audio');
+    this._unmountPlaybackElement('sip-remote-audio');
+    this.releaseLocalStream();
+    this.ringbackMedia = null; this.remoteMedia = null; this.localMedia = null;
+    if (this.audioCtx) { try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch(e) {} this.audioCtx = null; }
+    this.oscillatorLow = null; this.oscillatorHigh = null; this.gainNode = null;
+    this.events = {}; this.configuration = null; this.profile = null; this.settings = null; this.session = null; this.ua = null;
+  }
+}
+
+export default {
+  WebPhone, ProfileManager, checkMicrophonePermission, releaseMediaStream,
+  RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS
+};

+ 10 - 0
src/api/qw/sopLogs.js

@@ -86,3 +86,13 @@ export function generateLiveShortLink(data) {
     data: data
   })
 }
+
+
+// 查看sip任务
+export function getSopSipTask(query) {
+    return request({
+        url: '/company/sop/sipCallLogs/list',
+        method: 'get',
+        params: query
+    })
+}

+ 13 - 0
src/router/index.js

@@ -288,6 +288,19 @@ export const constantRoutes = [
     ]
   },
 
+  {
+  path: '/aiAddwxSop',
+  component: Layout,
+  hidden: true,
+  children: [
+    {
+      path: 'canvas',
+      component: () => import('@/views/aiAddwxSop/canvas'),
+      name: 'AiAddwxSopCanvas',
+      meta: { title: '画布节点设计', activeMenu: '/aiAddwxSop/template' }
+    }
+  ]
+},
   {
     path: '/watch/deviceInfo/details',
     component: Layout,

+ 572 - 0
src/views/aiAddwxSop/canvas.vue

@@ -0,0 +1,572 @@
+<template>
+  <div class="canvas-page">
+    <!-- 顶部工具栏 -->
+    <div class="canvas-topbar">
+      <el-button icon="el-icon-arrow-left" size="small" @click="goBack">返回模板列表</el-button>
+      <span class="topbar-title">画布节点设计 - {{ templateInfo.templateName }}</span>
+      <div class="topbar-actions">
+        <el-button v-if="canvasNodes.length === 0" type="primary" icon="el-icon-plus" @click="addStartNode">新增开始节点</el-button>
+        <template v-else>
+          <el-button type="success" size="small" icon="el-icon-check" @click="saveCanvasNodes">保存画布</el-button>
+          <el-button size="small" icon="el-icon-refresh" @click="resetCanvas">重置</el-button>
+        </template>
+        <el-button size="small" icon="el-icon-document" @click="showWorkflowTemplate">工作流模板</el-button>
+      </div>
+      <span v-if="canvasNodes.length > 0" style="margin-left: 10px; color: #909399; font-size: 12px;">
+        节点数: {{ canvasNodes.length }} | 点击节点"+"添加子节点 | 拖拽节点调整位置 | 点击节点编辑配置
+      </span>
+    </div>
+
+    <!-- 主体:画布 + 右侧面板 -->
+    <div class="canvas-layout">
+      <!-- 左侧:画布 -->
+      <div class="canvas-main">
+        <div
+          class="canvas-container"
+          ref="canvasContainer"
+          @mousedown="onCanvasMouseDown"
+          @mousemove="onCanvasMouseMove"
+          @mouseup="onCanvasMouseUp"
+          @mouseleave="onCanvasMouseUp"
+        >
+          <svg :width="canvasWidth" :height="canvasHeight" class="canvas-svg">
+            <defs>
+              <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
+                <polygon points="0 0, 10 3.5, 0 7" fill="#999" />
+              </marker>
+            </defs>
+            <line
+              v-for="(line, idx) in canvasLines"
+              :key="'line-' + idx"
+              :x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
+              stroke="#999" stroke-width="2" marker-end="url(#arrowhead)"
+            />
+            <g
+              v-for="node in canvasNodes"
+              :key="node._cid"
+              @mousedown.stop="onNodeMouseDown($event, node)"
+              @click.stop="onNodeClick(node)"
+              :style="{ cursor: draggingNode && draggingNode._cid === node._cid ? 'grabbing' : 'pointer' }"
+            >
+              <rect
+                :x="node._x" :y="node._y" :width="nodeWidth" :height="nodeHeight"
+                :rx="8" :ry="8"
+                :fill="getNodeColor(node.nodeType)"
+                :stroke="selectedNode && selectedNode._cid === node._cid ? '#303133' : 'transparent'"
+                stroke-width="2" opacity="0.9"
+              />
+              <text :x="node._x + nodeWidth / 2" :y="node._y + nodeHeight / 2 - 6" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">
+                {{ node.nodeName || '未命名' }}
+              </text>
+              <text :x="node._x + nodeWidth / 2" :y="node._y + nodeHeight / 2 + 14" text-anchor="middle" fill="rgba(255,255,255,0.85)" font-size="11">
+                {{ getNodeTypeName(node.nodeType) }}
+              </text>
+              <g @click.stop="addChildNode(node)" class="node-add-btn">
+                <circle :cx="node._x + nodeWidth / 2" :cy="node._y + nodeHeight + 14" r="10" fill="#fff" stroke="#409EFF" stroke-width="2" />
+                <line :x1="node._x + nodeWidth / 2 - 6" :y1="node._y + nodeHeight + 14" :x2="node._x + nodeWidth / 2 + 6" :y2="node._y + nodeHeight + 14" stroke="#409EFF" stroke-width="2" />
+                <line :x1="node._x + nodeWidth / 2" :y1="node._y + nodeHeight + 8" :x2="node._x + nodeWidth / 2" :y2="node._y + nodeHeight + 20" stroke="#409EFF" stroke-width="2" />
+              </g>
+              <g v-if="canvasNodes.length > 1" @click.stop="removeCanvasNode(node)" class="node-del-btn">
+                <circle :cx="node._x + nodeWidth" :cy="node._y" r="9" fill="#F56C6C" opacity="0.85" />
+                <line :x1="node._x + nodeWidth - 4" :y1="node._y - 4" :x2="node._x + nodeWidth + 4" :y2="node._y + 4" stroke="#fff" stroke-width="2" />
+                <line :x1="node._x + nodeWidth + 4" :y1="node._y - 4" :x2="node._x + nodeWidth - 4" :y2="node._y + 4" stroke="#fff" stroke-width="2" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+
+      <!-- 右侧:节点配置面板 -->
+      <div class="canvas-sidebar" >
+        <div class="sidebar-header">
+          <span class="sidebar-title">{{ selectedNode ? '节点配置' : '操作面板' }}</span>
+        </div>
+        <div class="sidebar-body">
+          <!-- 未选中节点时 -->
+          <div v-if="!selectedNode" class="sidebar-empty">
+            <el-alert title="操作提示" type="info" :closable="false" show-icon>
+              <div>点击 <strong>新增开始节点</strong> 创建起始节点</div>
+              <div>点击节点上的 <strong>+</strong> 按钮添加子节点</div>
+              <div>拖拽节点调整画布位置</div>
+              <div>点击节点进行配置</div>
+            </el-alert>
+          </div>
+
+          <!-- 选中节点时显示编辑表单 -->
+          <div v-else class="sidebar-form">
+            <el-form ref="nodeForm" :model="nodeForm" label-width="100px" size="small">
+              <el-form-item label="节点名称">
+                <el-input v-model="nodeForm.nodeName" placeholder="请输入节点名称" />
+              </el-form-item>
+              <el-form-item label="节点类型">
+                <el-select v-model="nodeForm.nodeType" placeholder="请选择节点类型" style="width:100%">
+                  <el-option v-for="dict in nodeTypeOptions" :key="dict.dictValue" :label="dict.dictLabel" :value="dict.dictValue" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="排序">
+                <el-input-number v-model="nodeForm.sortOrder" :min="0" />
+              </el-form-item>
+              <el-form-item label="上一节点">
+                <el-select v-model="nodeForm.prevNodeId" placeholder="请选择上一节点" clearable style="width:100%">
+                  <el-option v-for="node in templateNodes" :key="node.nodeId" :label="node.nodeName + ' (ID:' + node.nodeId + ')'" :value="node.nodeId" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="节点配置">
+                <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="8" placeholder="JSON格式配置" />
+              </el-form-item>
+              <el-form-item>
+                <el-button type="primary" icon="el-icon-check" @click="submitNodeForm">保存节点</el-button>
+                <el-button v-if="nodeForm.nodeId" type="warning" icon="el-icon-s-order" @click="handleNodeRulesFromSidebar">管理规则</el-button>
+              </el-form-item>
+              <el-form-item>
+                <el-button @click="selectedNode = null">取消</el-button>
+              </el-form-item>
+            </el-form>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 工作流模板查看对话框 -->
+    <el-dialog :title="'工作流模板 - 和泰cid加好友后流程'" :visible.sync="workflowTemplateOpen" width="900px" top="5vh" append-to-body>
+      <div class="workflow-template-body">
+        <iframe src="https://docs.qq.com/flowchart/DV1JobkhDT3BTcm9m" frameborder="0" class="workflow-iframe-large" sandbox="allow-scripts allow-same-origin allow-popups" />
+      </div>
+    </el-dialog>
+
+    <!-- 节点规则管理对话框 -->
+    <el-dialog :title="'管理规则 - ' + currentRuleNode.nodeName" :visible.sync="ruleDialogOpen" width="800px" append-to-body @opened="loadNodeRules">
+      <div class="rule-actions mb8">
+        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAddRule">新增规则</el-button>
+      </div>
+      <el-table :data="nodeRules" border>
+        <el-table-column label="排序" align="center" prop="sortOrder" width="60" />
+        <el-table-column label="规则名称" align="center" prop="ruleName" />
+        <el-table-column label="间隔天数" align="center" prop="sendDay" width="80" />
+        <el-table-column label="间隔时间" align="center" prop="sendTime" width="100" />
+        <el-table-column label="操作" align="center" width="120">
+          <template slot-scope="scope">
+            <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditRule(scope.row)">编辑</el-button>
+            <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteRule(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div v-if="ruleFormVisible" class="rule-form-panel" style="margin-top: 20px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 15px;">
+        <h4>{{ ruleFormTitle }}</h4>
+        <el-form ref="ruleForm" :model="ruleForm" label-width="100px" size="small">
+          <el-form-item label="规则名称"><el-input v-model="ruleForm.ruleName" placeholder="规则名称" /></el-form-item>
+          <el-form-item label="间隔时间">
+            <el-time-picker v-model="ruleForm.sendTime" value-format="HH:mm" format="HH:mm" :picker-options="{ selectableRange: ['00:00:00 - 23:59:59'] }" placeholder="选择时间" style="width:160px;" />
+          </el-form-item>
+          <el-form-item label="间隔天数"><el-input-number v-model="ruleForm.sendDay" :min="0" /></el-form-item>
+          <el-form-item label="发送内容"><el-input v-model="ruleForm.prompt" type="textarea" :rows="3" /></el-form-item>
+          <el-form-item label="条件">
+            <el-select v-model="ruleForm.rule" placeholder="请选择条件" style="width:160px;">
+              <el-option label="已回复消息" value="replied" />
+              <el-option label="未回复消息" value="unreplied" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="排序"><el-input-number v-model="ruleForm.sortOrder" :min="0" /></el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="submitRuleForm">保存规则</el-button>
+            <el-button @click="ruleFormVisible = false">取消</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getTemplate } from '../../api/aiAddwxSop/api'
+import { listTemplateNode, addTemplateNode, updateTemplateNode, delTemplateNode } from '../../api/aiAddwxSop/api'
+import { listTemplateNodeRule, addTemplateNodeRule, updateTemplateNodeRule, delTemplateNodeRule } from '../../api/aiAddwxSop/api'
+import { saveCanvasNodes } from '../../api/aiAddwxSop/api'
+
+let _canvasNodeCid = 0
+
+export default {
+  name: 'AiAddwxSopCanvas',
+  data() {
+    return {
+      templateId: null,
+      templateInfo: { templateName: '' },
+      templateNodes: [],
+      nodeTypeOptions: [],
+      // 画布
+      canvasNodes: [],
+      canvasWidth: 2000,
+      canvasHeight: 1500,
+      nodeWidth: 130,
+      nodeHeight: 56,
+      selectedNode: null,
+      draggingNode: null,
+      dragOffsetX: 0,
+      dragOffsetY: 0,
+      isDragging: false,
+      // 节点表单(嵌入右侧面板)
+      nodeForm: {},
+      // 规则
+      ruleDialogOpen: false,
+      currentRuleNode: {},
+      nodeRules: [],
+      ruleForm: {},
+      ruleFormVisible: false,
+      // 工作流模板
+      workflowTemplateOpen: false
+    }
+  },
+  computed: {
+    ruleFormTitle() {
+      return this.ruleForm.ruleId ? '编辑规则' : '新增规则'
+    },
+    canvasLines() {
+      const lines = []
+      this.canvasNodes.forEach(node => {
+        if (node._parentCid) {
+          const parent = this.canvasNodes.find(n => n._cid === node._parentCid)
+          if (parent) {
+            lines.push({
+              x1: parent._x + this.nodeWidth / 2,
+              y1: parent._y + this.nodeHeight,
+              x2: node._x + this.nodeWidth / 2,
+              y2: node._y
+            })
+          }
+        }
+      })
+      return lines
+    }
+  },
+  created() {
+    this.templateId = this.$route.query.templateId
+    if (!this.templateId) {
+      this.$message.error('缺少模板ID')
+      this.goBack()
+      return
+    }
+    this.getDicts('ai_add_wechat_sop_node').then(response => {
+      this.nodeTypeOptions = response.data
+    })
+    this.loadTemplateInfo()
+    this.loadCanvasNodes()
+  },
+  methods: {
+    goBack() {
+      this.$router.go(-1)
+    },
+    loadTemplateInfo() {
+      getTemplate(this.templateId).then(response => {
+        this.templateInfo = response.data || response || {}
+      })
+    },
+    loadCanvasNodes() {
+      listTemplateNode(this.templateId).then(response => {
+        const serverNodes = response.data || []
+        this.templateNodes = serverNodes
+        if (serverNodes.length === 0) {
+          this.canvasNodes = []
+          return
+        }
+        this.canvasNodes = serverNodes.map(node => ({
+          _cid: 'n_' + (++_canvasNodeCid),
+          _x: node.positionX != null ? node.positionX : 100,
+          _y: node.positionY != null ? node.positionY : 100,
+          _parentCid: null,
+          nodeId: node.nodeId,
+          templateId: node.templateId,
+          nodeKey: node.nodeKey,
+          nodeName: node.nodeName,
+          nodeType: node.nodeType,
+          prevNodeId: node.prevNodeId,
+          nextNodeId: node.nextNodeId,
+          nodeConfig: node.nodeConfig,
+          sortOrder: node.sortOrder
+        }))
+        this.canvasNodes.forEach(node => {
+          if (node.prevNodeId) {
+            const parent = this.canvasNodes.find(n => n.nodeId === node.prevNodeId)
+            if (parent) node._parentCid = parent._cid
+          }
+        })
+        const hasPositions = this.canvasNodes.some(n => n._x !== 100 || n._y !== 100)
+        if (!hasPositions) this.autoLayout()
+      })
+    },
+    autoLayout() {
+      if (this.canvasNodes.length === 0) return
+      const gapX = 200; const gapY = 140
+      const visited = new Set(); const queue = []
+      const roots = this.canvasNodes.filter(n => !n._parentCid)
+      roots.forEach((root, idx) => {
+        root._x = 120 + idx * gapX; root._y = 80
+        queue.push(root); visited.add(root._cid)
+      })
+      while (queue.length > 0) {
+        const parent = queue.shift()
+        const children = this.canvasNodes.filter(n => n._parentCid === parent._cid && !visited.has(n._cid))
+        children.forEach((child, idx) => {
+          child._x = parent._x + (idx - (children.length - 1) / 2) * gapX
+          child._y = parent._y + gapY
+          visited.add(child._cid); queue.push(child)
+        })
+      }
+    },
+    addStartNode() {
+      const cid = 'n_' + (++_canvasNodeCid)
+      this.canvasNodes.push({
+        _cid: cid, _x: (this.canvasWidth - this.nodeWidth) / 2, _y: 80, _parentCid: null,
+        nodeId: null, templateId: this.templateId, nodeKey: null,
+        nodeName: '开始', nodeType: 'receive', prevNodeId: null, nextNodeId: null,
+        nodeConfig: null, sortOrder: 1
+      })
+    },
+    addChildNode(parentNode) {
+      const cid = 'n_' + (++_canvasNodeCid)
+      const childCount = this.canvasNodes.filter(n => n._parentCid === parentNode._cid).length
+      this.canvasNodes.push({
+        _cid: cid, _x: parentNode._x + (childCount - 0.5) * 180, _y: parentNode._y + 140,
+        _parentCid: parentNode._cid,
+        nodeId: null, templateId: this.templateId, nodeKey: null,
+        nodeName: '新节点' + (this.canvasNodes.length + 1), nodeType: 'medication',
+        prevNodeId: parentNode.nodeId, nextNodeId: null, nodeConfig: null,
+        sortOrder: this.canvasNodes.length + 1
+      })
+      const newNode = this.canvasNodes[this.canvasNodes.length - 1]
+      this.selectedNode = newNode
+      this.$nextTick(() => this.fillNodeForm(newNode))
+    },
+    onNodeClick(node) {
+      if (this.isDragging) return
+      this.selectedNode = node
+      this.fillNodeForm(node)
+    },
+    fillNodeForm(canvasNode) {
+      this.nodeForm = {
+        nodeId: canvasNode.nodeId || null,
+        templateId: this.templateId,
+        nodeKey: canvasNode.nodeKey || null,
+        nodeName: canvasNode.nodeName,
+        nodeType: canvasNode.nodeType,
+        prevNodeId: canvasNode.prevNodeId,
+        nextNodeId: canvasNode.nextNodeId || null,
+        nodeConfig: canvasNode.nodeConfig || null,
+        sortOrder: canvasNode.sortOrder || 1,
+        positionX: canvasNode._x,
+        positionY: canvasNode._y
+      }
+    },
+    removeCanvasNode(canvasNode) {
+      this.$confirm('确认删除节点"' + (canvasNode.nodeName || '未命名') + '"及其子节点吗?', '警告', {
+        confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
+      }).then(() => {
+        const removeRecursive = (cid) => {
+          const children = this.canvasNodes.filter(n => n._parentCid === cid)
+          children.forEach(child => removeRecursive(child._cid))
+          this.canvasNodes = this.canvasNodes.filter(n => n._cid !== cid)
+        }
+        removeRecursive(canvasNode._cid)
+        if (this.selectedNode && this.selectedNode._cid === canvasNode._cid) {
+          this.selectedNode = null
+        }
+      }).catch(() => {})
+    },
+    // 拖拽
+    onCanvasMouseDown() { this.selectedNode = null },
+    onNodeMouseDown(event, node) {
+      this.draggingNode = node
+      const rect = this.$refs.canvasContainer.getBoundingClientRect()
+      this.dragOffsetX = event.clientX - rect.left + this.$refs.canvasContainer.scrollLeft - node._x
+      this.dragOffsetY = event.clientY - rect.top + this.$refs.canvasContainer.scrollTop - node._y
+      this.isDragging = false
+      event.preventDefault()
+    },
+    onCanvasMouseMove(event) {
+      if (!this.draggingNode) return
+      this.isDragging = true
+      const rect = this.$refs.canvasContainer.getBoundingClientRect()
+      this.draggingNode._x = Math.max(0, event.clientX - rect.left + this.$refs.canvasContainer.scrollLeft - this.dragOffsetX)
+      this.draggingNode._y = Math.max(0, event.clientY - rect.top + this.$refs.canvasContainer.scrollTop - this.dragOffsetY)
+      // 同步更新表单坐标
+      if (this.selectedNode && this.selectedNode._cid === this.draggingNode._cid) {
+        this.nodeForm.positionX = Math.round(this.draggingNode._x)
+        this.nodeForm.positionY = Math.round(this.draggingNode._y)
+      }
+    },
+    onCanvasMouseUp() { this.draggingNode = null; setTimeout(() => { this.isDragging = false }, 50) },
+    resetCanvas() {
+      this.$confirm('确认重置画布?所有未保存的修改将丢失。', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+        .then(() => { this.loadCanvasNodes(); this.selectedNode = null }).catch(() => {})
+    },
+    saveCanvasNodes() {
+      if (this.canvasNodes.length === 0) { this.$message.warning('请先添加节点'); return }
+      this.canvasNodes.forEach(node => {
+        if (node._parentCid) {
+          const parent = this.canvasNodes.find(n => n._cid === node._parentCid)
+          if (parent && parent.nodeId) node.prevNodeId = parent.nodeId
+        }
+      })
+      const payload = this.canvasNodes.map(node => ({
+        templateId: this.templateId,
+        nodeId: node.nodeId,
+        nodeKey: node.nodeKey || (this.templateInfo.templateName + '_' + (node.nodeType || 'node') + '_' + Date.now()),
+        nodeName: node.nodeName,
+        nodeType: node.nodeType || 'receive',
+        prevNodeId: node.prevNodeId,
+        nextNodeId: node.nextNodeId,
+        nodeConfig: node.nodeConfig,
+        sortOrder: node.sortOrder || 1,
+        positionX: Math.round(node._x),
+        positionY: Math.round(node._y)
+      }))
+      saveCanvasNodes(payload).then(() => { this.msgSuccess('画布保存成功'); this.loadCanvasNodes() })
+    },
+    // 保存单个节点
+    submitNodeForm() {
+      if (this.nodeForm.nodeId) {
+        updateTemplateNode(this.nodeForm).then(() => {
+          this.msgSuccess('修改成功')
+          this.syncCanvasFromForm()
+          this.loadCanvasNodes()
+        }).catch(() => {})
+      } else {
+        const tName = (this.templateInfo.templateName || 'node').replace(/\s+/g, '_')
+        this.nodeForm.nodeKey = tName + '_' + (this.nodeForm.nodeType || 'unknown') + '_' + Date.now()
+        addTemplateNode(this.nodeForm).then(response => {
+          this.msgSuccess('新增成功')
+          const newNodeId = response.data || response
+          const cn = this.canvasNodes.find(n => n._cid === this.selectedNode._cid)
+          if (cn) cn.nodeId = newNodeId
+          this.loadCanvasNodes()
+        }).catch(() => {})
+      }
+    },
+    syncCanvasFromForm() {
+      if (!this.selectedNode) return
+      this.selectedNode.nodeName = this.nodeForm.nodeName
+      this.selectedNode.nodeType = this.nodeForm.nodeType
+      this.selectedNode.nodeConfig = this.nodeForm.nodeConfig
+      this.selectedNode.sortOrder = this.nodeForm.sortOrder
+      this.selectedNode.prevNodeId = this.nodeForm.prevNodeId
+      if (this.nodeForm.positionX != null) this.selectedNode._x = this.nodeForm.positionX
+      if (this.nodeForm.positionY != null) this.selectedNode._y = this.nodeForm.positionY
+    },
+    getNodeColor(type) {
+      if (!type) return '#409EFF'
+      // 按节点类型分组颜色(数字码 1-11)
+      const groupMap = {
+        '1': '#409EFF',   // 用户加微
+        '2': '#67C23A',   // 到货
+        '3': '#67C23A',   // 未到货
+        '4': '#E6A23C',   // 使用
+        '5': '#E6A23C',   // 未使用
+        '6': '#E6A23C',   // 断药
+        '7': '#909399',   // 反馈结果1
+        '8': '#909399',   // 反馈结果2
+        '9': '#909399',   // 攻单/推荐转人工
+        '10': '#F56C6C',  // 成交
+        '11': '#F56C6C'   // 未成交
+      }
+      if (groupMap[type]) return groupMap[type]
+      // 兜底英文码映射
+      const fallback = { 'receive': '#67C23A', 'medication': '#E6A23C', 'effect_feedback': '#909399', 'transfer_ai': '#F56C6C', 'start': '#409EFF' }
+      return fallback[type] || '#409EFF'
+    },
+    getNodeTypeName(type) {
+      if (!type) return '未知'
+      const d = this.nodeTypeOptions.find(d => d.dictValue === type)
+      if (d) return d.dictLabel
+      const f = { 'receive': '收货节点', 'medication': '用药节点', 'effect_feedback': '效果反馈', 'transfer_ai': '转AI节点', 'start': '开始' }
+      return f[type] || type
+    },
+    showWorkflowTemplate() { this.workflowTemplateOpen = true },
+    // 规则管理
+    handleNodeRulesFromSidebar() {
+      this.currentRuleNode = { nodeId: this.nodeForm.nodeId, nodeName: this.nodeForm.nodeName }
+      this.ruleDialogOpen = true
+      this.ruleForm = {}
+      this.ruleFormVisible = false
+    },
+    loadNodeRules() {
+      listTemplateNodeRule(this.currentRuleNode.nodeId).then(response => { this.nodeRules = response.data || [] })
+    },
+    handleAddRule() {
+      this.ruleForm = { nodeId: this.currentRuleNode.nodeId, msgType: 1, sortOrder: this.nodeRules.length + 1 }
+      this.ruleFormVisible = true
+    },
+    handleEditRule(row) {
+      this.ruleForm = { ...row }
+      if (row.contentJson) {
+        try {
+          const p = JSON.parse(row.contentJson)
+          this.ruleForm.prompt = p.prompt || ''; this.ruleForm.rule = p.rule || ''
+        } catch (e) { this.ruleForm.prompt = ''; this.ruleForm.rule = '' }
+      }
+      this.ruleFormVisible = true
+    },
+    handleDeleteRule(row) {
+      this.$confirm('确认删除规则"' + row.ruleName + '"吗?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
+        .then(() => delTemplateNodeRule(row.ruleId)).then(() => { this.loadNodeRules(); this.msgSuccess('删除成功') })
+    },
+    submitRuleForm() {
+      this.ruleForm.contentJson = JSON.stringify({ prompt: this.ruleForm.prompt || '', rule: this.ruleForm.rule || '' })
+      if (this.ruleForm.ruleId) {
+        updateTemplateNodeRule(this.ruleForm).then(() => { this.msgSuccess('修改成功'); this.loadNodeRules(); this.ruleForm = {}; this.ruleFormVisible = false })
+      } else {
+        addTemplateNodeRule(this.ruleForm).then(() => { this.msgSuccess('新增成功'); this.loadNodeRules(); this.ruleForm = {}; this.ruleFormVisible = false })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.canvas-page {
+  height: calc(100vh - 84px);
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+.canvas-topbar {
+  padding: 10px 20px;
+  border-bottom: 1px solid #ebeef5;
+  background: #fafafa;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+}
+.topbar-title { font-size: 15px; font-weight: 600; color: #303133; }
+.topbar-actions { margin-left: auto; display: flex; gap: 8px; }
+.canvas-layout { flex: 1; display: flex; overflow: hidden; }
+.canvas-main { flex: 1; overflow: hidden; min-width: 0; }
+.canvas-container {
+  width: 100%; height: 100%; overflow: auto;
+  background: #f9fafb;
+  background-image: radial-gradient(circle, #dcdfe6 1px, transparent 1px);
+  background-size: 20px 20px;
+  cursor: default;
+}
+.canvas-svg { display: block; min-width: 100%; min-height: 100%; }
+.node-add-btn { cursor: pointer; opacity: 0.7; transition: opacity 0.2s; }
+.node-add-btn:hover { opacity: 1; }
+.node-del-btn { cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
+.node-del-btn:hover { opacity: 1; }
+
+.canvas-sidebar {
+  width: 350px; flex-shrink: 0; border-left: 1px solid #dcdfe6;
+  display: flex; flex-direction: column; overflow: hidden; background: #fff;
+}
+.sidebar-header {
+  padding: 12px 15px; border-bottom: 1px solid #ebeef5; background: #fafafa;
+  display: flex; align-items: center; justify-content: space-between;
+}
+.sidebar-title { font-size: 14px; font-weight: 600; color: #303133; }
+.sidebar-body { flex: 1; overflow-y: auto; padding: 15px; }
+.sidebar-empty { padding-top: 10px; }
+.sidebar-form { /* form inside sidebar */ }
+
+.workflow-template-body { height: 70vh; overflow: hidden; }
+.workflow-iframe-large { width: 100%; height: 100%; border: none; }
+</style>

+ 22 - 11
src/views/aiAddwxSop/tagTemplateBind.vue

@@ -44,8 +44,8 @@
     <!-- 新增/修改绑定对话框 -->
     <el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
       <el-form ref="form" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="企微标签" prop="qwTagId">
-          <el-select v-model="form.qwTagId" filterable placeholder="请选择企微标签" style="width: 100%;" @change="onTagChange">
+        <el-form-item label="企微标签" prop="qwTagIds">
+          <el-select v-model="form.qwTagIds" multiple filterable placeholder="请选择企微标签(可多选)" style="width: 100%;" @change="onTagChange">
             <el-option v-for="tag in tagList" :key="tag.id" :label="tag.name + ' (' + tag.tagId + ')'" :value="tag.id" />
           </el-select>
         </el-form-item>
@@ -92,7 +92,7 @@ export default {
       },
       form: {},
       rules: {
-        qwTagId: [{ required: true, message: '请选择企微标签', trigger: 'change' }],
+        qwTagIds: [{ required: true, message: '请选择企微标签', trigger: 'change' }],
         templateId: [{ required: true, message: '请选择工作流模板', trigger: 'change' }]
       }
     }
@@ -121,13 +121,16 @@ export default {
         this.templateList = response.rows || []
       })
     },
-    onTagChange(val) {
-      const tag = this.tagList.find(t => t.id === val)
-      // console.log(tag)
-      if (tag) {
-        this.form.qwTagName = tag.name
+    onTagChange(vals) {
+      if (vals && vals.length > 0) {
+        const names = vals.map(id => {
+          const tag = this.tagList.find(t => t.id === id)
+          return tag ? tag.name : ''
+        }).filter(n => n)
+        this.form.qwTagName = names.join('、')
+      } else {
+        this.form.qwTagName = ''
       }
-
     },
     onTemplateChange(val) {
       const tpl = this.templateList.find(t => t.templateId === val)
@@ -161,7 +164,7 @@ export default {
     },
     handleUpdate(row) {
       this.reset()
-      this.form = { ...row }
+      this.form = { ...row, qwTagIds: row.qwTagId ? [row.qwTagId] : [] }
       this.open = true
       this.title = '修改标签模板绑定'
     },
@@ -181,12 +184,20 @@ export default {
       this.$refs.form.validate(valid => {
         if (valid) {
           if (this.form.bindId != null) {
-            updateTagTemplateBind(this.form).then(() => {
+            // 编辑:单标签
+            const data = { ...this.form }
+            if (data.qwTagIds && data.qwTagIds.length > 0) {
+              data.qwTagId = data.qwTagIds[0]
+              const tag = this.tagList.find(t => t.id === data.qwTagId)
+              data.qwTagName = tag ? tag.name : ''
+            }
+            updateTagTemplateBind(data).then(() => {
               this.$message.success('修改成功')
               this.open = false
               this.getList()
             })
           } else {
+            // 新增:支持多标签批量绑定
             addTagTemplateBind(this.form).then(() => {
               this.$message.success('新增成功')
               this.open = false

+ 2 - 302
src/views/aiAddwxSop/template.vue

@@ -70,136 +70,11 @@
         <el-button type="primary" @click="submitForm">确 定</el-button>
       </div>
     </el-dialog>
-
-    <!-- 节点设计对话框 -->
-    <el-dialog :title="'节点设计 - ' + currentTemplate.templateName" :visible.sync="nodeDesignOpen" width="1100px" append-to-body @opened="loadTemplateNodes">
-      <div class="node-panel">
-        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAddNode">添加节点</el-button>
-        <el-table :data="templateNodes" style="margin-top: 10px;" row-key="nodeId">
-          <el-table-column label="节点ID" align="center" prop="nodeId" width="80" />
-          <el-table-column label="排序" align="center" prop="sortOrder" width="60" />
-          <el-table-column label="节点名称" align="center" prop="nodeName" />
-          <el-table-column label="节点类型" align="center" prop="nodeType" width="120">
-            <template slot-scope="scope">
-              <el-tag :type="getNodeTypeTag(scope.row.nodeType)">{{ getNodeTypeName(scope.row.nodeType) }}</el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column label="上一节点" align="center" width="140">
-            <template slot-scope="scope">
-              <span v-if="scope.row.prevNodeId">{{ getPrevNodeLabel(scope.row.prevNodeId) }}</span>
-              <span v-else>-</span>
-            </template>
-          </el-table-column>
-          <el-table-column label="操作" align="center" width="180">
-            <template slot-scope="scope">
-              <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditNode(scope.row)">编辑</el-button>
-              <el-button size="mini" type="text" icon="el-icon-s-order" @click="handleNodeRules(scope.row)">管理规则</el-button>
-              <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteNode(scope.row)">删除</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-      </div>
-    </el-dialog>
-
-    <!-- 新增/编辑节点对话框 -->
-    <el-dialog :title="nodeFormTitle" :visible.sync="nodeFormOpen" width="550px" append-to-body>
-      <el-form ref="nodeForm" :model="nodeForm" label-width="100px" size="small">
-        <el-form-item label="节点名称" prop="nodeName">
-          <el-input v-model="nodeForm.nodeName" placeholder="请输入节点名称" />
-        </el-form-item>
-        <el-form-item label="节点类型" prop="nodeType">
-          <el-select v-model="nodeForm.nodeType" placeholder="请选择节点类型">
-            <el-option v-for="dict in nodeTypeOptions" :key="dict.dictValue" :label="dict.dictLabel" :value="dict.dictValue" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="排序" prop="sortOrder">
-          <el-input-number v-model="nodeForm.sortOrder" :min="0" />
-        </el-form-item>
-        <el-form-item label="上一节点" prop="prevNodeId">
-          <el-select v-model="nodeForm.prevNodeId" placeholder="请选择上一节点" clearable>
-            <el-option v-for="node in templateNodes" :key="node.nodeId" :label="node.nodeName + ' (ID:' + node.nodeId + ')'" :value="node.nodeId" />
-          </el-select>
-        </el-form-item>
-        <!-- <el-form-item label="下一节点ID" prop="nextNodeId">
-          <el-input-number v-model="nodeForm.nextNodeId" :min="0" placeholder="可选" />
-        </el-form-item> -->
-        <el-form-item label="节点配置" prop="nodeConfig">
-          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="4" placeholder="JSON格式配置" />
-        </el-form-item>
-      </el-form>
-      <div slot="footer" class="dialog-footer">
-        <el-button @click="nodeFormOpen = false">取 消</el-button>
-        <el-button type="primary" @click="submitNodeForm">保存节点</el-button>
-      </div>
-    </el-dialog>
-
-    <!-- 节点规则管理对话框 -->
-    <el-dialog :title="'管理规则 - ' + currentRuleNode.nodeName" :visible.sync="ruleDialogOpen" width="800px" append-to-body @opened="loadNodeRules">
-      <div class="rule-actions mb8">
-        <el-button type="primary" size="small" icon="el-icon-plus" @click="handleAddRule">新增规则</el-button>
-      </div>
-      <el-table :data="nodeRules" border>
-        <el-table-column label="排序" align="center" prop="sortOrder" width="60" />
-        <el-table-column label="规则名称" align="center" prop="ruleName" />
-        <el-table-column label="间隔天数" align="center" prop="sendDay" width="80" />
-        <el-table-column label="间隔时间" align="center" prop="sendTime" width="100" />
-        <el-table-column label="操作" align="center" width="120">
-          <template slot-scope="scope">
-            <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditRule(scope.row)">编辑</el-button>
-            <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDeleteRule(scope.row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-
-      <!-- 规则编辑区域 -->
-      <div v-if="ruleFormVisible" class="rule-form-panel" style="margin-top: 20px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 15px;">
-        <h4>{{ ruleFormTitle }}</h4>
-        <el-form ref="ruleForm" :model="ruleForm" label-width="100px" size="small">
-          <el-form-item label="规则名称" prop="ruleName">
-            <el-input v-model="ruleForm.ruleName" placeholder="规则名称,仅内部可见" />
-          </el-form-item>
-          <el-form-item label="间隔时间" prop="sendTime">
-            <el-time-picker
-              v-model="ruleForm.sendTime"
-              value-format="HH:mm"
-              format="HH:mm"
-              :picker-options="{ selectableRange: ['00:00:00 - 23:59:59'] }"
-              placeholder="选择时间"
-              style="width: 160px;">
-            </el-time-picker>
-          </el-form-item>
-          <el-form-item label="间隔天数" prop="sendDay">
-            <el-input-number v-model="ruleForm.sendDay" :min="0" placeholder="第几天" />
-          </el-form-item>
-          <!-- <el-form-item label="内容类别" prop="contentType">
-            <el-input v-model="ruleForm.contentType" placeholder="请输入内容类别" />
-          </el-form-item> -->
-          <el-form-item label="发送提示词" prop="prompt">
-            <el-input v-model="ruleForm.prompt" type="textarea" :rows="3" placeholder="发送提示词" />
-          </el-form-item>
-          <el-form-item label="条件" prop="rule">
-            <el-select v-model="ruleForm.rule" placeholder="请选择条件" style="width: 30%;">
-              <el-option label="已回复消息" value="replied" />
-              <el-option label="未回复消息" value="unreplied" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="排序" prop="sortOrder">
-            <el-input-number v-model="ruleForm.sortOrder" :min="0" />
-          </el-form-item>
-          <el-form-item>
-            <el-button type="primary" @click="submitRuleForm">保存规则</el-button>
-            <el-button @click="ruleFormVisible = false">取消</el-button>
-          </el-form-item>
-        </el-form>
-      </div>
-    </el-dialog>
   </div>
 </template>
 
 <script>
 import { listTemplate, getTemplate, addTemplate, updateTemplate, delTemplate } from '../../api/aiAddwxSop/api'
-import { listTemplateNode, addTemplateNode, updateTemplateNode, delTemplateNode } from '../../api/aiAddwxSop/api'
-import { listTemplateNodeRule, addTemplateNodeRule, updateTemplateNodeRule, delTemplateNodeRule } from '../../api/aiAddwxSop/api'
 
 export default {
   name: 'AiAddwxSopTemplate',
@@ -209,19 +84,9 @@ export default {
       showSearch: true,
       total: 0,
       templateList: [],
-      templateNodes: [],
       ids: [],
       title: '',
       open: false,
-      nodeDesignOpen: false,
-      nodeFormOpen: false,
-      currentTemplate: {},
-      ruleDialogOpen: false,
-      currentRuleNode: {},
-      nodeTypeOptions: [],
-      nodeRules: [],
-      ruleForm: {},
-      ruleFormVisible: false,
       queryParams: {
         pageNum: 1,
         pageSize: 10,
@@ -229,7 +94,6 @@ export default {
         status: null
       },
       form: {},
-      nodeForm: {},
       rules: {
         templateName: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }]
       },
@@ -239,18 +103,7 @@ export default {
       ]
     }
   },
-  computed: {
-    nodeFormTitle() {
-      return this.nodeForm.nodeId ? '编辑节点' : '新增节点'
-    },
-    ruleFormTitle() {
-      return this.ruleForm.ruleId ? '编辑规则' : '新增规则'
-    }
-  },
   created() {
-    this.getDicts('ai_add_wechat_sop_node').then(response => {
-      this.nodeTypeOptions = response.data
-    })
     this.getList()
   },
   methods: {
@@ -325,166 +178,13 @@ export default {
         }
       })
     },
-    // 节点设计
+    // 节点设计 - 跳转到画布页面
     handleNodeDesign(row) {
-      this.currentTemplate = row
-      this.nodeDesignOpen = true
-      this.nodeForm = {}
-      this.nodeForm.templateId = row.templateId
-    },
-    loadTemplateNodes() {
-      listTemplateNode(this.currentTemplate.templateId).then(response => {
-        this.templateNodes = response.data || []
-      })
-    },
-    getNodeTypeName(type) {
-      const dict = this.nodeTypeOptions.find(d => d.dictValue === type)
-      return dict ? dict.dictLabel : type
-    },
-    getNodeTypeTag(type) {
-      const map = {
-        'receive': 'success',
-        'medication': 'warning',
-        'effect_feedback': 'info',
-        'transfer_ai': 'danger'
-      }
-      return map[type] || ''
-    },
-    getPrevNodeLabel(prevNodeId) {
-      const node = this.templateNodes.find(n => n.nodeId === prevNodeId)
-      return node ? node.nodeName + ' (ID:' + node.nodeId + ')' : 'ID:' + prevNodeId
-    },
-    handleAddNode() {
-      this.nodeForm = {
-        templateId: this.currentTemplate.templateId,
-        sortOrder: this.templateNodes.length + 1
-      }
-      this.nodeFormOpen = true
-    },
-    handleEditNode(row) {
-      this.nodeForm = { ...row, templateId: this.currentTemplate.templateId }
-      this.nodeFormOpen = true
-    },
-    handleDeleteNode(row) {
-      this.$confirm('确认删除节点"' + row.nodeName + '"吗?', '警告', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        return delTemplateNode(row.nodeId)
-      }).then(() => {
-        this.loadTemplateNodes()
-        this.msgSuccess('删除成功')
-      })
-    },
-    submitNodeForm() {
-      if (this.nodeForm.nodeId) {
-        updateTemplateNode(this.nodeForm).then(() => {
-          this.msgSuccess('修改成功')
-          this.loadTemplateNodes()
-          this.nodeForm = {}
-          this.nodeFormOpen = false
-        })
-      } else {
-        const templateName = (this.currentTemplate.templateName || 'node').replace(/\s+/g, '_')
-        const nodeType = this.nodeForm.nodeType || 'unknown'
-        const timestamp = Date.now()
-        this.nodeForm.nodeKey = templateName + '_' + nodeType + '_' + timestamp
-        addTemplateNode(this.nodeForm).then(() => {
-          this.msgSuccess('新增成功')
-          this.loadTemplateNodes()
-          this.nodeForm = {}
-          this.nodeFormOpen = false
-        })
-      }
-    },
-    // 节点规则管理
-    handleNodeRules(row) {
-      this.currentRuleNode = row
-      this.ruleDialogOpen = true
-      this.ruleForm = {}
-      this.ruleFormVisible = false
-    },
-    loadNodeRules() {
-      listTemplateNodeRule(this.currentRuleNode.nodeId).then(response => {
-        this.nodeRules = response.data || []
-      })
-    },
-    getMsgTypeName(type) {
-      const map = { 1: '普通', 2: '课程', 4: 'AI触达', 5: '打标签', 20: '直播间' }
-      return map[type] || type
-    },
-    getMsgTypeTag(type) {
-      const map = { 1: '', 2: 'success', 4: 'warning', 5: 'danger', 20: 'info' }
-      return map[type] || ''
-    },
-    handleAddRule() {
-      this.ruleForm = {
-        nodeId: this.currentRuleNode.nodeId,
-        msgType: 1,
-        sortOrder: this.nodeRules.length + 1
-      }
-      this.ruleFormVisible = true
-    },
-    handleEditRule(row) {
-      this.ruleForm = { ...row }
-      if (row.contentJson) {
-        try {
-          const parsed = JSON.parse(row.contentJson)
-          this.ruleForm.prompt = parsed.prompt || ''
-          this.ruleForm.rule = parsed.rule || ''
-        } catch (e) {
-          this.ruleForm.prompt = ''
-          this.ruleForm.rule = ''
-        }
-      }
-      this.ruleFormVisible = true
-    },
-    handleDeleteRule(row) {
-      this.$confirm('确认删除规则"' + row.ruleName + '"吗?', '警告', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
-        return delTemplateNodeRule(row.ruleId)
-      }).then(() => {
-        this.loadNodeRules()
-        this.msgSuccess('删除成功')
-      })
-    },
-    submitRuleForm() {
-      this.ruleForm.contentJson = JSON.stringify({
-        prompt: this.ruleForm.prompt || '',
-        rule: this.ruleForm.rule || ''
-      })
-      if (this.ruleForm.ruleId) {
-        updateTemplateNodeRule(this.ruleForm).then(() => {
-          this.msgSuccess('修改成功')
-          this.loadNodeRules()
-          this.ruleForm = {}
-          this.ruleFormVisible = false
-        })
-      } else {
-        addTemplateNodeRule(this.ruleForm).then(() => {
-          this.msgSuccess('新增成功')
-          this.loadNodeRules()
-          this.ruleForm = {}
-          this.ruleFormVisible = false
-        })
-      }
+      this.$router.push({ path: '/aiAddwxSop/canvas', query: { templateId: row.templateId } })
     }
   }
 }
 </script>
 
 <style scoped>
-.node-panel {
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  padding: 15px;
-  min-height: 300px;
-}
-.node-panel h4 {
-  margin: 0 0 10px 0;
-}
 </style>

+ 1 - 1
src/views/aiAddwxSop/userWorkflow.vue

@@ -16,7 +16,7 @@
     <!-- 操作按钮 -->
     <el-row :gutter="10" class="mb8">
       <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-plus" @click="handleCreateDialog">从模板创建</el-button>
+        <!-- <el-button type="primary" plain icon="el-icon-plus" @click="handleCreateDialog">从模板创建</el-button> -->
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
     </el-row>

+ 29 - 2
src/views/company/companyUser/index.vue

@@ -180,9 +180,12 @@
                     <span v-else style="color: #67C23A;">客户语音销售回复语音</span>
                 </template>
             </el-table-column>
-          <el-table-column label="绑定的企微号" align="center">
+          <el-table-column label="绑定的企微号" align="center" width="170">
             <template slot-scope="scope">
-              <div v-if="scope.row.qwUsers && scope.row.qwUsers.length > 0">
+              <div
+                v-if="scope.row.qwUsers && scope.row.qwUsers.length > 0"
+                class="qw-users-scroll"
+              >
                 <div v-for="user in scope.row.qwUsers" :key="user.id">
                   <el-tag size="mini">{{ user.qwUserName }}</el-tag>
                 </div>
@@ -221,6 +224,8 @@
                 <template slot-scope="scope">
                     <el-tag
                         :type="scope.row.isRecordVoiceSeat === 1 ? 'success' : 'info'">{{scope.row.isRecordVoiceSeat === 1 ? '是' : '否' }}</el-tag>
+                    <br>
+                    已录次数({{scope.row.times}})
                 </template>
             </el-table-column>
 
@@ -2331,4 +2336,26 @@ export default {
         text-align: right;
     }
 }
+
+.qw-users-scroll {
+  max-height: 180px;
+  overflow-y: auto;
+  padding-right: 4px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.qw-users-scroll::-webkit-scrollbar {
+  width: 4px;
+}
+
+.qw-users-scroll::-webkit-scrollbar-thumb {
+  background: #c0c4cc;
+  border-radius: 2px;
+}
+
+.qw-users-scroll::-webkit-scrollbar-track {
+  background: transparent;
+}
 </style>

+ 28 - 1
src/views/qw/sop/deptSop.vue

@@ -274,6 +274,13 @@
             v-hasPermi="['qw:sop:list']"
           >执行详情
           </el-button>
+          <el-button
+                v-if="(scope.row.status==2 || scope.row.sendType==4  ||  scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4)"
+                size="mini"
+                type="text"
+                style="color: green;"
+                @click="handleSipScheduleDetail(scope.row)"
+          >sip执行计划</el-button>
           <el-button
             v-if="scope.row.status==2 || scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4  "
             size="mini"
@@ -884,6 +891,11 @@
       </div>
     </el-dialog>
 
+      <!--  SIP执行计划  -->
+    <el-drawer :title="sipSopLogsDialog.title" :visible.sync="sipSopLogsDialog.open" size="85%" style="font-weight: bolder">
+          <sipSopLogsDetails ref="sipSopLogsDetails" :rowDetailFrom="sipSopLogsDialog.sopLogsForm"></sipSopLogsDetails>
+    </el-drawer>
+
   </div>
 </template>
 
@@ -913,10 +925,11 @@ import {getMyQwCompanyList} from "@/api/qw/user";
 import {allList} from "@/api/qw/groupChat";
 import SendMsgSopOpenTool from '@/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue'
 import {getQwDeptUserList} from "../../../api/company/companyUser";
+import sipSopLogsDetails from '@/views/qw/sopLogs/sipSopLogsList.vue'
 
 export default {
   name: "Sop",
-  components: {CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool},
+  components: { sipSopLogsDetails, CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool},
   data() {
     return {
       projectFrom:process.env.VUE_APP_PROJECT_FROM,
@@ -1039,6 +1052,11 @@ export default {
         open: false,
         sopLogsForm: [],
       },
+      sipSopLogsDialog: {
+        title: '',
+        open: false,
+        sopLogsForm: [],
+      },
       // 表单参数
       form: {
         autoSopTime: {autoSopType: 2, autoStartTime: '00:00', autoEndTime: '24:00', autoSopSend: 2},
@@ -1558,6 +1576,15 @@ export default {
       this.$set(this.sopLogsDialog.sopLogsForm, 'filterSopType', 3);
     },
 
+      /**
+       * 查看营期内详情
+       */
+    handleSipScheduleDetail(row) {
+          this.sipSopLogsDialog.title = 'SIP执行计划';
+          this.sipSopLogsDialog.open = true;
+          this.sipSopLogsDialog.sopLogsForm = row;
+    },
+
     handleAvatarSuccessFile(res, file, item) {
       if (res.code === 200) {
         // 使用 $set 确保响应式更新

+ 28 - 1
src/views/qw/sop/mySop.vue

@@ -274,6 +274,13 @@
             v-hasPermi="['qw:sop:list']"
           >执行详情
           </el-button>
+          <el-button
+                v-if="(scope.row.status==2 || scope.row.sendType==4  ||  scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4)"
+                size="mini"
+                type="text"
+                style="color: green;"
+                @click="handleSipScheduleDetail(scope.row)"
+          >sip执行计划</el-button>
           <el-button
             v-if="scope.row.status==2 || scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4  "
             size="mini"
@@ -883,6 +890,10 @@
         <el-button @click="cancelMsgForm">取 消</el-button>
       </div>
     </el-dialog>
+      <!--  SIP执行计划  -->
+      <el-drawer :title="sipSopLogsDialog.title" :visible.sync="sipSopLogsDialog.open" size="85%" style="font-weight: bolder">
+          <sipSopLogsDetails ref="sipSopLogsDetails" :rowDetailFrom="sipSopLogsDialog.sopLogsForm"></sipSopLogsDetails>
+      </el-drawer>
 
   </div>
 </template>
@@ -912,10 +923,11 @@ import {listTag,} from "@/api/qw/tag";
 import {getMyQwCompanyList} from "@/api/qw/user";
 import {allList} from "@/api/qw/groupChat";
 import SendMsgSopOpenTool from '@/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue'
+import sipSopLogsDetails from '@/views/qw/sopLogs/sipSopLogsList.vue'
 
 export default {
   name: "Sop",
-  components: {CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool},
+  components: { sipSopLogsDetails, CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool},
   data() {
     return {
       projectFrom:process.env.VUE_APP_PROJECT_FROM,
@@ -1038,6 +1050,13 @@ export default {
         open: false,
         sopLogsForm: [],
       },
+
+      sipSopLogsDialog: {
+            title: '',
+            open: false,
+            sopLogsForm: [],
+      },
+
       // 表单参数
       form: {
         autoSopTime: {autoSopType: 2, autoStartTime: '00:00', autoEndTime: '24:00', autoSopSend: 2},
@@ -1556,6 +1575,14 @@ export default {
       this.sopLogsDialog.sopLogsForm = row;
       this.$set(this.sopLogsDialog.sopLogsForm, 'filterSopType', 2);
     },
+      /**
+       * 查看营期内详情
+       */
+    handleSipScheduleDetail(row) {
+      this.sipSopLogsDialog.title = 'SIP执行计划';
+      this.sipSopLogsDialog.open = true;
+      this.sipSopLogsDialog.sopLogsForm = row;
+    },
 
     handleAvatarSuccessFile(res, file, item) {
       if (res.code === 200) {

+ 28 - 1
src/views/qw/sop/sop.vue

@@ -274,6 +274,13 @@
             v-hasPermi="['qw:sop:list']"
           >执行详情
           </el-button>
+          <el-button
+                v-if="(scope.row.status==2 || scope.row.sendType==4  ||  scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4)"
+                size="mini"
+                type="text"
+                style="color: green;"
+                @click="handleSipScheduleDetail(scope.row)"
+          >sip执行计划</el-button>
           <el-button
             v-if="scope.row.status==2 || scope.row.status==0 || scope.row.status == 3 || scope.row.status == 4  "
             size="mini"
@@ -925,6 +932,10 @@
         <el-button @click="cancelMsgForm">取 消</el-button>
       </div>
     </el-dialog>
+      <!--  SIP执行计划  -->
+      <el-drawer :title="sipSopLogsDialog.title" :visible.sync="sipSopLogsDialog.open" size="85%" style="font-weight: bolder">
+          <sipSopLogsDetails ref="sipSopLogsDetails" :rowDetailFrom="sipSopLogsDialog.sopLogsForm"></sipSopLogsDetails>
+      </el-drawer>
 
   </div>
 </template>
@@ -955,10 +966,11 @@ import {getMyQwCompanyList} from "@/api/qw/user";
 import {allList} from "@/api/qw/groupChat";
 import SendMsgSopOpenTool from '@/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue'
 import {listToLiveNoEnd} from "@/api/live/live";
+import sipSopLogsDetails from '@/views/qw/sopLogs/sipSopLogsList.vue'
 
 export default {
   name: "Sop",
-  components: {CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool},
+  components: {CustomerGroupDetails, qwUserList, ImageUpload, sopLogsDetails, SendMsgSopOpenTool,sipSopLogsDetails},
   data() {
     return {
       projectFrom:process.env.VUE_APP_PROJECT_FROM,
@@ -1081,6 +1093,12 @@ export default {
         open: false,
         sopLogsForm: [],
       },
+
+      sipSopLogsDialog: {
+            title: '',
+            open: false,
+            sopLogsForm: [],
+      },
       // 表单参数
       form: {
         autoSopTime: {autoSopType: 2, autoStartTime: '00:00', autoEndTime: '24:00', autoSopSend: 2},
@@ -1608,6 +1626,15 @@ export default {
       this.$set(this.sopLogsDialog.sopLogsForm, 'filterSopType', 1);
     },
 
+    /**
+       * 查看营期内详情
+       */
+    handleSipScheduleDetail(row) {
+          this.sipSopLogsDialog.title = 'SIP执行计划';
+          this.sipSopLogsDialog.open = true;
+          this.sipSopLogsDialog.sopLogsForm = row;
+    },
+
     handleAvatarSuccessFile(res, file, item) {
       if (res.code === 200) {
         // 使用 $set 确保响应式更新

+ 1194 - 0
src/views/qw/sopLogs/sipSopLogsList.vue

@@ -0,0 +1,1194 @@
+<template>
+  <!-- SIP执行计划 -->
+  <div class="app-container">
+    <!-- 执行时间说明 -->
+    <el-alert
+      title="执行时间说明"
+      type="info"
+      :closable="false"
+      style="margin-bottom: 10px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);"
+    >
+      <div style="font-size: 13px; line-height: 1.8; color: #606266;">
+        <p style="margin: 0; padding-left: 8px;">
+          <i class="el-icon-time" style="color: #409EFF; margin-right: 5px;"></i>
+          规则执行时间为每个小时的 <strong style="color: #409EFF;">0分、20分、40分</strong>
+        </p>
+        <p style="margin: 8px 0 0 0; padding-left: 8px;">
+          <i class="el-icon-example" style="color: #67C23A; margin-right: 5px;"></i>
+          例如:预计外呼时间为 <code style="background-color: #f4f4f5; padding: 2px 6px; border-radius: 3px; color: #E6A23C; font-size: 12px;">2026-01-01 10:00:00</code>
+          ,实际执行时间则为 <code style="background-color: #f4f4f5; padding: 2px 6px; border-radius: 3px; color: #E6A23C; font-size: 12px;">2026-01-01 10:20:00</code>
+        </p>
+        <p style="margin: 8px 0 0 0; padding-left: 8px; color: #909399; font-size: 12px;">
+          <i class="el-icon-warning-outline" style="margin-right: 5px;"></i>
+          因任务多可能存在一定延时,一般不超过下一次执行时间
+        </p>
+      </div>
+    </el-alert>
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="120px">
+      <el-form-item label="营期ID" prop="sopUserLogsId">
+        <el-input
+          v-model="queryParams.sopUserLogsId"
+          placeholder="请输入营期ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="外呼任务" prop="status">
+        <el-select v-model="queryParams.sipBatchId" placeholder="请选择外呼任务" clearable size="small">
+          <el-option
+            v-for="template in aiSipCallTaskList"
+            :key="template.batchId"
+            :label="template.batchName"
+            :value="template.batchId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="执行状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择执行状态" clearable size="small">
+          <el-option
+            v-for="dict in sipTaskStatusOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!--    <el-row :gutter="10" class="mb8">-->
+    <!--      <el-col :span="1.5">-->
+    <!--        <el-button-->
+    <!--          type="warning"-->
+    <!--          plain-->
+    <!--          icon="el-icon-download"-->
+    <!--          size="mini"-->
+    <!--          :loading="exportLoading"-->
+    <!--          @click="handleExport"-->
+    <!--        >导出</el-button>-->
+    <!--      </el-col>-->
+    <!--      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>-->
+    <!--    </el-row>-->
+
+    <el-table v-loading="loading" :data="sipTaskList">
+      <el-table-column label="ID" align="center" prop="id" width="80"/>
+      <!--      <el-table-column label="sopId" align="center" prop="sopId" width="120"/>-->
+      <el-table-column label="营期ID" align="center" prop="sopUserLogsId" width="120"/>
+      <el-table-column label="课程配置" align="center" prop="contentJson" >
+        <template slot-scope="scope">
+          <el-button type="text" @click="showContentDialog(scope.row)">
+            查看配置
+          </el-button>
+        </template>
+      </el-table-column>
+      <el-table-column label="企微员工账号" align="center" prop="qwUserid" width="100"/>
+      <el-table-column label="企微员工名称" align="center" prop="qwUserName" width="100"/>
+      <el-table-column label="外呼任务" align="center" prop="sipBatchName" width="120"/>
+      <el-table-column label="预计外呼时间" align="center" prop="sendTime" width="180"/>
+      <el-table-column label="任务来源" align="center" prop="taskSource" width="120">
+        <template slot-scope="scope">
+          <dict-tag :options="taskSourceOptions" :value="scope.row.taskSource + ''"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="执行状态" align="center" prop="status" width="120">
+        <template slot-scope="scope">
+          <dict-tag :options="sipTaskStatusOptions" :value="scope.row.status + ''"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="失败原因" align="center" prop="failReason" min-width="180"/>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180"/>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="120" fixed="right">
+        <template slot-scope="scope">
+          <el-button
+            v-if="scope.row.status == 3"
+            size="mini"
+            type="text"
+            icon="el-icon-view"
+            v-hasPermi="['company:aiSipCall:phone:hisSopList']"
+            @click="handleViewDetail(scope.row)"
+          >执行详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <!-- 暂无数据提示 -->
+    <el-empty
+      v-if="!loading && sipTaskList.length === 0"
+      description="暂无数据"
+      :image-size="200"
+    ></el-empty>
+
+    <!-- 查看详情对话框 -->
+    <el-dialog
+      title="执行详情"
+      :visible.sync="detailDialog.visible"
+      width="1600px"
+      :close-on-click-modal="false"
+      append-to-body
+      custom-class="detail-dialog"
+    >
+      <!-- 数据同步说明 -->
+      <el-collapse v-model="syncInfoExpanded" class="sync-collapse">
+        <el-collapse-item name="sync">
+          <template slot="title">
+            <div class="sync-header">
+              <i class="el-icon-info" style="color: #E6A23C; margin-right: 8px;"></i>
+              <span class="sync-title">数据同步说明</span>
+              <span class="sync-summary">存在延时 · 三重同步机制保障</span>
+              <el-button type="text" size="mini" class="view-detail-btn">
+                <i class="el-icon-arrow-down"></i> 点击查看详细说明
+              </el-button>
+            </div>
+          </template>
+          <div class="sync-detail-content">
+            <el-row :gutter="16">
+              <!-- 左侧:延时原因 -->
+              <el-col :span="10">
+                <div class="sync-card">
+                  <div class="sync-card-header warning">
+                    <i class="el-icon-warning-outline"></i>
+                    <span>延时原因</span>
+                  </div>
+                  <ul class="compact-list">
+                    <li>拨号队列排队中</li>
+                    <li>外呼任务并发量过低</li>
+                    <li>外呼平台异常</li>
+                  </ul>
+                </div>
+              </el-col>
+
+              <!-- 右侧:同步规则 -->
+              <el-col :span="14">
+                <div class="sync-card">
+                  <div class="sync-card-header info">
+                    <i class="el-icon-refresh"></i>
+                    <span>三重同步机制</span>
+                  </div>
+
+                  <!-- 实时同步 -->
+                  <div class="rule-detail-item">
+                    <div class="rule-header">
+                      <el-tag size="mini" type="success" effect="dark">实时</el-tag>
+                      <span class="rule-desc">每分钟同步 <code>T-4分钟</code> 至 <code>T-3分钟</code> 的1分钟数据</span>
+                    </div>
+                    <div class="rule-example">
+                      <i class="el-icon-time"></i>
+                      示例:当前时间 <strong>08:00:00</strong> → 同步 <code>07:56:00 ~ 07:57:00</code> 的数据
+                    </div>
+                  </div>
+
+                  <!-- 小时同步 -->
+                  <div class="rule-detail-item">
+                    <div class="rule-header">
+                      <el-tag size="mini" effect="dark">小时</el-tag>
+                      <span class="rule-desc">每小时整点同步 <code>T-2小时</code> 的1小时数据</span>
+                    </div>
+                    <div class="rule-example">
+                      <i class="el-icon-time"></i>
+                      示例:当前时间 <strong>08:00:00</strong> → 同步 <code>06:00:00 ~ 07:00:00</code> 的数据
+                    </div>
+                  </div>
+
+                  <!-- 每日同步 -->
+                  <div class="rule-detail-item">
+                    <div class="rule-header">
+                      <el-tag size="mini" type="warning" effect="dark">每日</el-tag>
+                      <span class="rule-desc">每天凌晨4点同步前一天的全天数据</span>
+                    </div>
+                    <div class="rule-example">
+                      <i class="el-icon-time"></i>
+                      示例:当前时间 <strong>01-02 04:00:00</strong> → 同步 <code>01-01 00:00:00 ~ 23:59:59</code> 的数据
+                    </div>
+                  </div>
+                </div>
+              </el-col>
+            </el-row>
+          </div>
+        </el-collapse-item>
+      </el-collapse>
+
+      <el-form :model="detailDialog.queryParams" ref="detailQueryForm" :inline="true" label-width="100px" style="margin-bottom: 15px;">
+        <el-form-item label="UUID" prop="uuid">
+          <el-input
+            v-model="detailDialog.queryParams.uuid"
+            placeholder="请输入UUID"
+            clearable
+            size="small"
+            @keyup.enter.native="handleDetailQuery"
+          />
+        </el-form-item>
+        <el-form-item label="客户昵称" prop="externalUserName">
+          <el-input
+            v-model="detailDialog.queryParams.externalUserName"
+            placeholder="请输入客户昵称"
+            clearable
+            size="small"
+            @keyup.enter.native="handleDetailQuery"
+          />
+        </el-form-item>
+        <el-form-item label="外呼结果" prop="callstatus">
+          <el-select v-model="detailDialog.queryParams.callstatus" placeholder="请选择外呼结果" clearable size="small">
+            <el-option
+              v-for="item in callStatusOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="客户意向" prop="intent">
+          <el-input
+            v-model="detailDialog.queryParams.intent"
+            placeholder="请输入客户意向"
+            clearable
+            size="small"
+            @keyup.enter.native="handleDetailQuery"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleDetailQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetDetailQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table
+        v-loading="detailDialog.loading"
+        :data="detailDialog.list"
+        border
+        size="small"
+        max-height="500"
+        :header-cell-style="{background: '#f5f7fa', color: '#606266', fontWeight: 'bold'}"
+      >
+        <el-table-column label="ID" align="center" prop="id" width="160" />
+<!--        <el-table-column label="UUID" align="center" prop="uuid" min-width="180" show-overflow-tooltip/>-->
+        <el-table-column label="客户昵称" align="center" prop="externalUserName" min-width="120" show-overflow-tooltip/>
+        <el-table-column label="预计外呼时间" align="center" prop="sendTime" width="180" />
+        <el-table-column label="实际外呼时间" align="center" prop="calloutTimeStr" width="180" />
+        <el-table-column label="外呼结果" align="center" prop="callstatusName" min-width="150">
+          <template slot-scope="scope">
+            <el-tag
+              :type="getCallStatusType(scope.row.callstatus)"
+              size="small"
+            >
+              {{ scope.row.callstatusName }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="客户意向" align="center" prop="intent" min-width="60" show-overflow-tooltip/>
+        <el-table-column label="挂机原因" align="center" prop="hangupCause" min-width="180" show-overflow-tooltip/>
+        <el-table-column label="创建时间" align="center" prop="createtimeStr" width="180" />
+      </el-table>
+      <pagination
+        v-show="detailDialog.total > 0"
+        :total="detailDialog.total"
+        :page.sync="detailDialog.queryParams.pageNum"
+        :limit.sync="detailDialog.queryParams.pageSize"
+        @pagination="getDetailList"
+      />
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="detailDialog.visible = false">关 闭</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 查看课程详情对话框 -->
+    <el-dialog :visible.sync="contentDialog.isDialogVisible" title="课程详情" width="560px" append-to-body :close-on-click-modal="false">
+      <div v-loading="contentDialog.loading" style="max-height: 65vh; overflow-y: auto; overflow-x: hidden; padding-right: 8px; box-sizing: border-box;">
+        <!-- 内容类型标题 -->
+        <div v-if="contentDialog.contentType === '9'"
+             style="padding: 8px 12px; border: 1px solid #dcdfe6; border-radius: 4px; margin-bottom: 10px; text-align: center; font-size: 13px; color: #606266; word-wrap: break-word;">
+          <i class="el-icon-mobile-phone" style="margin-right: 5px;"></i>APP发课
+        </div>
+
+        <!-- 课程信息 -->
+        <el-card class="box-card" v-if="contentDialog.courseInfo" style="margin-bottom: 10px;">
+          <div slot="header" class="clearfix" style="padding: 8px 15px; line-height: 1.2;">
+            <span style="font-weight: 600; font-size: 13px;">课程信息</span>
+          </div>
+          <div style="padding: 8px 15px;">
+            <!-- 课程封面居中显示 -->
+            <div v-if="contentDialog.courseInfo.imgUrl" style="text-align: center; margin-bottom: 10px;">
+              <img :src="contentDialog.courseInfo.imgUrl" alt=""
+                   @click="openImageViewer(contentDialog.courseInfo.imgUrl)"
+                   style="width: 200px; height: 150px; cursor: pointer; border-radius: 6px; border: 1px solid #e4e7ed; object-fit: cover; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"/>
+            </div>
+            <!-- 课程信息字段 -->
+            <div style="overflow: hidden; margin-bottom: 6px; line-height: 1.5;">
+              <span style="color: #909399; font-size: 13px; float: left; width: 70px;">课程ID:</span>
+              <div style="margin-left: 70px; font-size: 13px; word-break: break-all;">{{ contentDialog.courseInfo.courseId || '-' }}</div>
+            </div>
+            <div style="overflow: hidden; line-height: 1.5;">
+              <span style="color: #909399; font-size: 13px; float: left; width: 70px;">课程名称:</span>
+              <div style="margin-left: 70px; font-size: 13px; word-break: break-all;">{{ contentDialog.courseInfo.courseName || '-' }}</div>
+            </div>
+          </div>
+        </el-card>
+
+        <!-- 课程小节信息 -->
+        <el-card class="box-card" v-if="contentDialog.videoInfo" style="margin-bottom: 10px;">
+          <div slot="header" class="clearfix" style="padding: 8px 15px; line-height: 1.2;">
+            <span style="font-weight: 600; font-size: 13px;">课程小节信息</span>
+          </div>
+          <div style="padding: 8px 15px;">
+            <div style="overflow: hidden; margin-bottom: 6px; line-height: 1.5;">
+              <span style="color: #909399; font-size: 13px; float: left; width: 70px;">小节ID:</span>
+              <div style="margin-left: 70px; font-size: 13px; word-break: break-all;">{{ contentDialog.videoInfo.videoId || '-' }}</div>
+            </div>
+            <div style="overflow: hidden; line-height: 1.5;">
+              <span style="color: #909399; font-size: 13px; float: left; width: 70px;">小节名称:</span>
+              <div style="margin-left: 70px; font-size: 13px; word-break: break-all;">{{ contentDialog.videoInfo.title || '-' }}</div>
+            </div>
+          </div>
+        </el-card>
+
+        <!-- 消息类型 -->
+        <div v-if="contentDialog.messageType !== null && contentDialog.messageType !== undefined"
+             style="padding: 10px 15px; border: 1px solid #dcdfe6; border-radius: 4px; margin-bottom: 10px; display: flex; align-items: center; background-color: #f5f7fa;">
+          <span style="color: #606266; font-size: 13px; font-weight: 600; margin-right: 10px; white-space: nowrap;">消息类型:</span>
+          <el-tag :type="getMessageTypeTag(contentDialog.messageType)" size="small">
+            {{ getMessageTypeName(contentDialog.messageType) }}
+          </el-tag>
+        </div>
+
+        <!-- 无数据提示 -->
+        <div v-if="!contentDialog.courseInfo && !contentDialog.videoInfo && contentDialog.contentType !== '9' && !contentDialog.loading" style="text-align: center; padding: 25px; color: #999;">
+          <i class="el-icon-info" style="font-size: 36px;"></i>
+          <p style="margin-top: 6px; font-size: 13px;">暂无课程或视频信息</p>
+        </div>
+      </div>
+      <span slot="footer" class="dialog-footer" style="padding: 10px 20px;">
+        <el-button @click="contentDialog.isDialogVisible = false" size="small">关闭</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {getUserCourse} from "@/api/course/userCourse";
+import {getUserCourseVideo} from "@/api/course/userCourseVideo";
+import { listAiSipCallTask } from '@/api/aiSipCall/aiSipCallTask'
+import { companySopList } from '@/api/aiSipCall/aiSipCallPhone'
+import {getSopSipTask} from "@/api/qw/sopLogs";
+
+export default {
+  name: "SipTaskDetails",
+  props:{
+    rowDetailFrom:{},
+  },
+
+  watch:{
+    rowDetailFrom:{
+      handler(newVal){
+        // 当formData变化时重新查询
+        this.getList(newVal);
+      },
+      deep: true
+    }
+  },
+  data() {
+    return {
+      // 数据同步说明折叠状态
+      syncInfoExpanded: [],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // SIP任务表格数据
+      sipTaskList: [],
+
+      // 任务状态字典
+      sipTaskStatusOptions: [],
+      // 处理状态字典
+      handleStatusOptions: [],
+      // 任务来源字典
+      taskSourceOptions: [],
+      // AI通话任务列表
+      aiSipCallTaskList: [],
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        sopUserLogsId: null,
+        appCustomerId: null,
+        status: null,
+      },
+      // 外呼结果选项
+      callStatusOptions: [
+        { value: 0, label: '待呼叫/未接听' },
+        { value: 1, label: '已进入呼叫队列' },
+        { value: 2, label: '正在拨号' },
+        { value: 3, label: '未接通(如果关闭空号检测)' },
+        { value: 4, label: '已接通' },
+        { value: 5, label: '通话中断' },
+        { value: 6, label: '成功转人工或AI' },
+        { value: 7, label: '线路故障' },
+        { value: 30, label: '未接通' },
+        { value: 31, label: '客户正在通话中' },
+        { value: 32, label: '关机' },
+        { value: 33, label: '空号' },
+        { value: 34, label: '无人接听' },
+        { value: 35, label: '停机' },
+        { value: 36, label: '网络忙' },
+        { value: 37, label: '语音助手' },
+        { value: 38, label: '暂时无法接通' },
+        { value: 39, label: '呼叫限制' }
+      ],
+      // 详情对话框
+      detailDialog: {
+        visible: false,
+        loading: false,
+        data: {},
+        list: [],
+        total: 0,
+        queryParams: {
+          pageNum: 1,
+          pageSize: 10,
+          sipTaskId: null,
+          status: 0,
+          executeType: '0',
+          externalUserName: null,
+          intent: null,
+          uuid: null,
+          callstatus: null,
+        }
+      },
+      contentDialog:{
+        isDialogVisible:false,
+        json: [],
+        videoId:null,
+        courseId:null,
+        contentType: null,
+        messageType: null,
+        courseInfo: null,
+        videoInfo: null,
+        loading: false,
+        currentRow: {},
+      },
+    };
+  },
+  created() {
+    this.getList(this.rowDetailFrom);
+
+    // 获取任务状态字典(如果不存在则使用默认值)
+    this.sipTaskStatusOptions = [
+      { dictValue: '1', dictLabel: '待执行' },
+      { dictValue: '2', dictLabel: '执行中' },
+      { dictValue: '3', dictLabel: '执行成功' },
+      { dictValue: '4', dictLabel: '执行失败' },
+      { dictValue: '5', dictLabel: '没有标签跳过' },
+      { dictValue: '6', dictLabel: '没有营期跳过' }
+    ];
+
+    // 获取处理状态字典
+    this.handleStatusOptions = [
+      { dictValue: '0', dictLabel: '待处理' },
+      { dictValue: '1', dictLabel: '处理中' },
+      { dictValue: '2', dictLabel: '处理完成' },
+      { dictValue: '3', dictLabel: '不再自动处理' }
+    ];
+
+    // 获取任务来源字典
+    this.taskSourceOptions = [
+      { dictValue: '0', dictLabel: '未知' },
+      { dictValue: '1', dictLabel: '一键群发' },
+      { dictValue: '2', dictLabel: '任务模板' }
+    ];
+
+    this.getAiSipCallTask();
+  },
+  methods: {
+    /** 查询SIP任务列表 */
+    getList(val) {
+      let qp = {...this.queryParams};
+      qp['sopId'] = val.id || this.rowDetailFrom.id;
+      this.loading = true;
+      getSopSipTask(qp).then(response => {
+        this.sipTaskList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList(this.rowDetailFrom);
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.queryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        sopUserLogsId: null,
+        appCustomerId: null,
+        status: null,
+      };
+      this.handleQuery();
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      let qp = {...this.queryParams};
+      qp['sopId'] = this.rowDetailFrom.id;
+      this.$confirm('是否确认导出所有SIP任务数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        // 这里需要添加导出API调用
+        // return exportSipTask(qp);
+      }).then(response => {
+        // this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    },
+    /** 查看详情 */
+    handleViewDetail(row) {
+      this.detailDialog.data = { ...row };
+      this.detailDialog.queryParams.sipTaskId = row.id;
+      this.detailDialog.queryParams.externalUserName = null;
+      this.detailDialog.queryParams.intent = null;
+      this.detailDialog.queryParams.uuid = null;
+      this.detailDialog.queryParams.pageNum = 1;
+      this.detailDialog.visible = true;
+      this.getDetailList();
+    },
+    /** 详情搜索按钮操作 */
+    handleDetailQuery() {
+      this.detailDialog.queryParams.pageNum = 1;
+      this.getDetailList();
+    },
+    /** 详情重置按钮操作 */
+    resetDetailQuery() {
+      this.detailDialog.queryParams.externalUserName = null;
+      this.detailDialog.queryParams.intent = null;
+      this.detailDialog.queryParams.uuid = null;
+      this.detailDialog.queryParams.callstatus = null;
+      this.getDetailList();
+    },
+    /** 获取通话记录列表 */
+    getDetailList() {
+      this.detailDialog.loading = true;
+      companySopList(this.detailDialog.queryParams).then(response => {
+        this.detailDialog.list = response.rows || [];
+        this.detailDialog.total = response.total || 0;
+        this.detailDialog.loading = false;
+      }).catch(() => {
+        this.detailDialog.loading = false;
+      });
+    },
+    /** 格式化时间戳 */
+    formatDate(timestamp) {
+      if (!timestamp) return '-';
+      const date = new Date(timestamp * 1000);
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      const hours = String(date.getHours()).padStart(2, '0');
+      const minutes = String(date.getMinutes()).padStart(2, '0');
+      const seconds = String(date.getSeconds()).padStart(2, '0');
+      return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+    },
+    /** 获取呼叫状态标签类型 */
+    getCallStatusType(status) {
+      const typeMap = {
+        0: 'info',
+        1: 'warning',
+        2: 'warning',
+        3: 'danger',
+        4: 'success',
+        5: 'warning',
+        6: 'success',
+        7: 'danger',
+        30: 'danger',
+        31: 'warning',
+        32: 'danger',
+        33: 'info',
+        34: 'warning',
+        35: 'danger',
+        36: 'warning',
+        37: 'info',
+        38: 'warning',
+        39: 'danger'
+      };
+      return typeMap[status] || 'info';
+    },
+    /** 获取呼叫状态标签文本 */
+    getCallStatusLabel(status) {
+      const labelMap = {
+        0: '未拨打',
+        1: '已进入队列',
+        2: '正在拨号',
+        3: '未接通',
+        4: '已接通',
+        5: '通话中断',
+        6: '转人工/AI',
+        7: '线路故障',
+        30: '未接通',
+        31: '正在通话',
+        32: '关机',
+        33: '空号',
+        34: '无人接听',
+        35: '停机',
+        36: '网络忙',
+        37: '语音助手',
+        38: '无法接通',
+        39: '呼叫限制'
+      };
+      return labelMap[status] || '未知';
+    },
+    /** 查看对话详情 */
+    viewDialogueDetail(row) {
+      if (row.dialogue) {
+        try {
+          const dialogueData = typeof row.dialogue === 'string' ? JSON.parse(row.dialogue) : row.dialogue;
+          this.$alert(JSON.stringify(dialogueData, null, 2), '对话内容', {
+            confirmButtonText: '确定',
+            customClass: 'dialogue-detail-dialog',
+            dangerouslyUseHTMLString: false
+          });
+        } catch (e) {
+          this.$message.error('解析对话数据失败');
+        }
+      } else {
+        this.$message.info('暂无对话记录');
+      }
+    },
+    // 显示消息详情对话框
+    showContentDialog(row) {
+      this.contentDialog.currentRow = row;
+      this.contentDialog.isDialogVisible = true;
+      this.contentDialog.loading = true;
+      this.contentDialog.courseInfo = null;
+      this.contentDialog.videoInfo = null;
+      this.contentDialog.contentType = null;
+      this.contentDialog.messageType = null;
+
+
+      try {
+        const courseId = row.courseId;
+        const videoId = row.videoId;
+        const type = row.courseType;
+
+        this.contentDialog.courseId = courseId;
+        this.contentDialog.videoId = videoId;
+        this.contentDialog.messageType = type;
+
+        // 并行请求课程和视频详情
+        const promises = [];
+
+        if (courseId) {
+          promises.push(
+            getUserCourse(courseId).then(response => {
+              this.contentDialog.courseInfo = response.data;
+            }).catch(err => {
+              console.error('获取课程信息失败:', err);
+            })
+          );
+        }
+
+        if (videoId) {
+          promises.push(
+            getUserCourseVideo(videoId).then(response => {
+              this.contentDialog.videoInfo = response.data;
+            }).catch(err => {
+              console.error('获取视频信息失败:', err);
+            })
+          );
+        }
+
+        // 等待所有请求完成
+        Promise.all(promises).finally(() => {
+          this.contentDialog.loading = false;
+        });
+
+      } catch (e) {
+        console.error('解析 contentJson 失败:', e);
+        this.contentDialog.loading = false;
+      }
+    },
+    // 获取消息类型标签样式
+    getMessageTypeTag(type) {
+      const tagMap = {
+        0: '',
+        1: 'warning',
+        2: 'danger',
+        3: 'success',
+        4: 'info'
+      };
+      return tagMap[type] !== undefined ? tagMap[type] : '';
+    },
+    // 获取消息类型名称
+    getMessageTypeName(type) {
+      const typeMap = {
+        0: '普通消息',
+        1: '待看课消息',
+        2: '看课中断消息',
+        3: '已完课消息',
+        4: '待看和中断消息'
+      };
+      return typeMap[type] !== undefined ? typeMap[type] : '未知类型';
+    },
+    openImageViewer(url) {
+      // 打开大图预览对话框
+      this.dialogImageUrl=url
+      this.dialogVisible = true;
+    },
+    // 获取sip模板信息
+    getAiSipCallTask() {
+      if (this.aiSipCallTaskList.length === 0) {
+        listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+          this.aiSipCallTaskList = response.rows || response.data || [];
+        }).catch(error => {
+          console.error('加载sip模板失败:', error);
+          this.$message.error('加载sip模板失败');
+        });
+      }
+    },
+  }
+};
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+</style>
+
+<style>
+/* 详情对话框样式 */
+.detail-dialog .el-dialog__body {
+  padding: 20px;
+}
+
+.detail-dialog .el-table {
+  font-size: 13px;
+}
+
+.detail-dialog .el-table th {
+  padding: 12px 0;
+}
+
+.detail-dialog .el-table td {
+  padding: 10px 0;
+}
+
+.detail-dialog .el-tag {
+  font-weight: 500;
+}
+
+/* 数据同步说明 - 紧凑折叠样式 */
+.sync-collapse {
+  margin-bottom: 16px;
+  border: none;
+}
+
+.sync-collapse .el-collapse-item__header {
+  height: auto;
+  min-height: 40px;
+  line-height: 20px;
+  padding: 8px 15px;
+  background-color: #fdf6ec;
+  border-left: 3px solid #E6A23C;
+  border-radius: 4px;
+  font-size: 13px;
+}
+
+.sync-collapse .el-collapse-item__header.is-active {
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+}
+
+.sync-collapse .el-collapse-item__wrap {
+  border: 1px solid #EBEEF5;
+  border-top: none;
+  border-bottom-left-radius: 4px;
+  border-bottom-right-radius: 4px;
+  background-color: #fafafa;
+}
+
+.sync-collapse .el-collapse-item__content {
+  padding: 16px;
+}
+
+.sync-header {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+.sync-title {
+  font-weight: 600;
+  color: #303133;
+  margin-right: 12px;
+}
+
+.sync-summary {
+  color: #909399;
+  font-size: 12px;
+}
+
+.view-detail-btn {
+  margin-left: auto;
+  color: #409EFF !important;
+  font-size: 12px !important;
+  padding: 0 8px !important;
+}
+
+.view-detail-btn i {
+  transition: transform 0.3s;
+}
+
+.sync-collapse .el-collapse-item__header.is-active .view-detail-btn i {
+  transform: rotate(180deg);
+}
+
+.view-detail-btn {
+  margin-left: auto;
+  color: #409EFF !important;
+  font-size: 12px !important;
+  padding: 0 8px !important;
+}
+
+.view-detail-btn i {
+  transition: transform 0.3s;
+}
+
+.sync-collapse .el-collapse-item__header.is-active .view-detail-btn i {
+  transform: rotate(180deg);
+}
+
+.sync-detail-content {
+  font-size: 13px;
+}
+
+.sync-card {
+  background-color: #fff;
+  border: 1px solid #EBEEF5;
+  border-radius: 4px;
+  padding: 12px;
+  height: 100%;
+}
+
+.sync-card-header {
+  display: flex;
+  align-items: center;
+  font-weight: 600;
+  font-size: 13px;
+  margin-bottom: 10px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #EBEEF5;
+}
+
+.sync-card-header.warning {
+  color: #E6A23C;
+}
+
+.sync-card-header.info {
+  color: #409EFF;
+}
+
+.sync-card-header i {
+  margin-right: 6px;
+}
+
+.compact-list {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.compact-list li {
+  padding: 4px 0;
+  color: #606266;
+  position: relative;
+  padding-left: 14px;
+  font-size: 12px;
+}
+
+.compact-list li::before {
+  content: "•";
+  position: absolute;
+  left: 2px;
+  color: #909399;
+}
+
+.sync-rules {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.rule-detail-item {
+  background-color: #f5f7fa;
+  border-radius: 4px;
+  padding: 10px 12px;
+  border-left: 3px solid #409EFF;
+}
+
+.rule-detail-item:nth-child(1) {
+  border-left-color: #67C23A;
+}
+
+.rule-detail-item:nth-child(2) {
+  border-left-color: #409EFF;
+}
+
+.rule-detail-item:nth-child(3) {
+  border-left-color: #E6A23C;
+}
+
+.rule-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+.rule-desc {
+  font-size: 12px;
+  color: #606266;
+  margin-left: 8px;
+  flex: 1;
+}
+
+.rule-desc code {
+  background-color: #ecf5ff;
+  color: #409EFF;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: Consolas, Monaco, 'Courier New', monospace;
+  font-size: 11px;
+  margin: 0 2px;
+}
+
+.rule-example {
+  display: flex;
+  align-items: center;
+  font-size: 11px;
+  color: #909399;
+  padding-left: 52px;
+  line-height: 1.6;
+}
+
+.rule-example i {
+  color: #C0C4CC;
+  margin-right: 6px;
+  font-size: 13px;
+}
+
+.rule-example strong {
+  color: #606266;
+  font-weight: 600;
+  margin: 0 3px;
+}
+
+.rule-example code {
+  background-color: #fdf6ec;
+  color: #E6A23C;
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-family: Consolas, Monaco, 'Courier New', monospace;
+  font-size: 11px;
+  margin: 0 2px;
+}
+
+/* 数据同步说明 - 紧凑折叠样式 */
+.sync-collapse {
+  margin-bottom: 16px;
+  border: none;
+}
+
+.sync-collapse .el-collapse-item__header {
+  height: auto;
+  min-height: 40px;
+  line-height: 20px;
+  padding: 8px 15px;
+  background-color: #fdf6ec;
+  border-left: 3px solid #E6A23C;
+  border-radius: 4px;
+  font-size: 13px;
+}
+
+.sync-collapse .el-collapse-item__header.is-active {
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+}
+
+.sync-collapse .el-collapse-item__wrap {
+  border: 1px solid #EBEEF5;
+  border-top: none;
+  border-bottom-left-radius: 4px;
+  border-bottom-right-radius: 4px;
+  background-color: #fafafa;
+}
+
+.sync-collapse .el-collapse-item__content {
+  padding: 16px;
+}
+
+.sync-header {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+.sync-title {
+  font-weight: 600;
+  color: #303133;
+  margin-right: 12px;
+}
+
+.sync-summary {
+  color: #909399;
+  font-size: 12px;
+}
+
+.sync-detail-content {
+  font-size: 13px;
+}
+
+.sync-card {
+  background-color: #fff;
+  border: 1px solid #EBEEF5;
+  border-radius: 4px;
+  padding: 12px;
+  height: 100%;
+}
+
+.sync-card-header {
+  display: flex;
+  align-items: center;
+  font-weight: 600;
+  font-size: 13px;
+  margin-bottom: 10px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #EBEEF5;
+}
+
+.sync-card-header.warning {
+  color: #E6A23C;
+}
+
+.sync-card-header.info {
+  color: #409EFF;
+}
+
+.sync-card-header i {
+  margin-right: 6px;
+}
+
+.compact-list {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.compact-list li {
+  padding: 4px 0;
+  color: #606266;
+  position: relative;
+  padding-left: 14px;
+  font-size: 12px;
+}
+
+.compact-list li::before {
+  content: "•";
+  position: absolute;
+  left: 2px;
+  color: #909399;
+}
+
+.sync-rules {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.rule-detail-item {
+  background-color: #f5f7fa;
+  border-radius: 4px;
+  padding: 10px 12px;
+  border-left: 3px solid #409EFF;
+}
+
+.rule-detail-item:nth-child(1) {
+  border-left-color: #67C23A;
+}
+
+.rule-detail-item:nth-child(2) {
+  border-left-color: #409EFF;
+}
+
+.rule-detail-item:nth-child(3) {
+  border-left-color: #E6A23C;
+}
+
+.rule-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+.rule-desc {
+  font-size: 12px;
+  color: #606266;
+  margin-left: 8px;
+  flex: 1;
+}
+
+.rule-desc code {
+  background-color: #ecf5ff;
+  color: #409EFF;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: Consolas, Monaco, 'Courier New', monospace;
+  font-size: 11px;
+  margin: 0 2px;
+}
+
+.rule-example {
+  display: flex;
+  align-items: center;
+  font-size: 11px;
+  color: #909399;
+  padding-left: 52px;
+  line-height: 1.6;
+}
+
+.rule-example i {
+  color: #C0C4CC;
+  margin-right: 6px;
+  font-size: 13px;
+}
+
+.rule-example strong {
+  color: #606266;
+  font-weight: 600;
+  margin: 0 3px;
+}
+
+.rule-example code {
+  background-color: #fdf6ec;
+  color: #E6A23C;
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-family: Consolas, Monaco, 'Courier New', monospace;
+  font-size: 11px;
+  margin: 0 2px;
+}
+</style>

+ 53 - 33
src/views/qw/sopTemp/updateSopTempSXJZ.vue

@@ -168,33 +168,6 @@
                                     请注意:官方群发 【第1天】 只能设置1点-1点30 的时间,【其他天数时间】 只能设置0点-0点30。此处仅为生成发送记录时间,实际发送可由 销售在公司规定的 【暂早上8点(前!!)】,在企业微信的【群发助手】处点击发送
                                   </div>
                                 </el-form-item>
-
-                                <el-form-item label="是否@所有人" v-if="form.sendType != 4" class="switch-form-item">
-                                  <el-switch
-                                    v-model="content.isAtAll"
-                                    active-color="#13ce66"
-                                    inactive-color="#DCDFE6"
-                                    :active-value="1"
-                                    :inactive-value="0">
-                                  </el-switch>
-                                  <div class="form-tip-info">
-                                    <i class="el-icon-info"></i>
-                                    开启后将在群发消息时@所有人(默认关闭)
-                                  </div>
-                                </el-form-item>
-
-                                <el-form-item label="消息类型" v-if="form.sendType != 4">
-                                  <el-select :disabled="formType == 3" v-model="content.courseType"
-                                             placeholder="请选择消息类型" size="mini"
-                                             style=" margin-right: 10px;" v-if="content.type != 4 ">
-                                    <el-option
-                                      v-for="dict in sysFsSopWatchStatus"
-                                      :key="dict.dictValue"
-                                      :label="dict.dictLabel"
-                                      :value="Number(dict.dictValue)"
-                                    />
-                                  </el-select>
-                                </el-form-item>
                                 <el-form-item label="消息类别" v-if="form.sendType != 4 && form.sendType != 5" class="msg-category-item">
                                   <el-radio-group v-model="content.type"
                                                   :disabled="formType == 3 || content.isOfficial === '1'"
@@ -203,6 +176,7 @@
                                     <el-radio :label="2">课程</el-radio>
                                     <el-radio :label="4">AI触达</el-radio>
                                     <el-radio :label="5">打标签</el-radio>
+                                    <el-radio :label="8">SIP外呼</el-radio>
                                     <el-radio :label="20">直播间</el-radio>
                                   </el-radio-group>
                                 </el-form-item>
@@ -219,7 +193,7 @@
                                         />
                                       </el-select>
                                 </el-form-item>
-                                <el-form-item label="课程选择" v-if="content.type == 2 && form.sendType != 5" required class="course-select-item">
+                                <el-form-item label="课程选择" v-if="(content.type == 2 || content.type == 8) && form.sendType != 5" required class="course-select-item">
                                   <div class="course-select-row">
                                     <el-select :disabled="formType == 3 || form.sendType == 11 || !roles.includes('edit_sop_temp_content')" v-model="content.courseId"
                                                placeholder="请选择课程" size="small" remote
@@ -323,9 +297,24 @@
 
                                 </el-form-item>
 
+                                <el-card class="box-card"  v-if="content.type === 8 ">
+                                      <el-form-item label="外呼任务" >
+                                          <el-select v-model="content.aiCustomerRoleId"
+                                                     placeholder="请选择外呼任务" size="mini"
+                                                     @change="getAiSipCallTask(content)" >
+                                              <el-option
+                                                  v-for="template in aiSipCallTaskList"
+                                                  :key="template.batchId"
+                                                  :label="template.batchName"
+                                                  :value="template.batchId"
+                                              />
+                                          </el-select>
+                                      </el-form-item>
+                                </el-card>
+
                                 <div v-for="(setList, setIndex) in content.setting" :key="setIndex"
                                      class="setting-item-card"
-                                     v-if="content.type != 4 && content.type != 5">
+                                     v-if="content.type != 4 && content.type != 5 && content.type != 8">
                                   <div class="setting-item-header">
                                     <span class="setting-item-title">
                                       <i class="el-icon-document"></i>
@@ -765,7 +754,7 @@
                                 </div>
                                 <el-link type="primary" class="el-icon-plus" :underline="false"
                                          @click='addSetList(contentIndex,item.content)'
-                                         v-if="content.type != 4 && formType != 3 && roles.includes('add_sop_temp_content')">添加内容
+                                         v-if="content.type != 4 && content.type != 8 && formType != 3 && roles.includes('add_sop_temp_content')">添加内容
                                 </el-link>
                               </el-form>
                             </el-col>
@@ -777,7 +766,7 @@
                           </el-row>
                         </div>
                         <el-link type="primary" class="el-icon-plus" :underline="false" @click='addContent(index)'
-                                 v-if="formType != 3 && form.sendType != 4 && roles.includes('add_sop_temp_rule')">添加规则
+                                 v-if="formType != 3 && form.sendType != 4  && roles.includes('add_sop_temp_rule')">添加规则
                         </el-link>
                       </el-form-item>
                     </el-form>
@@ -1801,6 +1790,7 @@ import { getQwSopTempVoiceCompanyUser } from "@/api/qw/QwSopTempVoice";
 import {
   getRoles,
 } from "@/api/qw/sop";
+import { listAiSipCallTask } from '@/api/aiSipCall/aiSipCallTask'
 
 export default {
   name: "updateSopTemp",
@@ -1926,6 +1916,7 @@ export default {
         ],
       },
       liveList: [],
+      aiSipCallTaskList: [],
     };
   },
   created() {
@@ -1972,8 +1963,37 @@ export default {
     this.handleUpdate(id);
 
     this.loadSmsTemplates();
+    this.loadSipTemplates();
   },
   methods: {
+
+      // 获取sip模板信息
+      getAiSipCallTask() {
+          // 如果短信模板列表为空,先加载数据
+          if (this.aiSipCallTaskList.length === 0) {
+              listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+                  this.aiSipCallTaskList = response.rows || response.data || [];
+              }).catch(error => {
+                  console.error('加载sip模板失败:', error);
+                  this.$message.error('加载sip模板失败');
+              });
+          }
+      },
+
+      loadSipTemplates() {
+          if (this.aiSipCallTaskList.length > 0) {
+              return; // 如果已经有数据,不再重复加载
+          }
+
+          listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+              this.aiSipCallTaskList = response.rows || response.data || [];
+              if (this.aiSipCallTaskList.length === 0) {
+              }
+          }).catch(error => {
+              console.error('加载sip模板失败:', error);
+              this.$message.error('加载sip模板失败');
+          });
+      },
     getLuckyBagStatus(content) {
       const selectedLuckyBag = this.luckyBagList.find(item => item.id === content.luckyBagId);
       if (selectedLuckyBag) {
@@ -2437,7 +2457,7 @@ export default {
             }
           }
 
-          if (data.content[j].type == 2) {
+          if (data.content[j].type == 2 || data.content[j].type == 8) {
             if (data.content[j].courseId == null || data.content[j].courseId == "") {
               this.$message.error("请选择课程")
               return false;
@@ -2452,7 +2472,7 @@ export default {
             }
           }
 
-          if (data.content[j].type != 4 && data.content[j].type != 5) {
+          if (data.content[j].type != 4 && data.content[j].type != 5 && data.content[j].type != 8) {
             for (let k = 0; k < data.content[j].setting.length; k++) {
               if (data.content[j].setting[k].contentType == 1 && (data.content[j].setting[k].value == null || data.content[j].setting[k].value == "")) {
                 this.$message.error("内容不能为空")

+ 120 - 69
src/views/qw/sopUserLogsInfo/sendMsgOpenTool.vue

@@ -17,10 +17,11 @@
             <el-radio :label="1">正常群发</el-radio>
             <el-radio :label="2">清除草稿</el-radio>
             <el-radio :label="3">发送草稿</el-radio>
+            <el-radio :label="4">sip外呼</el-radio>
           </el-radio-group>
         </el-form-item>
 
-        <el-form-item label="选择课程" v-if="msgForm.draftStrategy==1" >
+        <el-form-item label="选择课程" v-if="msgForm.draftStrategy==1 || msgForm.draftStrategy==4 " >
           <el-select  v-model="msgForm.courseId" placeholder="请选择课程" style=" margin-right: 10px;" size="mini" filterable  @change="courseChange()">
             <el-option
               v-for="dict in courseList"
@@ -46,7 +47,7 @@
             />
           </el-select>
         </el-form-item>
-         <el-form-item label="选择直播间">
+         <el-form-item label="选择直播间" v-if="msgForm.draftStrategy!=4">
              <el-select  v-model="msgForm.liveId"
                          filterable
                                       placeholder="请选择直播间" size="mini"
@@ -425,7 +426,19 @@
           <el-link type="primary" class="el-icon-plus" :underline="false" @click='addSetList()'  >添加内容</el-link>
 
         </el-form-item>
-          <el-form-item label="发送时间" prop="sendTime" v-if="msgForm.draftStrategy==1" >
+        <el-form-item label="外呼任务" v-if="msgForm.draftStrategy==4" >
+              <el-select v-model="msgForm.aiCustomerRoleId"
+                         placeholder="请选择外呼任务" size="mini"
+                         @change="getAiSipCallTask()" >
+                  <el-option
+                      v-for="template in aiSipCallTaskList"
+                      :key="template.batchId"
+                      :label="template.batchName"
+                      :value="template.batchId"
+                  />
+              </el-select>
+        </el-form-item>
+        <el-form-item label="发送时间" prop="sendTime" v-if="msgForm.draftStrategy==1 || msgForm.draftStrategy==4 " >
               <el-date-picker
                   class="custom-input"
                   v-model="msgForm.sendTime"
@@ -484,6 +497,7 @@ import {listToLiveNoEnd} from "@/api/live/live";
 import {listReward} from "@/api/qw/luckyBag";
 import { getSmsTempList } from "@/api/company/companySmsTemp";
 import { getQwSopTempVoiceCompanyUser } from "@/api/qw/QwSopTempVoice";
+import { listAiSipCallTask } from '@/api/aiSipCall/aiSipCallTask'
 
 
 export default {
@@ -577,6 +591,7 @@ export default {
         ids:null,
       },
       smsTemplateList: [],
+      aiSipCallTaskList: [],
       // 表单参数
       form: {},
       setting:[{contentType:'1', value: '',}],
@@ -668,8 +683,37 @@ export default {
       this.luckyBagList = response.rows;
     });
     this.loadSmsTemplates();
+    this.loadSipTemplates();
   },
   methods: {
+
+    loadSipTemplates() {
+      if (this.aiSipCallTaskList.length > 0) {
+          return; // 如果已经有数据,不再重复加载
+      }
+
+      listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+          this.aiSipCallTaskList = response.rows || response.data || [];
+          if (this.aiSipCallTaskList.length === 0) {
+          }
+      }).catch(error => {
+          console.error('加载sip模板失败:', error);
+          this.$message.error('加载sip模板失败');
+      });
+    },
+
+      // 获取sip模板信息
+    getAiSipCallTask() {
+      if (this.aiSipCallTaskList.length === 0) {
+          listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+              this.aiSipCallTaskList = response.rows || response.data || [];
+          }).catch(error => {
+              console.error('加载sip模板失败:', error);
+              this.$message.error('加载sip模板失败');
+          });
+      }
+    },
+
     getLuckyBagStatus(content) {
       const selectedLuckyBag = this.luckyBagList.find(item => item.id === content.luckyBagId);
       if (selectedLuckyBag) {
@@ -1100,7 +1144,7 @@ export default {
       this.$refs["msgForm"].validate(valid => {
         if (valid) {
 
-          if (this.msgForm.draftStrategy==1) {
+          if (this.msgForm.draftStrategy==1 || this.msgForm.draftStrategy == 4) {
 
             if (!!hasLiveSetting && (this.msgForm.courseId != null && this.msgForm.courseId != '')) {
               return this.$message.error("直播间不能选取课程");
@@ -1137,76 +1181,83 @@ export default {
               return this.$message.error("消息类型不能为空")
             }
 
-            for (let i = 0; i < this.setting.length; i++) {
-              if ((this.setting[i].contentType == 1 || this.setting[i].contentType == 15) && (this.setting[i].value == null || this.setting[i].value == "")) {
-                return this.$message.error("内容不能为空")
-              }
-              if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
-                return this.$message.error("图片不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkTitle == null || this.setting[i].linkTitle == "")) {
-                return this.$message.error("链接标题不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkDescribe == null || this.setting[i].linkDescribe == "")) {
-                return this.$message.error("链接描述不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkImageUrl == null || this.setting[i].linkImageUrl == "")) {
-                return this.$message.error("链接图片不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
-                return this.$message.error("链接地址不能为空")
+            if(this.msgForm.draftStrategy == 4){
+              if (this.msgForm.aiCustomerRoleId == null || this.msgForm.aiCustomerRoleId == '') {
+                  return this.$message.error("请选择外呼任务")
               }
+            }
+            if(this.msgForm.draftStrategy != 4) {
+                for (let i = 0; i < this.setting.length; i++) {
+                    if ((this.setting[i].contentType == 1 || this.setting[i].contentType == 15) && (this.setting[i].value == null || this.setting[i].value == "")) {
+                        return this.$message.error("内容不能为空")
+                    }
+                    if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
+                        return this.$message.error("图片不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkTitle == null || this.setting[i].linkTitle == "")) {
+                        return this.$message.error("链接标题不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkDescribe == null || this.setting[i].linkDescribe == "")) {
+                        return this.$message.error("链接描述不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkImageUrl == null || this.setting[i].linkImageUrl == "")) {
+                        return this.$message.error("链接图片不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
+                        return this.$message.error("链接地址不能为空")
+                    }
 
-              if ((this.setting[i].contentType == 4 || this.setting[i].contentType == 10 || this.setting[i].contentType == 17 || this.setting[i].contentType == 23 || this.setting[i].contentType == 24) && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
-                return this.$message.error("小程序消息标题不能为空")
-              }
-              if ((this.setting[i].contentType == 4 || this.setting[i].contentType == 10 || this.setting[i].contentType == 17 || this.setting[i].contentType == 23 || this.setting[i].contentType == 24) && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
-                return this.$message.error("小程序封面地址不能为空")
-              }
+                    if ((this.setting[i].contentType == 4 || this.setting[i].contentType == 10 || this.setting[i].contentType == 17 || this.setting[i].contentType == 23 || this.setting[i].contentType == 24) && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
+                        return this.$message.error("小程序消息标题不能为空")
+                    }
+                    if ((this.setting[i].contentType == 4 || this.setting[i].contentType == 10 || this.setting[i].contentType == 17 || this.setting[i].contentType == 23 || this.setting[i].contentType == 24) && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
+                        return this.$message.error("小程序封面地址不能为空")
+                    }
 
-              if (this.setting[i].contentType == 10 && (this.setting[i].miniprogramPage == null || this.setting[i].miniprogramPage == "")) {
-                return this.$message.error("小程序page地址不能为空")
-              }
+                    if (this.setting[i].contentType == 10 && (this.setting[i].miniprogramPage == null || this.setting[i].miniprogramPage == "")) {
+                        return this.$message.error("小程序page地址不能为空")
+                    }
 
-              if (this.setting[i].contentType == 5 && (this.setting[i].fileUrl == null || this.setting[i].fileUrl == "")) {
-                return this.$message.error("文件不能为空")
-              }
-              if (this.setting[i].contentType == 6 && (this.setting[i].videoUrl == null || this.setting[i].videoUrl == "")) {
-                return this.$message.error("视频不能为空")
-              }
-              if ((this.setting[i].contentType == 7 || this.setting[i].contentType == 16) && (this.setting[i].value == null || this.setting[i].value == "")) {
-                return this.$message.error("语音不能为空")
-              }
-              // 添加对福袋的验证
-              if (this.setting[i].contentType == 14 && (this.setting[i].luckyBagId == null || this.setting[i].luckyBagId === "")) {
-                return this.$message.error("福袋不能为空")
-              }
-              if ((this.setting[i].contentType == 12 || this.setting[i].contentType == 24) && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
-                return this.$message.error("直播间不能为空");
-              }
-              if (this.setting[i].contentType == 20 && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
-                return this.$message.error("直播间不能为空");
-              }
-              if (this.setting[i].contentType == 20 && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
-                return this.$message.error("标题不能为空");
-              }
-              if (this.setting[i].contentType == 20 && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
-                return this.$message.error("封面不能为空");
-              }
-              // 短信模板验证
-              if (this.setting[i].contentType == 21 && (this.setting[i].smsTemplateId == null || this.setting[i].smsTemplateId === "")) {
-                return this.$message.error("短信模板不能为空")
-              }else {
-                if (this.setting[i].contentType == 21 && this.setting[i].smsTemplateId) {
-                  // 根据选中的短信模板ID查找对应的模板信息
-                  const selectedTemplate = this.smsTemplateList.find(template => template.tempId === this.setting[i].smsTemplateId);
-                  if (selectedTemplate) {
-                    // 设置短信模板的相关字段
-                    this.$set(this.setting[i], 'smsTemplateCode', selectedTemplate.tempCode || '');
-                    this.$set(this.setting[i], 'smsTemplateTitle', selectedTemplate.title || '');
-                  }
+                    if (this.setting[i].contentType == 5 && (this.setting[i].fileUrl == null || this.setting[i].fileUrl == "")) {
+                        return this.$message.error("文件不能为空")
+                    }
+                    if (this.setting[i].contentType == 6 && (this.setting[i].videoUrl == null || this.setting[i].videoUrl == "")) {
+                        return this.$message.error("视频不能为空")
+                    }
+                    if ((this.setting[i].contentType == 7 || this.setting[i].contentType == 16) && (this.setting[i].value == null || this.setting[i].value == "")) {
+                        return this.$message.error("语音不能为空")
+                    }
+                    // 添加对福袋的验证
+                    if (this.setting[i].contentType == 14 && (this.setting[i].luckyBagId == null || this.setting[i].luckyBagId === "")) {
+                        return this.$message.error("福袋不能为空")
+                    }
+                    if ((this.setting[i].contentType == 12 || this.setting[i].contentType == 24) && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
+                        return this.$message.error("直播间不能为空");
+                    }
+                    if (this.setting[i].contentType == 20 && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
+                        return this.$message.error("直播间不能为空");
+                    }
+                    if (this.setting[i].contentType == 20 && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
+                        return this.$message.error("标题不能为空");
+                    }
+                    if (this.setting[i].contentType == 20 && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
+                        return this.$message.error("封面不能为空");
+                    }
+                    // 短信模板验证
+                    if (this.setting[i].contentType == 21 && (this.setting[i].smsTemplateId == null || this.setting[i].smsTemplateId === "")) {
+                        return this.$message.error("短信模板不能为空")
+                    } else {
+                        if (this.setting[i].contentType == 21 && this.setting[i].smsTemplateId) {
+                            // 根据选中的短信模板ID查找对应的模板信息
+                            const selectedTemplate = this.smsTemplateList.find(template => template.tempId === this.setting[i].smsTemplateId);
+                            if (selectedTemplate) {
+                                // 设置短信模板的相关字段
+                                this.$set(this.setting[i], 'smsTemplateCode', selectedTemplate.tempCode || '');
+                                this.$set(this.setting[i], 'smsTemplateTitle', selectedTemplate.title || '');
+                            }
+                        }
+                    }
                 }
-              }
             }
 
             // // 短信模板数据映射

+ 101 - 49
src/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue

@@ -17,10 +17,11 @@
             <el-radio :label="1">正常群发</el-radio>
             <el-radio :label="2">清除草稿</el-radio>
             <el-radio :label="3">发送草稿</el-radio>
+            <el-radio :label="4">sip外呼</el-radio>
           </el-radio-group>
         </el-form-item>
 
-        <el-form-item label="选择课程" v-if="msgForm.draftStrategy==1" >
+        <el-form-item label="选择课程" v-if="msgForm.draftStrategy==1 || msgForm.draftStrategy==4" >
           <el-select  v-model="msgForm.courseId" placeholder="请选择课程" style=" margin-right: 10px;" size="mini" filterable clearable @change="courseChange()">
             <el-option
               v-for="dict in courseList"
@@ -300,7 +301,19 @@
           <el-link type="primary" class="el-icon-plus" :underline="false" @click='addSetList()'  >添加内容</el-link>
 
         </el-form-item>
-        <el-form-item label="发送时间" prop="sendTime" v-if="msgForm.draftStrategy==1" >
+        <el-form-item label="外呼任务" v-if="msgForm.draftStrategy==4" >
+              <el-select v-model="msgForm.aiCustomerRoleId"
+                         placeholder="请选择外呼任务" size="mini"
+                         @change="getAiSipCallTask()" >
+                  <el-option
+                      v-for="template in aiSipCallTaskList"
+                      :key="template.batchId"
+                      :label="template.batchName"
+                      :value="template.batchId"
+                  />
+              </el-select>
+          </el-form-item>
+        <el-form-item label="发送时间" prop="sendTime" v-if="msgForm.draftStrategy==1 || msgForm.draftStrategy==4" >
           <el-date-picker
             class="custom-input"
             v-model="msgForm.sendTime"
@@ -358,6 +371,7 @@ import userVideo from "@/views/qw/userVideo/userVideo";
 import {listReward} from "@/api/qw/luckyBag";
 import {listToLiveNoEnd} from "@/api/live/live";
 import { getQwSopTempVoiceCompanyUser } from "@/api/qw/QwSopTempVoice";
+import { listAiSipCallTask } from '@/api/aiSipCall/aiSipCallTask'
 
 
 export default {
@@ -365,6 +379,7 @@ export default {
   components: {ImageUpload,userVideo},
   data() {
     return {
+      aiSipCallTaskList: [],
       queryParams1: {
         pageNum: 1,
         pageSize: 10,
@@ -539,8 +554,37 @@ export default {
     listReward(this.queryParams1).then(response => {
       this.luckyBagList = response.rows;
     });
+
+    this.loadSipTemplates();
   },
   methods: {
+
+      loadSipTemplates() {
+          if (this.aiSipCallTaskList.length > 0) {
+              return; // 如果已经有数据,不再重复加载
+          }
+
+          listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+              this.aiSipCallTaskList = response.rows || response.data || [];
+              if (this.aiSipCallTaskList.length === 0) {
+              }
+          }).catch(error => {
+              console.error('加载sip模板失败:', error);
+              this.$message.error('加载sip模板失败');
+          });
+      },
+
+      // 获取sip模板信息
+      getAiSipCallTask() {
+          if (this.aiSipCallTaskList.length === 0) {
+              listAiSipCallTask({taskType: 1,status: 0,pageNum: 1,pageSize: 100}).then(response => {
+                  this.aiSipCallTaskList = response.rows || response.data || [];
+              }).catch(error => {
+                  console.error('加载sip模板失败:', error);
+                  this.$message.error('加载sip模板失败');
+              });
+          }
+      },
     getLuckyBagStatus(content) {
       const selectedLuckyBag = this.luckyBagList.find(item => item.id === content.luckyBagId);
       if (selectedLuckyBag) {
@@ -930,7 +974,7 @@ export default {
       this.$refs["msgForm"].validate(valid => {
         if (valid) {
 
-          if (this.msgForm.draftStrategy==1){
+          if (this.msgForm.draftStrategy==1 || this.msgForm.draftStrategy == 4 ){
 
             this.msgForm.setting = JSON.stringify(this.setting);
             let jsonSetting = JSON.parse(this.msgForm.setting);
@@ -968,53 +1012,61 @@ export default {
               return this.$message.error("消息类型不能为空")
             }
 
-            for (let i = 0; i < this.setting.length; i++) {
-              if (this.setting[i].contentType == 1 && (this.setting[i].value == null || this.setting[i].value == "")) {
-                return this.$message.error("内容不能为空")
-              }
-              if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
-                return this.$message.error("图片不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9  ) && (this.setting[i].linkTitle == null || this.setting[i].linkTitle == "")) {
-                return this.$message.error("链接标题不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ) && (this.setting[i].linkDescribe == null || this.setting[i].linkDescribe == "")) {
-                return this.$message.error("链接描述不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ) && (this.setting[i].linkImageUrl == null || this.setting[i].linkImageUrl == "")) {
-                return this.$message.error("链接图片不能为空")
-              }
-              if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 )&& this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
-                return this.$message.error("链接地址不能为空")
-              }
-
-              if ([4, 10, 17, 23, 24].includes(Number(this.setting[i].contentType)) && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
-                return this.$message.error("小程序消息标题不能为空")
-              }
-              if ([4, 10, 17, 23, 24].includes(Number(this.setting[i].contentType)) && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
-                return this.$message.error("小程序封面地址不能为空")
-              }
-
-              if (this.setting[i].contentType == 10 && (this.setting[i].miniprogramPage == null || this.setting[i].miniprogramPage == "")) {
-                return this.$message.error("小程序page地址不能为空")
-              }
+            if(this.msgForm.draftStrategy == 4){
+                  if (this.msgForm.aiCustomerRoleId == null || this.msgForm.aiCustomerRoleId == '') {
+                      return this.$message.error("请选择外呼任务")
+                  }
+            }
 
-              if (this.setting[i].contentType == 5 && (this.setting[i].fileUrl == null || this.setting[i].fileUrl == "")) {
-                return this.$message.error("文件不能为空")
-              }
-              if (this.setting[i].contentType == 6 && (this.setting[i].videoUrl == null || this.setting[i].videoUrl == "")) {
-                return this.$message.error("视频不能为空")
-              }
-              if (this.setting[i].contentType == 7 && (this.setting[i].value == null || this.setting[i].value == "")) {
-                return this.$message.error("语音不能为空")
-              }
-              // 添加对福袋的验证
-              if (this.setting[i].contentType == 14 && (this.setting[i].luckyBagId == null || this.setting[i].luckyBagId === "")) {
-                return this.$message.error("福袋不能为空")
-              }
-              if ((this.setting[i].contentType == 12 || this.setting[i].contentType == 24) && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
-                return this.$message.error("直播间不能为空");
-              }
+            if(this.msgForm.draftStrategy != 4) {
+                for (let i = 0; i < this.setting.length; i++) {
+                    if (this.setting[i].contentType == 1 && (this.setting[i].value == null || this.setting[i].value == "")) {
+                        return this.$message.error("内容不能为空")
+                    }
+                    if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
+                        return this.$message.error("图片不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkTitle == null || this.setting[i].linkTitle == "")) {
+                        return this.$message.error("链接标题不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkDescribe == null || this.setting[i].linkDescribe == "")) {
+                        return this.$message.error("链接描述不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && (this.setting[i].linkImageUrl == null || this.setting[i].linkImageUrl == "")) {
+                        return this.$message.error("链接图片不能为空")
+                    }
+                    if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9) && this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
+                        return this.$message.error("链接地址不能为空")
+                    }
+
+                    if ([4, 10, 17, 23, 24].includes(Number(this.setting[i].contentType)) && (this.setting[i].miniprogramTitle == null || this.setting[i].miniprogramTitle == "")) {
+                        return this.$message.error("小程序消息标题不能为空")
+                    }
+                    if ([4, 10, 17, 23, 24].includes(Number(this.setting[i].contentType)) && (this.setting[i].miniprogramPicUrl == null || this.setting[i].miniprogramPicUrl == "")) {
+                        return this.$message.error("小程序封面地址不能为空")
+                    }
+
+                    if (this.setting[i].contentType == 10 && (this.setting[i].miniprogramPage == null || this.setting[i].miniprogramPage == "")) {
+                        return this.$message.error("小程序page地址不能为空")
+                    }
+
+                    if (this.setting[i].contentType == 5 && (this.setting[i].fileUrl == null || this.setting[i].fileUrl == "")) {
+                        return this.$message.error("文件不能为空")
+                    }
+                    if (this.setting[i].contentType == 6 && (this.setting[i].videoUrl == null || this.setting[i].videoUrl == "")) {
+                        return this.$message.error("视频不能为空")
+                    }
+                    if (this.setting[i].contentType == 7 && (this.setting[i].value == null || this.setting[i].value == "")) {
+                        return this.$message.error("语音不能为空")
+                    }
+                    // 添加对福袋的验证
+                    if (this.setting[i].contentType == 14 && (this.setting[i].luckyBagId == null || this.setting[i].luckyBagId === "")) {
+                        return this.$message.error("福袋不能为空")
+                    }
+                    if ((this.setting[i].contentType == 12 || this.setting[i].contentType == 24) && (this.setting[i].liveId == null || this.setting[i].liveId == "")) {
+                        return this.$message.error("直播间不能为空");
+                    }
+                }
             }
           }