فهرست منبع

Merge branch 'master' of http://1.14.104.71:10880/root/ylrz_his_scrm_companyUI

caoliqin 2 روز پیش
والد
کامیت
1450d9092f
81فایلهای تغییر یافته به همراه13326 افزوده شده و 973 حذف شده
  1. 2 0
      .env.prod-sxjz
  2. 9 0
      src/api/aiAddwxSop/api.js
  3. 34 34
      src/api/aiSipCall/aiSipCallGateway.js
  4. 22 13
      src/api/aiSipCall/aiSipCallOutboundCdr.js
  5. 44 0
      src/api/aiSipCall/aiSipCallPhone.js
  6. 17 9
      src/api/aiSipCall/aiSipCallUser.js
  7. 8 0
      src/api/aiSipCall/aiSipCallVoiceTtsAliyun.js
  8. 1001 0
      src/api/aiSipCall/softPhone.js
  9. 53 0
      src/api/aiSopCall/aiSopCall.js
  10. 61 0
      src/api/aiSopCall/aiSopCallKnowledgeBase.js
  11. 61 0
      src/api/aiSopCall/aiSopCallRole.js
  12. 53 0
      src/api/aiSopCall/aiSopCallStaffModels.js
  13. 97 0
      src/api/aiSopCall/aiSopCallUser.js
  14. 9 0
      src/api/hisStore/storeOrder.js
  15. 39 0
      src/api/life/vest.js
  16. 63 0
      src/api/life/video.js
  17. 12 2
      src/api/live/live.js
  18. 44 1
      src/api/qw/QwSopTempVoice.js
  19. 53 0
      src/api/qw/QwSopTempVoiceGroup.js
  20. 33 0
      src/api/qw/externalContact.js
  21. 53 0
      src/api/qw/feishuAccount.js
  22. 99 0
      src/api/qw/groupVoiceMsg.js
  23. 71 0
      src/api/qw/groupVoiceMsgUser.js
  24. 78 0
      src/api/qw/groupVoiceMsgUserDetails.js
  25. 10 0
      src/api/qw/sopLogs.js
  26. 9 0
      src/api/store/user.js
  27. 87 31
      src/components/ImageUpload/index.vue
  28. 35 20
      src/components/VideoUpload/index.vue
  29. 396 0
      src/components/chat/ChatRecordViewer.vue
  30. 13 0
      src/router/index.js
  31. 572 0
      src/views/aiAddwxSop/canvas.vue
  32. 22 11
      src/views/aiAddwxSop/tagTemplateBind.vue
  33. 2 302
      src/views/aiAddwxSop/template.vue
  34. 1 1
      src/views/aiAddwxSop/userWorkflow.vue
  35. 65 8
      src/views/company/companyUser/index.vue
  36. 213 0
      src/views/company/downloadAppCount/index.vue
  37. 8 0
      src/views/course/courseUserStatistics/qw/my.vue
  38. 6 0
      src/views/hisStore/components/productAfterSalesOrder.vue
  39. 79 0
      src/views/hisStore/components/productOrder.vue
  40. 6 0
      src/views/hisStore/storeAfterSales/list.vue
  41. 33 4
      src/views/hisStore/storeOrder/healthStoreList.vue
  42. 31 1
      src/views/hisStore/storeOrder/index.vue
  43. 172 0
      src/views/life/vest/index.vue
  44. 680 0
      src/views/life/video/index.vue
  45. 101 2
      src/views/live/live/index.vue
  46. 10 0
      src/views/live/liveConfig/liveCoupon.vue
  47. 27 17
      src/views/live/liveConfig/liveReplay.vue
  48. 66 8
      src/views/live/liveConsole/LiveConsole.vue
  49. 262 0
      src/views/qw/QwSopTempVoiceGroup/index.vue
  50. 1 1
      src/views/qw/QwWorkTaskNew/deptWorkTask.vue
  51. 1 0
      src/views/qw/QwWorkTaskNew/index.vue
  52. 1 1
      src/views/qw/QwWorkTaskNew/qwWorkTask.vue
  53. 54 17
      src/views/qw/contactWay/index.vue
  54. 96 1
      src/views/qw/externalContact/deptIndex.vue
  55. 117 17
      src/views/qw/externalContact/index.vue
  56. 125 20
      src/views/qw/externalContact/myExternalContact.vue
  57. 377 0
      src/views/qw/feishuAccount/index.vue
  58. 1207 0
      src/views/qw/groupVoiceMsg/customerGroupDetails.vue
  59. 577 0
      src/views/qw/groupVoiceMsg/detailsRecord/index.vue
  60. 1611 0
      src/views/qw/groupVoiceMsg/detailsRecord/statistics.vue
  61. 1151 0
      src/views/qw/groupVoiceMsg/index.vue
  62. 441 0
      src/views/qw/qwSopTempVoice/index.vue
  63. 2 2
      src/views/qw/qwUserVoiceLog/index.vue
  64. 29 2
      src/views/qw/sop/deptSop.vue
  65. 29 2
      src/views/qw/sop/mySop.vue
  66. 29 2
      src/views/qw/sop/sop.vue
  67. 1194 0
      src/views/qw/sopLogs/sipSopLogsList.vue
  68. 11 10
      src/views/qw/sopLogs/sopLogsList.vue
  69. 70 14
      src/views/qw/sopTemp/updateSopTemp.vue
  70. 70 50
      src/views/qw/sopTemp/updateSopTempSXJZ.vue
  71. 192 81
      src/views/qw/sopUserLogsInfo/sendMsgOpenTool.vue
  72. 187 59
      src/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue
  73. 75 13
      src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetails.vue
  74. 9 9
      src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetailsOld.vue
  75. 0 1
      src/views/qw/user/cuDeptIdIndex.vue
  76. 0 1
      src/views/qw/user/index.vue
  77. 0 1
      src/views/qw/user/myIndex.vue
  78. 203 203
      src/views/qw/user/qwUserList.vue
  79. 248 0
      src/views/qw/user/qwUserListWay.vue
  80. 211 0
      src/views/qw/welcome/welComeAddAIVoice.vue
  81. 86 2
      src/views/store/components/userDetailsTemp.vue

+ 2 - 0
.env.prod-sxjz

@@ -23,3 +23,5 @@ VUE_APP_PROJECT_FROM=sxjz
 
 # 路由懒加载
 VUE_CLI_BABEL_TRANSPILE_MODULES = true
+
+VUE_APP_IPAD_IS_NEW_VERSION = true

+ 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
+};

+ 53 - 0
src/api/aiSopCall/aiSopCall.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+// 查询AI 外呼角色列表
+export function listAiSopCall(query) {
+  return request({
+    url: '/aiSopCall/aiSopCall/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询AI 外呼角色详细
+export function getAiSopCall(id) {
+  return request({
+    url: '/aiSopCall/aiSopCall/' + id,
+    method: 'get'
+  })
+}
+
+// 新增AI 外呼角色
+export function addAiSopCall(data) {
+  return request({
+    url: '/aiSopCall/aiSopCall',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改AI 外呼角色
+export function updateAiSopCall(data) {
+  return request({
+    url: '/aiSopCall/aiSopCall',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除AI 外呼角色
+export function delAiSopCall(id) {
+  return request({
+    url: '/aiSopCall/aiSopCall/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出AI 外呼角色
+export function exportAiSopCall(query) {
+  return request({
+    url: '/aiSopCall/aiSopCall/export',
+    method: 'get',
+    params: query
+  })
+}

+ 61 - 0
src/api/aiSopCall/aiSopCallKnowledgeBase.js

@@ -0,0 +1,61 @@
+import request from '@/utils/request'
+
+// 查询AI外呼知识库列表
+export function listAiSopCallKnowledgeBase(query) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询AI外呼知识库详细
+export function getAiSopCallKnowledgeBase(id) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase/' + id,
+    method: 'get'
+  })
+}
+
+// 新增AI外呼知识库
+export function addAiSopCallKnowledgeBase(data) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改AI外呼知识库
+export function updateAiSopCallKnowledgeBase(data) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除AI外呼知识库
+export function delAiSopCallKnowledgeBase(id) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出AI外呼知识库
+export function exportAiSopCallKnowledgeBase(query) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase/export',
+    method: 'get',
+    params: query
+  })
+}
+// 查询AI外呼知识库列表
+export function idAndNameList(query) {
+  return request({
+    url: '/company/aiSopCallKnowledgeBase/idAndNameList',
+    method: 'get',
+    params: query
+  })
+}

+ 61 - 0
src/api/aiSopCall/aiSopCallRole.js

@@ -0,0 +1,61 @@
+import request from '@/utils/request'
+
+// 查询AI外呼角色列表
+export function listRole(query) {
+  return request({
+    url: '/aiSopCall/role/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询AI外呼角色详细
+export function getRole(id) {
+  return request({
+    url: '/aiSopCall/role/' + id,
+    method: 'get'
+  })
+}
+
+// 新增AI外呼角色
+export function addRole(data) {
+  return request({
+    url: '/aiSopCall/role',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改AI外呼角色
+export function updateRole(data) {
+  return request({
+    url: '/aiSopCall/role',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除AI外呼角色
+export function delRole(id) {
+  return request({
+    url: '/aiSopCall/role/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出AI外呼角色
+export function exportRole(query) {
+  return request({
+    url: '/aiSopCall/role/export',
+    method: 'get',
+    params: query
+  })
+}
+
+// 复制AI外呼角色
+export function copyRole(id) {
+  return request({
+    url: '/aiSopCall/role/copyRole/' + id,
+    method: 'get'
+  })
+}

+ 53 - 0
src/api/aiSopCall/aiSopCallStaffModels.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+// 查询外呼模型模型列表
+export function listAiSopCallStaffModels(query) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询外呼模型模型详细
+export function getAiSopCallStaffModels(id) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels/' + id,
+    method: 'get'
+  })
+}
+
+// 新增外呼模型模型
+export function addAiSopCallStaffModels(data) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改外呼模型模型
+export function updateAiSopCallStaffModels(data) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除外呼模型模型
+export function delAiSopCallStaffModels(id) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出外呼模型模型
+export function exportAiSopCallStaffModels(query) {
+  return request({
+    url: '/aiSopCallStaffModels/aiSopCallStaffModels/export',
+    method: 'get',
+    params: query
+  })
+}

+ 97 - 0
src/api/aiSopCall/aiSopCallUser.js

@@ -0,0 +1,97 @@
+import request from '@/utils/request'
+
+// 查询员工角色列表
+export function listAiSopCallUser(query) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询员工角色详细
+export function getAiSopCallUser(id) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/' + id,
+    method: 'get'
+  })
+}
+
+// 新增员工角色
+export function addAiSopCallUser(data) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改员工角色
+export function updateAiSopCallUser(data) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除员工角色
+export function delAiSopCallUser(id) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出员工角色
+export function exportAiSopCallUser(query) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/export',
+    method: 'get',
+    params: query
+  })
+}
+
+
+// 获取模型列表(分页查询)
+export function getMode(params) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/getMode',
+    method: 'get',
+    params: params
+  })
+}
+
+
+// 获取音色列表
+export function getVoicesDict() {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/getVoicesDict',
+    method: 'get',
+  })
+}
+
+// 获取指定模型的语音标识列表
+export function getVoicesDictByModelId(params) {
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/getVoicesDictByModelId',
+    method: 'get',
+    params: params
+  })
+}
+
+// 保存本地语音
+export function saveUploadOssFile(file, fileName) {
+  const formData = new FormData();
+  formData.append('file', file);
+  formData.append('fileName', fileName);
+
+  return request({
+    url: '/aiSopCallUser/aiSopCallUser/saveUploadOssFile',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}

+ 9 - 0
src/api/hisStore/storeOrder.js

@@ -94,6 +94,15 @@ export function updateStoreOrder(data) {
   })
 }
 
+// 修改物流编号/发货人手机号
+export function updateDeliveryId(data) {
+  return request({
+    url: '/store/store/storeOrder/editDeliveryId',
+    method: 'put',
+    data: data
+  })
+}
+
 // 删除订单
 export function delStoreOrder(id) {
   return request({

+ 39 - 0
src/api/life/vest.js

@@ -0,0 +1,39 @@
+import request from '@/utils/request'
+
+export function listLifeVest(query) {
+  return request({
+    url: '/life/vest/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getLifeVest(id) {
+  return request({
+    url: '/life/vest/' + id,
+    method: 'get'
+  })
+}
+
+export function addLifeVest(data) {
+  return request({
+    url: '/life/vest',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateLifeVest(data) {
+  return request({
+    url: '/life/vest',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delLifeVest(id) {
+  return request({
+    url: '/life/vest/' + id,
+    method: 'delete'
+  })
+}

+ 63 - 0
src/api/life/video.js

@@ -0,0 +1,63 @@
+import request from '@/utils/request'
+
+export function listLifeVideo(query) {
+  return request({
+    url: '/life/video/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getLifeVideo(videoId) {
+  return request({
+    url: '/life/video/' + videoId,
+    method: 'get'
+  })
+}
+
+export function addLifeVideo(data) {
+  return request({
+    url: '/life/video',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateLifeVideo(data) {
+  return request({
+    url: '/life/video',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delLifeVideo(videoId) {
+  return request({
+    url: '/life/video/' + videoId,
+    method: 'delete'
+  })
+}
+
+export function shelfLifeVideo(data) {
+  return request({
+    url: '/life/video/shelf',
+    method: 'put',
+    data: data
+  })
+}
+
+export function searchLifeVideoProduct(query) {
+  return request({
+    url: '/life/video/product/search',
+    method: 'get',
+    params: query
+  })
+}
+
+export function batchAddLifeVideo(data) {
+  return request({
+    url: '/life/video/batch',
+    method: 'post',
+    data: data
+  })
+}

+ 12 - 2
src/api/live/live.js

@@ -52,10 +52,20 @@ export function exportLive(query) {
   })
 }
 // 查询未结束的直播间
-export function listToLiveNoEnd() {
+export function listToLiveNoEnd(query) {
   return request({
     url: '/live/live/listToLiveNoEnd',
-    method: 'get'
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询直播间分组列表
+export function listLiveGroupType(query) {
+  return request({
+    url: '/live/liveGroupType/list',
+    method: 'get',
+    params: query
   })
 }
 

+ 44 - 1
src/api/qw/QwSopTempVoice.js

@@ -3,9 +3,52 @@ import request from '@/utils/request'
 // 查询销售语音文件列表
 export function getQwSopTempVoiceCompanyUser(data) {
     return request({
-        url: '/qw/SopTempVoice/getQwUserVoice',
+        url: '/qw/QwSopTempVoice/getQwUserVoice',
         method: 'post',
         data: data
     })
 }
 
+// 查询模板对应的销售语音文件列表
+export function listQwSopTempVoice(query) {
+    return request({
+        url: '/qw/QwSopTempVoice/list',
+        method: 'get',
+        params: query
+    })
+}
+
+
+// 新增模板对应的销售语音文件
+export function addQwSopTempVoice(data) {
+    return request({
+        url: '/qw/QwSopTempVoice',
+        method: 'post',
+        data: data
+    })
+}
+
+export function removeSidebar(data) {
+    return request({
+        url: '/qw/QwSopTempVoice/removeSidebar',
+        method: 'post',
+        data: data
+    })
+}
+
+// 修改模板对应的销售语音文件
+export function updateQwSopTempVoice(data) {
+    return request({
+        url: '/qw/QwSopTempVoice',
+        method: 'put',
+        data: data
+    })
+}
+
+// 删除模板对应的销售语音文件
+export function delQwSopTempVoice(id) {
+    return request({
+        url: '/qw/QwSopTempVoice/' + id,
+        method: 'delete'
+    })
+}

+ 53 - 0
src/api/qw/QwSopTempVoiceGroup.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+// 查询语音分类-主要是区分侧边栏语音类型列表
+export function listQwSopTempVoiceGroup(query) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询语音分类-主要是区分侧边栏语音类型详细
+export function getQwSopTempVoiceGroup(id) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup/' + id,
+    method: 'get'
+  })
+}
+
+// 新增语音分类-主要是区分侧边栏语音类型
+export function addQwSopTempVoiceGroup(data) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改语音分类-主要是区分侧边栏语音类型
+export function updateQwSopTempVoiceGroup(data) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除语音分类-主要是区分侧边栏语音类型
+export function delQwSopTempVoiceGroup(id) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出语音分类-主要是区分侧边栏语音类型
+export function exportQwSopTempVoiceGroup(query) {
+  return request({
+    url: '/qw/QwSopTempVoiceGroup/export',
+    method: 'get',
+    params: query
+  })
+}

+ 33 - 0
src/api/qw/externalContact.js

@@ -194,6 +194,15 @@ export function updateExternalContactStatus(query) {
     params: query
   })
 }
+
+export function extChangeLevel(data) {
+    return request({
+        url: '/qw/externalContact/changeLevel',
+        method: 'post',
+        data: data
+    })
+}
+
 // 修改企业微信客户
 export function editStatus(data) {
   return request({
@@ -316,6 +325,7 @@ export function getCustomerCourseSop(query) {
 }
 
 
+
 export function batchUpdateExternalContactNotes(data) {
   return request({
     url: '/qw/externalContact/batchUpdateExternalContactNotes',
@@ -359,3 +369,26 @@ export function companyTransfer(data) {
     data: data
   })
 }
+
+/**
+ * 查询销售的企微客户数据
+ */
+export function getSalesAppDownloadStats(query) {
+    return request({
+        url: '/qw/externalContact/getSalesAppDownloadStats',
+        method: 'get',
+        params: query
+    })
+}
+
+/**
+ * 导出销售的企微客户数据
+ */
+export function exportSalesAppDownloadStats(query) {
+    return request({
+        url: '/qw/externalContact/exportSalesAppDownloadStats',
+        method: 'get',
+        params: query
+    })
+}
+

+ 53 - 0
src/api/qw/feishuAccount.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+// 查询账号列表
+export function listFeishuAccount(query) {
+  return request({
+    url: '/qw/feishuAccount/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询账号详情
+export function getFeishuAccount(id) {
+  return request({
+    url: '/qw/feishuAccount/' + id,
+    method: 'get'
+  })
+}
+
+// 新增账号
+export function addFeishuAccount(data) {
+  return request({
+    url: '/qw/feishuAccount',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改账号
+export function updateFeishuAccount(data) {
+  return request({
+    url: '/qw/feishuAccount',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除账号
+export function delFeishuAccount(id) {
+  return request({
+    url: '/qw/feishuAccount/' + id,
+    method: 'delete'
+  })
+}
+
+// 修改状态
+export function changeFeishuAccountStatus(data) {
+  return request({
+    url: '/qw/feishuAccount/changeStatus',
+    method: 'put',
+    data: data
+  })
+}

+ 99 - 0
src/api/qw/groupVoiceMsg.js

@@ -0,0 +1,99 @@
+import request from '@/utils/request'
+
+// 查询客户群发记录主列表
+export function listGroupVoiceMsg(query) {
+  return request({
+    url: '/qw/groupVoiceMsg/list',
+    method: 'get',
+    params: query
+  })
+}
+// 查询客户群发记录主列表
+export function myList(query) {
+  return request({
+    url: '/qw/groupVoiceMsg/myList',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询客户群发记录主详细
+export function getGroupVoiceMsg(id) {
+  return request({
+    url: '/qw/groupVoiceMsg/' + id,
+    method: 'get'
+  })
+}
+
+
+// 新增客户群发记录主
+export function addGroupVoiceMsg(data) {
+  return request({
+    url: '/qw/groupVoiceMsg',
+    method: 'post',
+    data: data
+  })
+}
+
+/** 统计数据详情,已发送,未发送,已接收,未接收等 */
+export function countGroupVoiceMsgUser(msgId){
+  return request({
+    url: '/qw/groupVoiceMsg/getCountGroupVoiceMsgUser/' + msgId,
+    method: 'get'
+  })
+}
+
+
+/** 统计数据详情,已发送群主,送达群聊,未发送群主,未送达群聊等 */
+export function CountGroupVoiceMsgBaseUser(msgId){
+  return request({
+    url: '/qw/groupVoiceMsg/getCountGroupVoiceMsgBaseUser/' + msgId,
+    method: 'get'
+  })
+}
+
+
+/** 客户群发 发送/接收的数据详情 */
+export function CountGroupVoiceMsgUserDetails(query){
+  return request({
+    url: '/qw/groupVoiceMsg/getCountGroupVoiceMsgUserDetails',
+    method: 'get',
+    params: query
+  })
+}
+
+/** 提醒成员群发 */
+
+export function remindGroupVoiceMsg(query){
+  return request({
+    url: '/qw/groupVoiceMsg/remindGroupVoiceMsg',
+    method: 'get',
+    params: query
+  })
+}
+
+// 修改客户群发记录主
+export function updateGroupVoiceMsg(data) {
+  return request({
+    url: '/qw/groupVoiceMsg',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除客户群发记录主
+export function delGroupVoiceMsg(id) {
+  return request({
+    url: '/qw/groupVoiceMsg/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出客户群发记录主
+export function exportGroupVoiceMsg(query) {
+  return request({
+    url: '/qw/groupVoiceMsg/export',
+    method: 'get',
+    params: query
+  })
+}

+ 71 - 0
src/api/qw/groupVoiceMsgUser.js

@@ -0,0 +1,71 @@
+import request from '@/utils/request'
+
+// 查询群发成员发送任务及执行结果反馈记录列表
+export function listGroupVoiceMsgUser(query) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询群发成员发送任务及执行结果反馈记录详细
+export function getGroupVoiceMsgUser(id) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/' + id,
+    method: 'get'
+  })
+}
+
+// 新增群发成员发送任务及执行结果反馈记录
+export function addGroupVoiceMsgUser(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改群发成员发送任务及执行结果反馈记录
+export function updateGroupVoiceMsgUser(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除群发成员发送任务及执行结果反馈记录
+export function delGroupVoiceMsgUser(id) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出群发成员发送任务及执行结果反馈记录
+export function exportGroupVoiceMsgUser(query) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/export',
+    method: 'get',
+    params: query
+  })
+}
+
+/** 刷新/同步 客户群群发 结果 */
+export function refreshResultsGroupVoiceMsgUser(data){
+  return request({
+    url: '/qw/groupVoiceMsgUser/refreshResultsGroupVoiceMsgUser',
+    method: 'post',
+    data: data
+  })
+}
+
+/** 刷新/同步 客户群发 结果 */
+export function refreshResultsVoiceMsgUser(data){
+  return request({
+    url: '/qw/groupVoiceMsgUser/refreshResultsVoiceMsgUser',
+    method: 'post',
+    data: data
+  })
+}

+ 78 - 0
src/api/qw/groupVoiceMsgUserDetails.js

@@ -0,0 +1,78 @@
+import request from '@/utils/request'
+
+// 查询AI外呼客服呼叫详情列表
+export function listDetails(query) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询AI外呼客服呼叫详情详细
+export function getDetails(id) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/' + id,
+    method: 'get'
+  })
+}
+// 查询AI外呼客服呼叫详情详细
+export function getBySessionIdDetails(sessionId) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/queryBySessionId/' + sessionId,
+    method: 'get'
+  })
+}
+
+// 新增AI外呼客服呼叫详情
+export function addDetails(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改AI外呼客服呼叫详情
+export function updateDetails(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除AI外呼客服呼叫详情
+export function delDetails(id) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出AI外呼客服呼叫详情
+export function exportDetails(query) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/export',
+    method: 'get',
+    params: query
+  })
+}
+
+// 导出AI外呼客服呼叫详情
+export function statisticsAiCallDetails(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/statisticsAiCallDetails',
+    method: 'post',
+    data: data
+  })
+}
+// 导出AI外呼客服呼叫详情
+export function exportStatistics(data) {
+  return request({
+    url: '/qw/groupVoiceMsgUser/details/exportStatistics',
+    method: 'post',
+    data: data
+  })
+}
+

+ 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
+    })
+}

+ 9 - 0
src/api/store/user.js

@@ -125,3 +125,12 @@ export function enabledBlackUsers(data) {
   })
 }
 
+// 绑定手机号
+export function bindPhNum(data) {
+  return request({
+    url: '/store/user/bindPhNum',
+    method: 'post',
+    data: data
+  })
+}
+

+ 87 - 31
src/components/ImageUpload/index.vue

@@ -58,6 +58,16 @@ export default {
        type: Number,
       default: 500,
     },
+    // 宽高比,如 "16:9";为空则不校验
+    aspectRatio: {
+      type: String,
+      default: null,
+    },
+    // 宽高比允许误差(相对误差)
+    aspectTolerance: {
+      type: Number,
+      default: 0.05,
+    },
     // 文件类型, 例如['png', 'jpg', 'jpeg']
     fileType: {
       type: Array,
@@ -154,39 +164,85 @@ export default {
         );
         return false;
       }
+      // fileSize 默认 500 表示沿用历史 3MB;显式传入且 <=100 时按 MB 校验
+      const maxMb = (this.fileSize != null && this.fileSize > 0 && this.fileSize <= 100) ? this.fileSize : 3;
       return new Promise((resolve, reject) => {
-        if (file.size / 1024 / 1024 > 3) {
-          this.$message.error('上传的图片不能超过3MB');
+        const closeLoading = () => {
+          if (this.loading && typeof this.loading.close === 'function') {
+            this.loading.close();
+            this.loading = null;
+          }
+        };
+        const fail = (msg) => {
+          if (msg) {
+            this.$message.error(msg);
+          }
+          closeLoading();
           reject();
+        };
+        if (file.size / 1024 / 1024 > maxMb) {
+          fail(`上传的图片不能超过${maxMb}MB`);
           return;
         }
-        if (file.size / 1024 / 1024 > 1) {
-          const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
-          // 文件大于1MB时进行压缩
-          this.compressImage(file).then((compressedFile) => {
-            loadingInstance.close();
-            if (compressedFile.size / 1024 > 500) {
-              this.$message.error('图片压缩后仍大于500KB');
-              reject();
-            } else {
-              // this.$message.success(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
-              console.log(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
-              console.log(`最终内存大小为: ${(compressedFile.size/1024).toFixed(2)}KB`);
-              resolve(compressedFile);
-            }
-          }).catch((err) => {
-            loadingInstance.close();
-            console.error(err);
-            reject();
+        const afterRatioCheck = () => {
+          // 校验通过后再进入「上传中」态
+          this.loading = this.$loading({
+            lock: true,
+            text: "上传中",
+            background: "rgba(0, 0, 0, 0.7)",
           });
+          if (file.size / 1024 / 1024 > 1 && maxMb > 1) {
+            const loadingInstance = Loading.service({ text: '图片内存过大正在压缩图片...' });
+            this.compressImage(file).then((compressedFile) => {
+              loadingInstance.close();
+              if (compressedFile.size / 1024 / 1024 > maxMb) {
+                fail(`图片压缩后仍大于${maxMb}MB`);
+              } else {
+                console.log(`图片压缩成功,最终质量为: ${this.finalQuality.toFixed(2)}`);
+                console.log(`最终内存大小为: ${(compressedFile.size/1024).toFixed(2)}KB`);
+                resolve(compressedFile);
+              }
+            }).catch((err) => {
+              loadingInstance.close();
+              console.error(err);
+              fail();
+            });
+          } else {
+            resolve(file);
+          }
+        };
+        if (this.aspectRatio) {
+          const parts = String(this.aspectRatio).split(':');
+          const rw = Number(parts[0]);
+          const rh = Number(parts[1]);
+          if (!rw || !rh) {
+            afterRatioCheck();
+            return;
+          }
+          const expected = rw / rh;
+          const reader = new FileReader();
+          reader.onload = (e) => {
+            const img = new Image();
+            img.onload = () => {
+              const actual = img.width / img.height;
+              if (Math.abs(actual - expected) / expected > this.aspectTolerance) {
+                fail(`封面比例须为 ${this.aspectRatio}(当前约 ${(actual).toFixed(2)})`);
+                return;
+              }
+              afterRatioCheck();
+            };
+            img.onerror = () => {
+              fail('无法读取图片尺寸');
+            };
+            img.src = e.target.result;
+          };
+          reader.onerror = () => {
+            fail('无法读取图片');
+          };
+          reader.readAsDataURL(file);
         } else {
-          resolve(file);
+          afterRatioCheck();
         }
-        this.loading = this.$loading({
-          lock: true,
-          text: "上传中",
-          background: "rgba(0, 0, 0, 0.7)",
-        });
       });
       // if (this.fileSize) {
       //   const isLt = file.size / 1024  < this.fileSize;
@@ -195,7 +251,7 @@ export default {
       //     return false;
       //   }
       // }
-      
+
     },
     compressImage(file) {
       return new Promise((resolve, reject) => {
@@ -215,16 +271,16 @@ export default {
 
             let quality = 1; // 初始压缩质量
             let dataURL = canvas.toDataURL('image/jpeg', quality);
-            
+
             // 逐步压缩,直到图片大小小于500KB并且压缩质量不再降低
             while (dataURL.length / 1024 > 500 && quality > 0.1) {
               quality -= 0.01;
               dataURL = canvas.toDataURL('image/jpeg', quality);
             }
             this.finalQuality = quality; // 存储最终的压缩质量
-            
-            if (dataURL.length / 1024 > 500) {
-              reject(new Error('压缩后图片仍然大于500KB'));
+
+            if (dataURL.length / 1024 > 1000) {
+              reject(new Error('压缩后图片仍然大于1000KB'));
               return;
             }
 

+ 35 - 20
src/components/VideoUpload/index.vue

@@ -14,13 +14,13 @@
           :auto-upload="false"
           :key="uploadKey"
         >
-          <el-button slot="trigger" size="small" type="primary" >选取视频</el-button>
+          <el-button slot="trigger" size="small" type="primary" >本地选取</el-button>
           <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">点击上传</el-button>
           <!-- 仅当showControl为true时显示视频库选取按钮 -->
-          <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="success" @click="openVideoLibrary">视频库选取</el-button>
+          <el-button v-if="showControl" style="margin-left: 10px;" size="small" type="warning" @click="openVideoLibrary">素材库选取</el-button>
           <!-- 线路一 -->
           <div class="progress-container">
-            <span class="progress-label">线路一</span>
+            <span class="progress-label">上传进度</span>
             <el-progress
               style="margin-top: 5px;"
               class="progress"
@@ -43,7 +43,8 @@
               status="success">
             </el-progress>
           </div>
-          <div slot="tip" class="el-upload__tip">只能上传mp4文件,且不超过500M</div>
+          <div v-if="uploadLoading" class="el-upload__tip" style="color:#E6A23C">处理中,请稍候…</div>
+          <div slot="tip" class="el-upload__tip">支持本地上传 mp4(≤500M)或从素材库选取</div>
         </el-upload>
       </div>
     </el-form-item>
@@ -123,12 +124,12 @@
 
 <script>
 import {getSignature, uploadHuaWeiObs, uploadHuaWeiVod} from "@/api/common";
-import {getThumbnail} from "@/api/course/userVideo";
+// import {getThumbnail} from "@/api/course/userVideo";
 import TcVod from "vod-js-sdk-v6";
 import { uploadObject } from "@/utils/cos.js";
 import { uploadToOBS } from "@/utils/obs.js";
 import Pagination from "@/components/Pagination";
-import { listVideoResource } from '@/api/course/videoResource';
+// import { listVideoResource } from '@/api/course/videoResource';
 
 export default {
   components: {
@@ -254,19 +255,29 @@ export default {
       if (this.fileList.length < 1) {
         return this.$message.error("请先选取视频,再进行上传");
       }
-      await this.getFirstThumbnail();
-      //同时上传个线路
-      await this.uploadVideoToTxPcdn();
-      await this.uploadVideoToHwObs();
-      this.$emit("update:fileName", this.fileList[0].name);
+      this.uploadLoading = true;
+      this.txProgress = 0;
+      try {
+        await this.getFirstThumbnail();
+        await this.uploadVideoToTxPcdn();
+        try {
+          await this.uploadVideoToHwObs();
+        } catch (e) {
+          console.warn('线路二上传失败', e);
+        }
+        this.$emit("update:fileName", this.fileList[0].name);
+        this.$emit("video-ready");
+      } finally {
+        this.uploadLoading = false;
+      }
     },
     //获取第一帧封面
     async getFirstThumbnail(){
       const file = this.fileList[0].raw;
-      getThumbnail(file).then(response => {
-        console.log("获取到第一帧为封面======>",response.url)
-        this.$emit("update:thumbnail", response.url);
-      })
+      // getThumbnail(file).then(response => {
+      //   console.log("获取到第一帧为封面======>",response.url)
+      //   this.$emit("update:thumbnail", response.url);
+      // })
     },
     //更新华为线路进度条
     updateHwProgress(progress) {
@@ -298,9 +309,11 @@ export default {
         this.$emit("update:videoUrl", line_1);
         this.$emit("update:line_1", line_1);
         // this.$emit("update:line_2", line_2);
+        this.$emit("change");
         this.$message.success("线路一上传成功");
       } catch (error) {
         this.$message.error("线路一上传失败");
+        throw error;
       }
     },
     //上传华为云Obs
@@ -320,6 +333,7 @@ export default {
         this.$message.success("线路二上传成功");
       } catch (error) {
         this.$message.error("线路二上传失败");
+        throw error;
       }
     },
     handleRemove(file, fileList) {
@@ -335,11 +349,11 @@ export default {
     /** 查询视频库列表 */
     getLibraryList() {
       this.libraryLoading = true;
-      listVideoResource(this.libraryQueryParams).then(response => {
-        this.libraryList = response.rows;
-        this.libraryTotal = response.total;
-        this.libraryLoading = false;
-      });
+      // listVideoResource(this.libraryQueryParams).then(response => {
+      //   this.libraryList = response.rows;
+      //   this.libraryTotal = response.total;
+      //   this.libraryLoading = false;
+      // });
     },
     /** 搜索视频库按钮操作 */
     handleLibraryQuery() {
@@ -391,6 +405,7 @@ export default {
 
       // 题目
       this.$emit("selectProjects", this.selectedVideo.projectIds)
+      this.$emit("video-ready");
 
       this.libraryOpen = false;
     },

+ 396 - 0
src/components/chat/ChatRecordViewer.vue

@@ -0,0 +1,396 @@
+<template>
+  <div class="chat-record-viewer">
+    <div v-if="chatData && parsedMessages.length > 0" class="chat-messages">
+      <div
+        v-for="(message, index) in displayedMessages"
+        :key="index"
+        :class="['message', message.role]"
+      >
+        <div class="message-content">
+          <div
+            :class="{ 'content-collapsed': shouldCollapse(index) && !expandedMessages[index] }"
+            :style="{ maxHeight: shouldCollapse(index) && !expandedMessages[index] ? '130px' : 'none' }"
+          >
+            <pre>{{ formatMessageContent(message.content) }}</pre>
+          </div>
+
+          <!-- 将展开按钮移到消息项内部 -->
+          <div v-if="shouldCollapse(index)" class="expand-toggle">
+            <el-button
+              type="text"
+              size="mini"
+              @click="toggleMessageExpand(index)"
+              class="expand-btn"
+            >
+              {{ expandedMessages[index] ? '收起' : '展开' }}
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 正确的无数据提示 -->
+    <div v-if="!chatData || parsedMessages.length === 0" class="no-chat-data">
+      <i class="el-icon-chat-line-round"></i>
+      <p>暂无聊天记录</p>
+    </div>
+
+    <!-- 加载更多按钮应该独立于消息容器 -->
+    <div v-if="hasMoreMessages" class="load-more">
+      <el-button
+        type="text"
+        @click="loadMore"
+        :loading="loadingMore"
+      >
+        加载更多消息 ({{ remainingCount }} 条)
+      </el-button>
+    </div>
+  </div>
+</template>
+
+
+<script>
+export default {
+  name: "ChatRecordViewer",
+  props: {
+    chatData: {
+      type: [String, Object],
+      default: null
+    },
+    initialDisplayCount: {
+      type: Number,
+      default: 7
+    },
+    agentName: {
+      type: String,
+      default: '客服'
+    },
+    customerName: {
+      type: String,
+      default: '客户'
+    }
+  },
+  data() {
+    return {
+      displayCount: this.initialDisplayCount,
+      loadingMore: false,
+      expandedMessages: {}
+    };
+  },
+  computed: {
+    parsedMessages() {
+      if (!this.chatData) return [];
+
+      try {
+        // 如果是字符串,尝试解析为JSON
+        const data = typeof this.chatData === 'string'
+          ? JSON.parse(this.chatData)
+          : this.chatData;
+
+        // 根据不同的数据结构进行处理
+        if (Array.isArray(data)) {
+          return data.map(msg => ({
+            role: msg.role || 'user',
+            content: msg.content || msg.text || '',
+            time: msg.time || msg.timestamp || Date.now()
+          }));
+        } else if (typeof data === 'object') {
+          // 如果是对象,转换为数组
+          return Object.entries(data).map(([key, value]) => ({
+            role: key.includes('user') ? 'user' : 'assistant',
+            content: value,
+            time: Date.now()
+          }));
+        }
+
+        return [];
+      } catch (error) {
+        // 如果不是JSON,按纯文本处理
+        return [{
+          role: 'system',
+          content: this.chatData,
+          time: Date.now()
+        }];
+      }
+    },
+
+    displayedMessages() {
+      return this.parsedMessages.slice(-this.displayCount);
+    },
+
+    hasMoreMessages() {
+      return this.displayCount < this.parsedMessages.length;
+    },
+
+    remainingCount() {
+      return this.parsedMessages.length - this.displayCount;
+    }
+  },
+  methods: {
+    formatTime(timestamp) {
+      if (!timestamp) return '';
+      const date = new Date(timestamp);
+      return date.toLocaleTimeString('zh-CN', {
+        hour: '2-digit',
+        minute: '2-digit'
+      });
+    },
+
+    formatMessageContent(content) {
+      if (!content) return '';
+
+      // 替换[客服]为实际的客服名称
+      let formattedContent = content.replace(/\[客服\]/g, this.agentName || '客服');
+
+      // 替换[客户]为实际的客户名称
+      formattedContent = formattedContent.replace(/\[客户\]/g, this.customerName || '客户');
+
+      return formattedContent;
+    },
+
+    shouldCollapse(index) {
+      // 检查消息内容是否超过8行(默认显示8行)
+      const message = this.displayedMessages[index];
+      if (!message || !message.content) return false;
+
+      // 计算行数(简单估算)
+      const lines = message.content.split('\n').length;
+      const wordsPerLine = 50; // 假设每行约50个字符
+      const estimatedLines = Math.ceil(message.content.length / wordsPerLine);
+
+      return estimatedLines > 8 || lines > 8;
+    },
+
+    toggleMessageExpand(index) {
+      this.$set(this.expandedMessages, index, !this.expandedMessages[index]);
+    },
+
+    loadMore() {
+      this.loadingMore = true;
+      setTimeout(() => {
+        this.displayCount = Math.min(
+          this.displayCount + this.initialDisplayCount,
+          this.parsedMessages.length
+        );
+        this.loadingMore = false;
+      }, 300);
+    }
+  }
+};
+</script>
+
+<style scoped>
+.chat-record-viewer {
+  max-height: 380px;
+  overflow-y: auto;
+  overflow-x: hidden; /* 移除水平滚动条 */
+  width: 100%;
+  padding: 8px 4px; /* 压缩内边距 */
+  box-sizing: border-box;
+}
+
+.chat-messages {
+  padding: 8px; /* 压缩内边距 */
+}
+
+.message {
+  margin-bottom: 16px;
+  padding: 18px 20px;
+  border-radius: 14px;
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
+  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+  border: 1px solid transparent;
+  position: relative;
+  overflow: hidden;
+  max-width: 100%;
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+  line-height: 1.7;
+  background: white;
+  border: 1px solid #e0e0e0;
+}
+
+.message:hover {
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+  transform: translateY(-3px);
+  border-color: #d0d0d0;
+}
+
+.message.user {
+  background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+  border: 1px solid #2196f3;
+}
+
+.message.assistant {
+  background: linear-gradient(135deg, #f1f8e9 0%, #dcedc8 100%);
+  border: 1px solid #4caf50;
+}
+
+.message.system {
+  background: linear-gradient(135deg, #f5f5f5 0%, #eeeeee 100%);
+  border: 1px solid #9e9e9e;
+}
+
+.message-content {
+  position: relative;
+  white-space: pre-wrap;
+  word-break: break-word;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  max-width: 100%;
+}
+
+.message-content pre {
+  margin: 0;
+  font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
+  line-height: 1.7;
+  color: #2c3e50;
+  white-space: pre-wrap;
+  word-break: break-word;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  font-size: 15px;
+  font-weight: 500;
+  letter-spacing: 0.3px;
+  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+  background: linear-gradient(135deg, #ffffff 0%, #f9f9f9 100%);
+  border-radius: 10px;
+  padding: 16px 20px;
+  box-shadow: 0 3px 12px rgba(0, 0, 0, 0.05);
+  border: 1px solid #e0e0e0;
+  transition: all 0.3s ease;
+  border-left: 3px solid #667eea;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+
+.message-content pre:hover {
+  box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
+  transform: translateY(-1px);
+}
+
+.content-collapsed {
+  overflow: hidden;
+  position: relative;
+  transition: all 0.3s ease;
+}
+
+.content-collapsed pre {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: inherit;
+  line-height: 1.6;
+  color: #303133;
+}
+
+.expand-toggle {
+  text-align: center;
+  padding: 10px 0;
+  margin-top: 8px;
+  position: relative;
+  z-index: 2;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  border: 1px solid #e1e8ef;
+}
+
+.expand-toggle .expand-btn {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 12px;
+  padding: 8px 18px;
+  font-size: 14px;
+  font-weight: 600;
+  min-height: 32px;
+  box-shadow: 0 3px 10px rgba(102, 126, 234, 0.3);
+  transition: all 0.3s ease;
+}
+
+.expand-toggle .expand-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 5px 14px rgba(102, 126, 234, 0.4);
+}
+
+.expand-btn {
+  background: #409EFF;
+  color: white;
+  border-radius: 10px; /* 减小圆角 */
+  padding: 3px 10px; /* 压缩内边距 */
+  font-size: 11px; /* 减小字体 */
+  min-height: 24px;
+}
+
+.expand-btn:hover {
+  background: #66b1ff;
+}
+
+.load-more {
+  text-align: center;
+  padding: 16px;
+  border-top: 1px solid #ebeef5;
+  background: linear-gradient(to bottom, rgba(248,250,252,0.9), rgba(248,250,252,1));
+  position: sticky;
+  bottom: 0;
+  z-index: 10;
+  margin-top: 12px;
+  border-radius: 8px;
+  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
+}
+
+.load-more .el-button {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: white;
+  border-radius: 12px;
+  padding: 10px 20px;
+  font-size: 14px;
+  font-weight: 600;
+  min-height: 36px;
+  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+  transition: all 0.3s ease;
+}
+
+.load-more .el-button:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
+}
+
+.load-more .el-button:active {
+  transform: translateY(0);
+}
+
+.no-chat-data {
+  text-align: center;
+  padding: 60px 20px;
+  color: #909399;
+}
+
+.no-chat-data i {
+  font-size: 48px;
+  margin-bottom: 16px;
+  display: block;
+}
+
+.no-chat-data p {
+  margin: 0;
+  font-size: 14px;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .chat-record-viewer {
+    max-height: 300px;
+  }
+
+  .message {
+    padding: 8px;
+  }
+
+  .expand-btn {
+    padding: 2px 8px;
+    font-size: 11px;
+  }
+}
+</style>

+ 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>

+ 65 - 8
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,16 +224,19 @@
                 <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>
 
           <el-table-column label="区域" align="center" prop="addressId">
           </el-table-column>
-          <el-table-column label="创建时间"  sortable align="center" prop="createTime" width="160">
-            <template slot-scope="scope">
-              <span>{{ parseTime(scope.row.createTime) }}</span>
-            </template>
-          </el-table-column>
+<!--          <el-table-column label="创建时间"  sortable align="center" prop="createTime" width="160">-->
+<!--            <template slot-scope="scope">-->
+<!--              <span>{{ parseTime(scope.row.createTime) }}</span>-->
+<!--            </template>-->
+<!--          </el-table-column>-->
+          <el-table-column label="账号过期时间"  sortable align="center" prop="expirationTime" width="160"/>
           <el-table-column label="是否单独注册会员" align="center" prop="isNeedRegisterMember" width="80px">
             <template slot-scope="scope">
               <el-tag
@@ -269,6 +275,14 @@
                 v-if="scope.row.doctorId!=null"
                 @click="handleUnBindUserId(scope.row)"
               >解绑医生</el-button>
+              <el-button
+                v-if="scope.row.unionId"
+                size="mini"
+                type="text"
+                icon="el-icon-edit"
+                @click="unBind(scope.row)"
+                v-hasPermi="['company:user:unBind']"
+              >解绑登录的微信</el-button>
               <el-button
                 v-if="scope.row.qwStatus == 0"
                 size="mini"
@@ -929,7 +943,8 @@ import {
   isAllowedAllRegister, unBindDoctorId, bindDoctorId,updateBatchUserRoles,
   queryFsUserByPhone,
   batchBindCompanyUserId,
-  getBoundUsers
+  getBoundUsers,
+  unBind
 } from "@/api/company/companyUser";
 import { getToken } from "@/utils/auth";
 import { treeselect } from "@/api/company/companyDept";
@@ -2093,6 +2108,26 @@ export default {
         this.getList();
       })
     },
+
+    unBind(row){
+      this.$confirm(
+          '确定要解绑吗?解绑后登录需要重新扫码绑定。',
+          '提示',
+          {
+              confirmButtonText: '确定',
+              cancelButtonText: '取消',
+              type: 'warning'
+          }
+      ).then(() => {
+          unBind(row.userId).then(response => {
+              if (response.code === 200) {
+                  this.msgSuccess("解绑成功");
+              }
+          });
+      }).catch(() => {
+          this.msgInfo("已取消操作");
+      });
+    },
     /** 绑定用户按钮操作 */
     handleBindUser(row) {
       this.bindUserForm.companyUserId = row.userId;
@@ -2330,4 +2365,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>

+ 213 - 0
src/views/company/downloadAppCount/index.vue

@@ -0,0 +1,213 @@
+<template>
+  <div class="app-container">
+    <div class="filter-container">
+      <el-form :model="queryParams" inline>
+
+        <el-form-item label="企微主体" prop="corpId">
+          <el-select v-model="queryParams.corpId" placeholder="企微主体" size="small" @change="updateCorpId()">
+            <el-option
+              v-for="dict in myQwCompanyList"
+              :key="dict.dictValue"
+              :label="dict.dictLabel"
+              :value="dict.dictValue"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="所属部门" prop="deptId">
+          <treeselect style="width:220px" v-model="queryParams.deptId" :options="deptOptions" :show-count="true" placeholder="请选择所属部门" />
+        </el-form-item>
+
+        <el-form-item label="员工">
+          <el-input
+            v-model="queryParams.companyUserName"
+            placeholder="输入员工姓名"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="企微客服">
+          <el-input
+            v-model="queryParams.qwUserName"
+            placeholder="输入企微客服"
+            clearable
+          />
+        </el-form-item>
+
+        <el-form-item label="统计日期">
+          <el-date-picker
+            v-model="queryParams.statDate"
+            type="date"
+            placeholder="选择日期"
+            value-format="yyyy-MM-dd"
+          />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+          <el-button
+            type="warning"
+            plain
+            icon="el-icon-download"
+            :loading="exportLoading"
+            @click="handleExport"
+          >导出</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <el-table :data="tableData" border v-loading="loading">
+      <el-table-column prop="corpName" label="企微客服" align="center" />
+      <el-table-column
+        label="部门"
+        align="center"
+        :show-overflow-tooltip="true"
+        :formatter="formatDeptName"
+      />
+
+      <el-table-column prop="nickName" label="员工" align="center" />
+      <el-table-column prop="externalTotal" label="总客户数" align="center" />
+      <el-table-column prop="totalCustomers" label="已注册客户数" align="center" />
+      <el-table-column prop="downloadCount" label="下载APP数" align="center" />
+      <el-table-column prop="notDownloadCount" label="未下载APP数" align="center" />
+      <el-table-column prop="downloadRate" label="下载率" align="center">
+        <template slot-scope="scope">
+          {{ (scope.row.downloadRate * 100).toFixed(2) }}%
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </div>
+</template>
+
+<script>
+import {getSalesAppDownloadStats, exportSalesAppDownloadStats} from '../../../api/qw/externalContact';
+import {listDept, treeselect,listDeptExcludeChild} from "@/api/company/companyDept";
+import Treeselect from "@riophae/vue-treeselect";
+import "@riophae/vue-treeselect/dist/vue-treeselect.css";
+import {getMyQwCompanyList} from "@/api/qw/user";
+export default {
+  name: 'AppDownloadStatistics',
+  components: {Treeselect},
+  data() {
+    return {
+      queryParams: {
+        corpId: null,
+        pageNum: 1,
+        pageSize: 10,
+        departmentName: "",
+        companyUserName: "",
+        qwUserName: "",
+        myQwCompanyList: [],
+        statDate: this.formatDate(new Date())
+      },
+      tableData: [],
+      total: 0,
+      loading: false,
+      deptOptions: undefined,
+      deptList:[],
+      deptMap: {},
+      // 导出遮罩层
+      exportLoading: false,
+    }
+  },
+  watch: {
+    // 根据名称筛选部门树
+    deptName(val) {
+      this.$refs.tree.filter(val);
+    },
+  },
+  created() {
+    this.getList();
+    this.getTreeselect();
+    getMyQwCompanyList().then(response => {
+      this.myQwCompanyList = response.data;
+      if (this.myQwCompanyList != null) {
+        this.queryParams.corpId = this.myQwCompanyList[0].dictValue;
+        this.getList();
+      }
+    });
+  },
+  methods: {
+    // 格式化日期为 YYYY-MM-DD
+    formatDate(date) {
+      const year = date.getFullYear()
+      const month = String(date.getMonth() + 1).padStart(2, '0')
+      const day = String(date.getDate()).padStart(2, '0')
+      return `${year}-${month}-${day}`
+    },
+
+    // 获取统计数据 - 使用假数据
+    getList() {
+      this.loading = true;
+      getSalesAppDownloadStats(this.queryParams).then(response => {
+        this.tableData = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    /** 查询部门下拉树结构 */
+    getTreeselect() {
+      treeselect().then((response) => {
+        this.deptOptions = response.data;
+        this.deptMap = this.flattenDeptTree(this.deptOptions);
+      });
+    },
+    // 查询处理
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+
+    // 重置查询
+    handleReset() {
+      this.queryParams = {
+        pageNum: 1,
+        pageSize: 20,
+        departmentName: undefined,
+        employeeName: undefined,
+        statDate: this.formatDate(new Date())
+      }
+      this.getList()
+    },
+    flattenDeptTree(tree, map = {}) {
+      tree.forEach(node => {
+        map[node.id] = node.label
+        if (node.children) {
+          this.flattenDeptTree(node.children, map)
+        }
+      })
+      console.log("部门map",map)
+      return map
+    },
+    formatDeptName(row) {
+      const id = Number(row.deptId)
+      console.log("deptId",id)
+      return this.deptMap[id] || ''
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有销售统计项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportSalesAppDownloadStats(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    }
+
+  }
+}
+</script>

+ 8 - 0
src/views/course/courseUserStatistics/qw/my.vue

@@ -20,6 +20,12 @@
                         :picker-options="pickerOptions"
                         @change="change"></el-date-picker>
       </el-form-item>
+      <el-form-item label="搜索类型" prop="searchType">
+        <el-select v-model="queryParams.searchType" size="small" clearable placeholder="请选择搜索类型">
+          <el-option label="个人" value="1" />
+          <el-option label="部门" value="2" v-hasPermi="['qw:watchLogStatistics:dept']" />
+        </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>
@@ -200,6 +206,7 @@ export default {
         eTime:null,
         scheduleStartTime: null,
         scheduleEndTime: null,
+        searchType: "1",
       },
       // 表单参数
       form: {},
@@ -390,6 +397,7 @@ export default {
 
       this.queryParams.scheduleStartTime = null;
       this.queryParams.scheduleEndTime = null;
+      this.queryParams.searchType = "1";
       this.handleQuery();
     },
     // 多选框选中数据

+ 6 - 0
src/views/hisStore/components/productAfterSalesOrder.vue

@@ -43,6 +43,12 @@
                   {{afterSales.refundAmount}}
                 </span>
             </el-descriptions-item>
+            <el-descriptions-item label="优惠券"  >
+                <span v-if="order!=null && order.couponPrice!=null">
+                  ¥{{ Number(order.couponPrice).toFixed(2) }}
+                </span>
+                <span v-else>¥0.00</span>
+            </el-descriptions-item>
             <el-descriptions-item label="申请类型"  >
                 <span v-if="afterSales!=null">
                   <el-tag prop="serviceType" v-for="(item, index) in serviceTypeOptions"    v-if="afterSales.serviceType==item.dictValue">{{item.dictLabel}}</el-tag>

+ 79 - 0
src/views/hisStore/components/productOrder.vue

@@ -36,6 +36,9 @@
           <div class="operate-button-container" v-hasPermi="['store:storeOrder:express']">
             <el-button size="mini" @click="showExpress()">查看物流</el-button>
           </div>
+          <div class="operate-button-container" v-hasPermi="['store:storeOrder:editDeliveryId']">
+            <el-button size="mini" @click="editDelivery()">修改物流</el-button>
+          </div>
         </div>
         <div style="margin: 20px 0px" v-if="order!=null">
         <span class="font-small">
@@ -126,6 +129,11 @@
                   {{ order.deliveryId }}
                 </span>
           </el-descriptions-item>
+          <el-descriptions-item label="发货人手机号">
+                <span v-if="order!=null ">
+                  {{ order.senderPhone }}
+                </span>
+          </el-descriptions-item>
           <el-descriptions-item label="物流状态">
                 <span v-if="order!=null">
                   <el-tag v-for="(item, index) in deliveryStatusOptions"
@@ -317,6 +325,7 @@
               <span v-if="order!=null">
                 ¥{{ order.couponPrice.toFixed(2) }}
               </span>
+              <span v-else>¥0.00</span>
           </el-descriptions-item>
           <el-descriptions-item label="积分抵扣">
               <span v-if="order!=null">
@@ -369,6 +378,7 @@
             <span v-if="order!=null">
               ¥{{ order.couponPrice.toFixed(2) }}
             </span>
+            <span v-else>¥0.00</span>
           </el-descriptions-item>
         </el-descriptions>
 
@@ -687,6 +697,33 @@
         </el-table-column>
       </el-table>
     </el-dialog>
+    <el-dialog :title="editDy.title" :visible.sync="editDy.open" width="600px" append-to-body>
+      <el-form ref="editDyForm" :model="editDyForm" :rules="editDyRules" label-width="120px">
+        <el-form-item label="物流公司" prop="deliverySn">
+          <el-select style="width:220px" v-model="editDyForm.deliverySn" placeholder="请选择" clearable size="small">
+            <el-option key="SF" label="顺丰" value="SF"/>
+            <el-option key="EMS" label="邮政" value="EMS"/>
+            <el-option key="ZTO" label="中通" value="ZTO"/>
+            <el-option key="STO" label="申通" value="STO"/>
+            <el-option key="JD" label="京东" value="JD"/>
+            <el-option key="DBL" label="德邦" value="DBL"/>
+            <el-option key="JTSD" label="极兔" value="JTSD"/>
+            <el-option key="YD" label="韵达" value="YD"/>
+            <el-option key="YTO" label="圆通" value="YTO"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="物流单号" prop="deliveryId">
+          <el-input v-model="editDyForm.deliveryId" placeholder="请输入物流单号"/>
+        </el-form-item>
+        <el-form-item label="发货人手机号" prop="senderPhone">
+          <el-input v-model="editDyForm.senderPhone" placeholder="请输入发货人手机号(物流订阅/查询用)" maxlength="20"/>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitEditDyForm">确 定</el-button>
+        <el-button @click="editDy.open = false">取 消</el-button>
+      </div>
+    </el-dialog>
     <el-dialog :title="bindCustomerDialog.title" :visible.sync="bindCustomerDialog.open" width="800px" append-to-body>
       <el-form ref="bindCustomerForm" :model="bindCustomerForm" :rules="bindCustomerRules" label-width="100px">
         <el-form-item label="客户查询">
@@ -1051,6 +1088,7 @@ import {
   getStoreOrderAddress,
   updateStoreOrder,
   updateStoreOrderItemJson,
+  updateDeliveryId,
   bindCustomer,
   uploadCredentials,
   getUserPhone,
@@ -1213,6 +1251,24 @@ export default {
         title: "物流信息",
         open: false,
       },
+      editDy: {
+        title: "修改物流单号",
+        open: false,
+      },
+      editDyForm: {
+        id: null,
+        deliverySn: null,
+        deliveryId: null,
+        senderPhone: null,
+      },
+      editDyRules: {
+        deliverySn: [
+          {required: true, message: "物流公司不能为空", trigger: "blur"}
+        ],
+        deliveryId: [
+          {required: true, message: "物流单号不能为空", trigger: "blur"}
+        ],
+      },
       citys: [],
       province: [],
       city: [],
@@ -1823,6 +1879,29 @@ export default {
       });
 
     },
+    editDelivery() {
+      this.editDy.open = true;
+      this.editDyForm.id = this.order.id;
+      this.editDyForm.deliveryId = this.order.deliveryId;
+      this.editDyForm.deliverySn = this.order.deliverySn;
+      this.editDyForm.senderPhone = this.order.senderPhone;
+    },
+    submitEditDyForm() {
+      this.$refs["editDyForm"].validate(valid => {
+        if (valid) {
+          if (this.editDyForm.senderPhone == null) {
+            this.editDyForm.senderPhone = '';
+          }
+          updateDeliveryId(this.editDyForm).then(response => {
+            if (response.code === 200) {
+              this.msgSuccess("操作成功");
+              this.editDy.open = false;
+              this.getOrder(this.order.id);
+            }
+          });
+        }
+      });
+    },
     districtChange(val) {
       var item = this.district.find((item) => {
         return item.value == val;

+ 6 - 0
src/views/hisStore/storeAfterSales/list.vue

@@ -125,6 +125,12 @@
       <el-table-column label="所属员工" align="center" prop="companyUserNickName" />
       <el-table-column label="会员手机号" align="center" prop="userPhone" />
       <el-table-column label="退款金额" align="center" prop="refundAmount" />
+      <el-table-column label="优惠券" align="center" prop="couponPrice" width="100px">
+        <template slot-scope="scope">
+          <span v-if="scope.row.couponPrice!=null">{{ Number(scope.row.couponPrice).toFixed(2) }}</span>
+          <span v-else>0.00</span>
+        </template>
+      </el-table-column>
        <el-table-column label="退款类型" align="center" prop="serviceType" >
           <template slot-scope="scope">
               <div prop="serviceType" v-for="(item, index) in serviceTypeOptions"    v-if="scope.row.serviceType==item.dictValue">{{item.dictLabel}}</div>

+ 33 - 4
src/views/hisStore/storeOrder/healthStoreList.vue

@@ -124,6 +124,24 @@
           @keyup.enter.native="handleQuery"
         />
       </el-form-item>
+      <el-form-item label="商品分类" prop="cateName">
+        <el-input
+          v-model="queryParams.cateName"
+          clearable
+          placeholder="请输入商品分类"
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="微信昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          clearable
+          placeholder="请输入微信昵称"
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
       <el-form-item label="订单类型" prop="orderType">
         <el-select v-model="queryParams.orderType" placeholder="请选择订单类型" clearable size="small">
           <el-option label="直播订单" value="2" />
@@ -455,10 +473,10 @@
       <el-table-column label="所属公司" align="center" prop="companyName" min-width="140" show-overflow-tooltip />
       <el-table-column label="所属员工" align="center" prop="companyUserNickName" width="120" show-overflow-tooltip />
       <el-table-column label="小程序名称" align="center" prop="miniProgramName"/>
-      <el-table-column align="center" label="用户昵称" prop="userNickName" width="150px">
-        <template slot-scope="scope">
+      <el-table-column align="center" label="微信昵称" prop="userNickName" width="150px">
+          <template slot-scope="scope">
           <span>{{ scope.row.userNickName }} </span>
-        </template>
+          </template>
       </el-table-column>
         <el-table-column align="center" label="手机号码" prop="nickname" width="150px">
             <template slot-scope="scope">
@@ -499,12 +517,18 @@
           <span v-if="scope.row.payDelivery!=null">{{ scope.row.payDelivery.toFixed(2) }}</span>
         </template>
       </el-table-column>
+      <el-table-column label="优惠券" align="center" prop="couponPrice" width="100px">
+        <template slot-scope="scope">
+          <span v-if="scope.row.couponPrice!=null">{{ scope.row.couponPrice.toFixed(2) }}</span>
+          <span v-else>0.00</span>
+        </template>
+      </el-table-column>
       <el-table-column label="成本价格" align="center" prop="cost" v-if="showFinanceTableField"/>
       <!-- <el-table-column label="结算价格" align="center" prop="fprice"  v-if="showFinanceTableField"/> -->
        <el-table-column label="实付金额" align="center" prop="payMoney"  v-if="showFinanceTableField"/>
       <el-table-column label="额外运费" align="center" prop="payPostage" v-if="showFinanceTableField"/>
       <el-table-column label="商品编码" align="center" prop="barCode"  v-if="showFinanceTableField"/>
-      <el-table-column label="商品分类" align="center" prop="cateName" v-if="showFinanceTableField"/>
+      <el-table-column label="商品分类" align="center" prop="cateName" width="120px"/>
       <el-table-column align="center" label="下单时间" prop="createTime"/>
       <!-- <el-table-column label="支付状态" align="center" prop="paid" /> -->
       <el-table-column align="center" label="支付时间" prop="payTime" width="180">
@@ -1104,6 +1128,7 @@ export default {
         { key: 'userPhone', label: '收货人电话', checked: true },
         { key: 'userAddress', label: '详细地址', checked: true },
         { key: 'payMoney', label: '实收金额', checked: true },
+        { key: 'couponPrice', label: '优惠券金额', checked: true },
         { key: 'payRemain', label: '物流代收金额', checked: false },
         { key: 'createTime', label: '下单时间', checked: true },
         { key: 'payTime', label: '支付时间', checked: true },
@@ -1111,6 +1136,8 @@ export default {
         { key: 'payType', label: '支付方式', checked: true },
         { key: 'status', label: '订单状态', checked: true },
         { key: 'barCode', label: '商品编码', checked: false },
+        { key: 'cateName', label: '商品分类', checked: false },
+        { key: 'userNickName', label: '微信昵称', checked: false },
 
 
         { key: 'deliverySn', label: '快递公司编号', checked: false },
@@ -1241,6 +1268,8 @@ export default {
         companyId: null,
         companyUserNickName: null,
         productName: null,
+        cateName: null,
+        nickname: null,
         payCode: null,
         orderType: null
 

+ 31 - 1
src/views/hisStore/storeOrder/index.vue

@@ -100,6 +100,24 @@
           @keyup.enter.native="handleQuery"
         />
       </el-form-item>
+      <el-form-item label="商品分类" prop="cateName">
+        <el-input
+          v-model="queryParams.cateName"
+          placeholder="请输入商品分类"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="微信昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入微信昵称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
 
       <el-form-item label="手机号" prop="userPhone">
         <el-input
@@ -456,7 +474,7 @@
           </template>
         </el-table-column>
       <el-table-column label="小程序名称" width="120px" align="center" prop="miniProgramName"/>
-      <el-table-column label="用户昵称" align="center" prop="userNickName" width="150px" >
+      <el-table-column label="微信昵称" align="center" prop="userNickName" width="150px" >
           <template slot-scope="scope">
               <span>{{scope.row.userNickName}} </span>
           </template>
@@ -466,6 +484,7 @@
                 <span>{{scope.row.nickname}} </span>
             </template>
         </el-table-column>
+      <el-table-column label="商品分类" align="center" prop="cateName" width="120px"/>
       <el-table-column label="收件人" align="center" prop="realName" width="150px" >
           <template slot-scope="scope">
               <span>{{scope.row.realName}} </span>
@@ -510,6 +529,12 @@
               <span v-if="scope.row.payPrice!=null">{{scope.row.payDelivery.toFixed(2)}}</span>
           </template>
       </el-table-column>
+      <el-table-column label="优惠券" align="center" prop="couponPrice" width="100px">
+          <template slot-scope="scope">
+              <span v-if="scope.row.couponPrice!=null">{{scope.row.couponPrice.toFixed(2)}}</span>
+              <span v-else>0.00</span>
+          </template>
+      </el-table-column>
       <el-table-column label="下单时间" align="center" prop="createTime" />
       <!-- <el-table-column label="支付状态" align="center" prop="paid" /> -->
       <el-table-column label="支付时间" align="center" prop="payTime" width="180" />
@@ -1115,7 +1140,10 @@ export default {
         { key: 'patientName', label: '就诊人', checked: true },
         { key: 'productName', label: '编号', checked: true },
         { key: 'barCode', label: '药品信息', checked: false },
+        { key: 'cateName', label: '商品分类', checked: false },
+        { key: 'userNickName', label: '微信昵称', checked: false },
         { key: 'payMoney', label: '实收金额', checked: true },
+        { key: 'couponPrice', label: '优惠券金额', checked: true },
         { key: 'payRemain', label: '物流代收金额', checked: false },
         { key: 'payType', label: '支付方式', checked: true },
         { key: 'createTime', label: '下单时间', checked: true },
@@ -1256,6 +1284,8 @@ export default {
         deptId:null,
         isUpload:null,
         productName:null,
+        cateName:null,
+        nickname:null,
 
       },
       // 表单参数

+ 172 - 0
src/views/life/vest/index.vue

@@ -0,0 +1,172 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="关键字" prop="keyword">
+        <el-input v-model="queryParams.keyword" placeholder="昵称/ID" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </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="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['life:vest:add']">新增</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="vestList">
+      <el-table-column label="马甲号ID" align="center" prop="id" width="100"/>
+      <el-table-column label="头像" align="center" width="80">
+        <template slot-scope="scope">
+          <el-image v-if="scope.row.avatar" :src="scope.row.avatar" style="width:40px;height:40px;border-radius:50%" fit="cover"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="昵称" align="center" prop="nickName"/>
+      <el-table-column label="简介" align="center" prop="intro" show-overflow-tooltip/>
+      <el-table-column label="状态" align="center" prop="status" width="80">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.status === 1" type="success">启用</el-tag>
+          <el-tag v-else type="info">禁用</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="220" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['life:vest:edit']">编辑</el-button>
+          <el-button size="mini" type="text" icon="el-icon-video-camera" @click="goVideo(scope.row)" v-hasPermi="['life:video:list']">内容配置</el-button>
+<!--          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['life:vest:remove']">删除</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-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <el-form-item label="昵称" prop="nickName">
+          <el-input v-model="form.nickName" placeholder="2-10个字" maxlength="10"/>
+        </el-form-item>
+        <el-form-item label="头像" prop="avatar">
+          <image-upload v-model="form.avatar" :limit="1"/>
+        </el-form-item>
+        <el-form-item label="简介" prop="intro">
+          <el-input v-model="form.intro" type="textarea" :rows="3" placeholder="选填"/>
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listLifeVest, getLifeVest, addLifeVest, updateLifeVest, delLifeVest } from '@/api/life/vest'
+
+export default {
+  name: 'LifeVest',
+  data() {
+    return {
+      loading: true,
+      showSearch: true,
+      total: 0,
+      vestList: [],
+      title: '',
+      open: false,
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        keyword: null
+      },
+      form: {},
+      rules: {
+        nickName: [
+          { required: true, message: '昵称不能为空', trigger: 'blur' },
+          { min: 2, max: 10, message: '长度需为2-10字', trigger: 'blur' }
+        ],
+        avatar: [{ required: true, message: '头像不能为空', trigger: 'change' }],
+        status: [{ required: true, message: '请选择状态', trigger: 'change' }]
+      }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listLifeVest(this.queryParams).then(res => {
+        this.vestList = res.rows
+        this.total = res.total
+        this.loading = false
+      })
+    },
+    cancel() {
+      this.open = false
+      this.reset()
+    },
+    reset() {
+      this.form = {
+        id: null,
+        nickName: null,
+        avatar: null,
+        intro: null,
+        status: 1
+      }
+      this.resetForm('form')
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    handleAdd() {
+      this.reset()
+      this.open = true
+      this.title = '新增马甲号'
+    },
+    handleUpdate(row) {
+      this.reset()
+      getLifeVest(row.id).then(res => {
+        this.form = res.data
+        this.open = true
+        this.title = '编辑马甲号'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.id != null ? updateLifeVest(this.form) : addLifeVest(this.form)
+        req.then(() => {
+          this.msgSuccess('操作成功')
+          this.open = false
+          this.getList()
+        })
+      })
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除马甲号「' + row.nickName + '」吗?', '警告', { type: 'warning' }).then(() => {
+        return delLifeVest(row.id)
+      }).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
+    goVideo(row) {
+      this.$router.push({ path: '/live/life/video', query: { vestId: row.id } })
+    }
+  }
+}
+</script>

+ 680 - 0
src/views/life/video/index.vue

@@ -0,0 +1,680 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="90px">
+      <el-form-item label="马甲号" prop="vestId">
+        <el-select v-model="queryParams.vestId" filterable clearable placeholder="全部马甲" size="small" style="width:180px">
+          <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="内容分类" prop="category">
+        <el-select v-model="queryParams.category" clearable placeholder="全部" size="small" style="width:140px">
+          <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="关键字" prop="keyword">
+        <el-input v-model="queryParams.keyword" placeholder="标题/ID/文件名" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="上架" prop="isShow">
+        <el-select v-model="queryParams.isShow" clearable placeholder="全部" size="small" style="width:100px">
+          <el-option label="上架" :value="1"/>
+          <el-option label="下架" :value="0"/>
+        </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="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['life:video:add']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-document-copy" size="mini" @click="handleBatchAdd" v-hasPermi="['life:video:add']">批量添加</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleBatchDelete" v-hasPermi="['life:video:remove']">批量删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="videoList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center"/>
+      <el-table-column label="视频ID" align="center" prop="videoId" width="90"/>
+      <el-table-column label="封面" align="center" width="80">
+        <template slot-scope="scope">
+          <el-image v-if="scope.row.coverUrl" :src="scope.row.coverUrl" style="width:50px;height:50px" fit="cover"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="标题" align="center" prop="title" show-overflow-tooltip/>
+      <el-table-column label="内容分类" align="center" prop="category" width="100"/>
+      <el-table-column label="文件名" align="center" prop="fileName" show-overflow-tooltip/>
+      <el-table-column label="马甲ID" align="center" prop="vestId" width="80"/>
+      <el-table-column label="时长(秒)" align="center" prop="duration" width="90"/>
+      <el-table-column label="上传时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="上架" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.isShow === 1" type="success">是</el-tag>
+          <el-tag v-else type="info">否</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="主推" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.isHot === 1" type="warning">是</el-tag>
+          <span v-else>否</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="播放/赞/藏/享" align="center" width="140">
+        <template slot-scope="scope">
+          {{ scope.row.views || 0 }}/{{ scope.row.likes || 0 }}/{{ scope.row.favoriteNum || 0 }}/{{ scope.row.shareNum || 0 }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" @click="handleUpdate(scope.row)" v-hasPermi="['life:video:edit']">修改</el-button>
+          <el-button size="mini" type="text" @click="handleShelf(scope.row)" v-hasPermi="['life:video:edit']">
+            {{ scope.row.isShow === 1 ? '下架' : '上架' }}
+          </el-button>
+          <el-button size="mini" type="text" @click="handleDelete(scope.row)" v-hasPermi="['life:video:remove']">删除</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-dialog :title="title" :visible.sync="open" width="780px" append-to-body :before-close="handleDialogClose">
+      <el-form ref="form" :model="form" :rules="rules" label-width="110px">
+        <el-form-item label="马甲号" prop="vestId">
+          <el-select v-model="form.vestId" filterable placeholder="请选择马甲号" style="width:100%">
+            <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="内容分类" prop="category">
+          <el-select v-model="form.category" placeholder="请选择内容分类" style="width:100%">
+            <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="视频标题" prop="title">
+          <el-input v-model="form.title" maxlength="20" show-word-limit placeholder="不超过20字"/>
+        </el-form-item>
+        <el-form-item label="文件名称" prop="fileName">
+          <el-input v-model="form.fileName" placeholder="上传文件名或标识"/>
+        </el-form-item>
+        <el-form-item label="封面" prop="coverUrl">
+          <image-upload v-model="form.coverUrl" :limit="1" :file-size="2"/>
+          <div style="color:#909399;font-size:12px;line-height:1.4;margin-top:4px">大小不超过 2MB</div>
+        </el-form-item>
+        <video-upload
+          :type="3"
+          :show-control="true"
+          :videoUrl.sync="form.videoUrl"
+          :fileName.sync="form.fileName"
+          @video-ready="onVideoReady"
+          @change="onVideoReady"
+          @video-duration="onVideoDuration"
+          ref="videoUpload"
+        />
+        <el-form-item v-if="form.videoUrl" label="上传时间">
+          <el-input :value="uploadTimeDisplay" disabled style="width:220px"/>
+        </el-form-item>
+        <el-form-item label="时长(秒)" prop="duration">
+          <el-input-number v-model="form.duration" :min="0" :controls="false"/>
+          <span style="margin-left:8px;color:#909399;font-size:12px">上传/选取后自动读取,可手动修改</span>
+        </el-form-item>
+        <el-form-item label="播放量" prop="views">
+          <el-input-number v-model="form.views" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="点赞数" prop="likes">
+          <el-input-number v-model="form.likes" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="收藏数" prop="favoriteNum">
+          <el-input-number v-model="form.favoriteNum" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="分享数" prop="shareNum">
+          <el-input-number v-model="form.shareNum" :min="0" :controls="false" style="width:160px"/>
+        </el-form-item>
+        <el-form-item label="是否上架" prop="isShow">
+          <el-radio-group v-model="form.isShow">
+            <el-radio :label="1">上架</el-radio>
+            <el-radio :label="0">下架</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="今日主推" prop="isHot">
+          <el-radio-group v-model="form.isHot">
+            <el-radio :label="1">是</el-radio>
+            <el-radio :label="0">否</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="是否关联商品" prop="isProduct">
+          <el-radio-group v-model="form.isProduct" @change="onIsProductChange">
+            <el-radio :label="1">是</el-radio>
+            <el-radio :label="0">否</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item v-show="form.isProduct === 1" label="关联商品">
+          <el-button type="primary" size="mini" :disabled="selectedProducts.length >= 1" @click="openProductDialog">选择商品</el-button>
+          <span style="margin-left:8px;color:#909399;font-size:12px">最多关联 1 个商品</span>
+          <el-table :data="selectedProducts" border size="mini" style="margin-top:8px">
+            <el-table-column label="商品ID" prop="productId" width="90"/>
+            <el-table-column label="名称" prop="productName" show-overflow-tooltip/>
+            <el-table-column label="价格" prop="price" width="90"/>
+            <el-table-column label="库存" prop="stock" width="80"/>
+            <el-table-column label="操作" width="80">
+              <template slot-scope="scope">
+                <el-button type="text" size="mini" @click="removeProduct(scope.$index)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog title="批量添加视频" :visible.sync="batchOpen" width="860px" append-to-body top="5vh">
+      <el-form label-width="90px" size="small">
+        <el-form-item label="马甲号" required>
+          <el-select v-model="batchVestId" filterable placeholder="请选择马甲号" style="width:320px">
+            <el-option v-for="v in vestOptions" :key="v.id" :label="v.nickName + '(' + v.id + ')'" :value="v.id"/>
+          </el-select>
+          <el-button type="primary" size="mini" style="margin-left:12px" @click="addBatchRow">添加一条</el-button>
+          <span style="margin-left:8px;color:#909399;font-size:12px">每条支持本地上传或素材库选取</span>
+        </el-form-item>
+      </el-form>
+      <div class="batch-list" style="max-height:62vh;overflow:auto;padding-right:4px">
+        <div
+          v-for="(row, index) in batchRows"
+          :key="row._key"
+          style="border:1px solid #EBEEF5;border-radius:4px;padding:12px 12px 4px;margin-bottom:12px;background:#FAFAFA"
+        >
+          <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+            <span style="font-weight:600">第 {{ index + 1 }} 条</span>
+            <el-button type="text" size="mini" style="color:#F56C6C" @click="removeBatchRow(index)">删除</el-button>
+          </div>
+          <el-form label-width="90px" size="small">
+            <el-form-item label="标题" required>
+              <el-input v-model="row.title" maxlength="20" show-word-limit placeholder="不超过20字"/>
+            </el-form-item>
+            <el-form-item label="内容分类" required>
+              <el-select v-model="row.category" placeholder="请选择" style="width:220px">
+                <el-option v-for="d in categoryOptions" :key="d.dictValue" :label="d.dictLabel" :value="d.dictLabel"/>
+              </el-select>
+            </el-form-item>
+            <el-form-item label="封面" required>
+              <image-upload v-model="row.coverUrl" :limit="1" :file-size="2"/>
+              <div style="color:#909399;font-size:12px">大小不超过 2MB</div>
+            </el-form-item>
+            <video-upload
+              :type="3"
+              :show-control="true"
+              :videoUrl.sync="row.videoUrl"
+              :fileName.sync="row.fileName"
+              @video-ready="onBatchVideoReady(index)"
+              @change="onBatchVideoReady(index)"
+              @video-duration="d => onBatchVideoDuration(index, d)"
+            />
+            <el-form-item v-if="row.videoUrl" label="上传时间">
+              <el-input :value="row.uploadTimeDisplay" disabled style="width:220px"/>
+            </el-form-item>
+            <el-form-item label="时长(秒)">
+              <el-input-number v-model="row.duration" :min="0" :controls="false"/>
+              <span style="margin-left:8px;color:#909399;font-size:12px">自动读取,可改</span>
+            </el-form-item>
+            <el-form-item label="播放量">
+              <el-input-number v-model="row.views" :min="0" :controls="false" style="width:120px"/>
+            </el-form-item>
+            <el-form-item label="赞/藏/享">
+              <el-input-number v-model="row.likes" :min="0" :controls="false" style="width:90px"/>
+              <el-input-number v-model="row.favoriteNum" :min="0" :controls="false" style="width:90px;margin:0 8px"/>
+              <el-input-number v-model="row.shareNum" :min="0" :controls="false" style="width:90px"/>
+            </el-form-item>
+            <el-form-item label="是否上架">
+              <el-switch v-model="row.isShow" :active-value="1" :inactive-value="0"/>
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="batchSubmitting" @click="submitBatch">确 定</el-button>
+        <el-button @click="batchOpen = false">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog title="选择商品" :visible.sync="productOpen" width="780px" append-to-body>
+      <el-form :inline="true" size="small">
+        <el-form-item label="商品名称">
+          <el-input v-model="productQuery.productName" clearable @keyup.enter.native="searchProduct"/>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" size="mini" @click="searchProduct">搜索</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="productList" border v-loading="productLoading" max-height="400">
+        <el-table-column label="ID" prop="productId" width="80"/>
+        <el-table-column label="图片" width="70">
+          <template slot-scope="scope">
+            <el-image v-if="scope.row.image" :src="scope.row.image" style="width:40px;height:40px" fit="cover"/>
+          </template>
+        </el-table-column>
+        <el-table-column label="名称" prop="productName" show-overflow-tooltip/>
+        <el-table-column label="价格" prop="price" width="90"/>
+        <el-table-column label="库存" prop="stock" width="80"/>
+        <el-table-column label="操作" width="100">
+          <template slot-scope="scope">
+            <el-button
+              type="text"
+              size="mini"
+              :disabled="isSelected(scope.row.productId) || (selectedProducts.length >= 1 && !isSelected(scope.row.productId))"
+              @click="pickProduct(scope.row)"
+            >
+              {{ isSelected(scope.row.productId) ? '已选' : (selectedProducts.length >= 1 ? '已满' : '选择') }}
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <pagination v-show="productTotal>0" :total="productTotal" :page.sync="productQuery.pageNum" :limit.sync="productQuery.pageSize" @pagination="searchProduct"/>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listLifeVest } from '@/api/life/vest'
+import { listLifeVideo, getLifeVideo, addLifeVideo, updateLifeVideo, delLifeVideo, shelfLifeVideo, searchLifeVideoProduct, batchAddLifeVideo } from '@/api/life/video'
+import VideoUpload from '@/components/VideoUpload/index.vue'
+
+export default {
+  name: 'LifeVideo',
+  components: { VideoUpload },
+  data() {
+    return {
+      loading: true,
+      showSearch: true,
+      total: 0,
+      videoList: [],
+      ids: [],
+      multiple: true,
+      vestOptions: [],
+      categoryOptions: [],
+      title: '',
+      open: false,
+      uploadTimeDisplay: '',
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        vestId: null,
+        category: null,
+        keyword: null,
+        isShow: null
+      },
+      form: {},
+      rules: {
+        vestId: [{ required: true, message: '请选择马甲号', trigger: 'change' }],
+        category: [{ required: true, message: '请选择内容分类', trigger: 'change' }],
+        title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
+        coverUrl: [{ required: true, message: '封面不能为空', trigger: 'change' }],
+        videoUrl: [{ required: true, message: '请上传或选择视频', trigger: 'change' }],
+        isShow: [{ required: true, message: '请选择是否上架', trigger: 'change' }],
+        isProduct: [{ required: true, message: '请选择是否关联商品', trigger: 'change' }]
+      },
+      selectedProducts: [],
+      productOpen: false,
+      productLoading: false,
+      productList: [],
+      productTotal: 0,
+      productQuery: {
+        pageNum: 1,
+        pageSize: 10,
+        productName: null
+      },
+      batchOpen: false,
+      batchVestId: null,
+      batchRows: [],
+      batchSubmitting: false
+    }
+  },
+  created() {
+    if (this.$route.query.vestId) {
+      this.queryParams.vestId = Number(this.$route.query.vestId)
+    }
+    this.loadCategories()
+    this.loadVests()
+    this.getList()
+  },
+  methods: {
+    loadCategories() {
+      this.getDicts('life_video_category').then(res => {
+        this.categoryOptions = res.data || []
+      })
+    },
+    loadVests() {
+      // 仅启用中的马甲出现在下拉
+      listLifeVest({ pageNum: 1, pageSize: 500, status: 1 }).then(res => {
+        this.vestOptions = res.rows || []
+      })
+    },
+    defaultCategory() {
+      const def = this.categoryOptions.find(d => d.isDefault === 'Y') || this.categoryOptions[0]
+      return def ? def.dictLabel : '默认分类'
+    },
+    getList() {
+      this.loading = true
+      listLifeVideo(this.queryParams).then(res => {
+        this.videoList = res.rows
+        this.total = res.total
+        this.loading = false
+      })
+    },
+    cancel() {
+      this.handleDialogClose(() => {
+        this.open = false
+      })
+    },
+    /** 已填写内容时取消需二次确认 */
+    isFormDirty() {
+      const f = this.form || {}
+      return !!(f.title || f.coverUrl || f.videoUrl || f.fileName
+        || (f.likes && f.likes > 0) || (f.favoriteNum && f.favoriteNum > 0) || (f.shareNum && f.shareNum > 0)
+        || (this.selectedProducts && this.selectedProducts.length))
+    },
+    handleDialogClose(done) {
+      const finish = () => {
+        this.reset()
+        if (typeof done === 'function') {
+          done()
+        } else {
+          this.open = false
+        }
+      }
+      if (!this.isFormDirty()) {
+        finish()
+        return
+      }
+      this.$confirm('已填写内容,确认放弃并关闭?', '提示', { type: 'warning' }).then(() => {
+        finish()
+      }).catch(() => {})
+    },
+    formatNow() {
+      const d = new Date()
+      const pad = n => (n < 10 ? '0' + n : '' + n)
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+        + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+    },
+    /** 本地上传完成 / 素材库选完后展示上传时间 */
+    onVideoReady() {
+      if (!this.form.videoUrl) {
+        return
+      }
+      if (this.form.videoId) {
+        this.uploadTimeDisplay = this.parseTime(this.form.createTime) || this.formatNow()
+      } else {
+        this.uploadTimeDisplay = this.formatNow()
+      }
+      if (this.form.duration == null) {
+        this.form.duration = 0
+      }
+      this.$nextTick(() => {
+        this.$refs.form && this.$refs.form.validateField('videoUrl')
+      })
+    },
+    onVideoDuration(duration) {
+      const d = Number(duration)
+      if (!isNaN(d) && d >= 0) {
+        this.form.duration = Math.round(d)
+      }
+    },
+    reset() {
+      this.form = {
+        videoId: null,
+        vestId: this.queryParams.vestId || null,
+        category: this.defaultCategory(),
+        title: null,
+        fileName: null,
+        coverUrl: null,
+        videoUrl: null,
+        duration: 0,
+        views: 0,
+        likes: 0,
+        favoriteNum: 0,
+        shareNum: 0,
+        isShow: 1,
+        isHot: 0,
+        isProduct: 0,
+        productIds: []
+      }
+      this.uploadTimeDisplay = ''
+      this.selectedProducts = []
+      this.resetForm('form')
+      this.$nextTick(() => {
+        if (this.$refs.videoUpload && this.$refs.videoUpload.resetUpload) {
+          this.$refs.videoUpload.resetUpload()
+        }
+      })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.queryParams.vestId = null
+      this.queryParams.category = null
+      this.handleQuery()
+    },
+    handleAdd() {
+      this.reset()
+      this.open = true
+      this.title = '新增生活号视频'
+    },
+    handleBatchAdd() {
+      this.batchVestId = this.queryParams.vestId || null
+      this.batchRows = [this.newBatchRow()]
+      this.batchOpen = true
+    },
+    newBatchRow() {
+      return {
+        _key: 'b_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
+        title: '',
+        category: this.defaultCategory(),
+        coverUrl: null,
+        videoUrl: null,
+        fileName: null,
+        uploadTimeDisplay: '',
+        duration: 0,
+        views: 0,
+        likes: 0,
+        favoriteNum: 0,
+        shareNum: 0,
+        isShow: 1,
+        isHot: 0
+      }
+    },
+    addBatchRow() {
+      if (this.batchRows.length >= 50) {
+        this.$message.warning('单次最多批量新增50条')
+        return
+      }
+      this.batchRows.push(this.newBatchRow())
+    },
+    removeBatchRow(index) {
+      this.batchRows.splice(index, 1)
+    },
+    onBatchVideoReady(index) {
+      const row = this.batchRows[index]
+      if (!row || !row.videoUrl) {
+        return
+      }
+      row.uploadTimeDisplay = this.formatNow()
+      if (row.duration == null) {
+        row.duration = 0
+      }
+    },
+    onBatchVideoDuration(index, duration) {
+      const row = this.batchRows[index]
+      if (!row) return
+      const d = Number(duration)
+      if (!isNaN(d) && d >= 0) {
+        row.duration = Math.round(d)
+      }
+    },
+    submitBatch() {
+      if (!this.batchVestId) {
+        this.$message.warning('请选择马甲号')
+        return
+      }
+      if (!this.batchRows.length) {
+        this.$message.warning('请至少添加一条视频')
+        return
+      }
+      for (let i = 0; i < this.batchRows.length; i++) {
+        const row = this.batchRows[i]
+        if (!row.title || !row.coverUrl || !row.videoUrl || !row.category) {
+          this.$message.warning(`第 ${i + 1} 条:标题、封面、视频、分类均为必填(视频请本地上传或素材库选取)`)
+          return
+        }
+      }
+      const payload = this.batchRows.map(row => ({
+        vestId: this.batchVestId,
+        title: row.title,
+        category: row.category,
+        coverUrl: row.coverUrl,
+        videoUrl: row.videoUrl,
+        fileName: row.fileName,
+        duration: row.duration || 0,
+        views: row.views || 0,
+        likes: row.likes || 0,
+        favoriteNum: row.favoriteNum || 0,
+        shareNum: row.shareNum || 0,
+        isShow: row.isShow,
+        isHot: row.isHot || 0,
+        productIds: []
+      }))
+      this.batchSubmitting = true
+      batchAddLifeVideo(payload).then(() => {
+        this.msgSuccess('批量添加成功')
+        this.batchOpen = false
+        this.getList()
+      }).finally(() => {
+        this.batchSubmitting = false
+      })
+    },
+    handleUpdate(row) {
+      this.reset()
+      getLifeVideo(row.videoId).then(res => {
+        this.form = Object.assign({}, res.data)
+        if (!this.form.category) {
+          this.form.category = this.defaultCategory()
+        }
+        this.form.likes = this.form.likes || 0
+        this.form.favoriteNum = this.form.favoriteNum || 0
+        this.form.shareNum = this.form.shareNum || 0
+        this.form.views = this.form.views || 0
+        this.form.duration = this.form.duration == null ? 0 : this.form.duration
+        this.uploadTimeDisplay = this.parseTime(this.form.createTime) || ''
+        const ids = res.data.productIds || []
+        this.form.productIds = ids
+        this.form.isProduct = ids.length > 0 ? 1 : 0
+        this.selectedProducts = ids.map(id => ({ productId: id, productName: '商品#' + id, price: '-', stock: '-' }))
+        if (ids.length) {
+          searchLifeVideoProduct({ pageNum: 1, pageSize: 100 }).then(p => {
+            const map = {}
+            ;(p.rows || []).forEach(item => { map[item.productId] = item })
+            this.selectedProducts = ids.map(id => map[id] || { productId: id, productName: '商品#' + id })
+          })
+        }
+        this.open = true
+        this.title = '修改生活号视频'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        if (this.form.isProduct === 1 && this.selectedProducts.length === 0) {
+          this.$message.warning('请选择关联商品')
+          return
+        }
+        if (this.selectedProducts.length > 1) {
+          this.$message.warning('最多关联一个商品')
+          return
+        }
+        this.form.productIds = this.form.isProduct === 1
+          ? this.selectedProducts.map(p => p.productId)
+          : []
+        const req = this.form.videoId != null ? updateLifeVideo(this.form) : addLifeVideo(this.form)
+        req.then(() => {
+          this.msgSuccess('操作成功')
+          this.open = false
+          this.getList()
+        })
+      })
+    },
+    handleShelf(row) {
+      const next = row.isShow === 1 ? 0 : 1
+      shelfLifeVideo({ videoId: row.videoId, isShow: next }).then(() => {
+        this.msgSuccess('操作成功')
+        this.getList()
+      })
+    },
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.videoId)
+      this.multiple = !selection.length
+    },
+    handleDelete(row) {
+      this.$confirm('删除后不可恢复,确认删除该视频?', '警告', { type: 'warning' }).then(() => {
+        return delLifeVideo(row.videoId)
+      }).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
+    handleBatchDelete() {
+      if (!this.ids.length) {
+        this.$message.warning('请选择要删除的视频')
+        return
+      }
+      this.$confirm('删除后不可恢复,确认批量删除选中的 ' + this.ids.length + ' 条视频?', '警告', { type: 'warning' }).then(() => {
+        return delLifeVideo(this.ids.join(','))
+      }).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
+    openProductDialog() {
+      this.productOpen = true
+      this.searchProduct()
+    },
+    searchProduct() {
+      this.productLoading = true
+      searchLifeVideoProduct(this.productQuery).then(res => {
+        this.productList = res.rows || []
+        this.productTotal = res.total || 0
+        this.productLoading = false
+      }).catch(() => { this.productLoading = false })
+    },
+    isSelected(productId) {
+      return this.selectedProducts.some(p => p.productId === productId)
+    },
+    pickProduct(row) {
+      if (this.isSelected(row.productId)) {
+        return
+      }
+      if (this.selectedProducts.length >= 1) {
+        this.$message.warning('最多关联一个商品,请先删除已选商品')
+        return
+      }
+      this.selectedProducts.push(row)
+      this.form.isProduct = 1
+      this.productOpen = false
+    },
+    removeProduct(index) {
+      this.selectedProducts.splice(index, 1)
+    }
+  }
+}
+</script>

+ 101 - 2
src/views/live/live/index.vue

@@ -410,6 +410,40 @@
                         <el-radio :label="2">下架</el-radio>
                     </el-radio-group>
                 </el-form-item>
+                <el-form-item label="可见范围" prop="visibleScope">
+                    <el-radio-group v-model="form.visibleScope" :disabled="isViewOnly">
+                        <el-radio :label="0">私域直播</el-radio>
+                        <el-radio :label="1">公域直播</el-radio>
+                    </el-radio-group>
+                </el-form-item>
+                <el-form-item label="直播马甲号" prop="vestId">
+                    <el-select v-model="form.vestId" filterable clearable placeholder="请选择马甲号" style="width:100%" :disabled="isViewOnly" @change="onVestChange">
+                        <el-option
+                            v-for="item in lifeVestOptions"
+                            :key="item.id"
+                            :label="item.nickName + '(' + item.id + ')'"
+                            :value="item.id"
+                        />
+                    </el-select>
+                    <div style="color:#909399;font-size:12px;line-height:1.4;margin-top:4px">公域直播必须绑定马甲号;私域可不选</div>
+                </el-form-item>
+                <el-form-item label="马甲号视频" prop="lifeVideoId" v-if="form.vestId && form.liveType == 2 && !isViewOnly">
+                    <el-select
+                        v-model="form.lifeVideoId"
+                        filterable
+                        clearable
+                        placeholder="可选:选用该马甲的短视频(自动带入时长)"
+                        style="width:100%"
+                        @change="onLifeVideoSelect"
+                    >
+                        <el-option
+                            v-for="item in lifeVideoOptions"
+                            :key="item.videoId"
+                            :label="(item.title || item.fileName || ('视频'+item.videoId)) + (item.duration ? (' / ' + secondsToTime(item.duration)) : '')"
+                            :value="item.videoId"
+                        />
+                    </el-select>
+                </el-form-item>
             </el-form>
             <div slot="footer" class="dialog-footer">
                 <el-button type="primary" @click="submitForm" v-show="!isViewOnly">确 定</el-button>
@@ -480,6 +514,8 @@ import user from '@/store/modules/user';
 import VideoUpload from "@/components/LiveVideoUpload/single.vue";
 import {listTag} from "@/api/qw/tag";
 import {getAppAllLint, getAppAllList} from "../../../api/live/live";
+import { listLifeVest } from "@/api/life/vest";
+import { listLifeVideo } from "@/api/life/video";
 
 export default {
     name: "Live",
@@ -600,11 +636,14 @@ export default {
             currentQrcodeUrl: "",        // 当前要展示的二维码地址
             defaultImg: "https://via.placeholder.com/400x400?text=二维码加载失败",  // 占位图
             // 导出评论状态:key为liveId,value为是否正在导出
-            exportingComments: {}
+            exportingComments: {},
+            lifeVestOptions: [],
+            lifeVideoOptions: []
         };
     },
     created() {
         this.getList();
+        this.loadLifeVestOptions();
         // 新增:获取直播类型字典
         this.getDicts("live_type").then((response) => {
             this.liveTypeDictList = response.data;
@@ -878,9 +917,13 @@ export default {
                 isShow: 1,
                 isTranscode:0,
                 uploadType:1,
-                transcodeFileKey:null
+                transcodeFileKey:null,
+                visibleScope: 0,
+                vestId: null,
+                lifeVideoId: null
             };
             this.videoUrl = "";
+            this.lifeVideoOptions = [];
             this.resetForm("form");
         },
         /** 搜索按钮操作 */
@@ -928,6 +971,9 @@ export default {
                 if(this.form.duration){
                     this.form.durationTime = this.secondsToTime(this.form.duration)
                 }
+                if (this.form.vestId) {
+                    this.loadLifeVideoOptions(this.form.vestId);
+                }
                 this.liveDesc = this.form.liveDesc
 
                 setTimeout(() => {
@@ -952,6 +998,9 @@ export default {
                 if(this.form.duration){
                     this.form.durationTime = this.secondsToTime(this.form.duration)
                 }
+                if (this.form.vestId) {
+                    this.loadLifeVideoOptions(this.form.vestId);
+                }
                 this.liveDesc = this.form.liveDesc
 
                 setTimeout(() => {
@@ -970,6 +1019,9 @@ export default {
         submitForm() {
             if(this.form.liveId != null) { this.videoUrl = this.form.videoUrl; }
             if(this.form.liveType==2 && this.videoUrl.length == 0) {return this.$message.error("请上传视频");}
+            if (this.form.visibleScope === 1 && !this.form.vestId) {
+                return this.$message.error("公域直播必须绑定马甲号");
+            }
             this.$refs["form"].validate(valid => {
                 if (valid) {
                     this.form.videoUrl = this.videoUrl;
@@ -989,6 +1041,53 @@ export default {
                 }
             });
         },
+        loadLifeVestOptions() {
+            listLifeVest({ pageNum: 1, pageSize: 500 }).then(res => {
+                this.lifeVestOptions = res.rows || [];
+            }).catch(() => {
+                this.lifeVestOptions = [];
+            });
+        },
+        onVestChange(vestId) {
+            this.form.lifeVideoId = null;
+            this.lifeVideoOptions = [];
+            if (vestId) {
+                this.loadLifeVideoOptions(vestId);
+            }
+        },
+        loadLifeVideoOptions(vestId) {
+            if (!vestId) {
+                this.lifeVideoOptions = [];
+                return;
+            }
+            listLifeVideo({ pageNum: 1, pageSize: 200, vestId, isShow: 1 }).then(res => {
+                this.lifeVideoOptions = res.rows || [];
+            }).catch(() => {
+                this.lifeVideoOptions = [];
+            });
+        },
+        onLifeVideoSelect(lifeVideoId) {
+            if (!lifeVideoId) {
+                return;
+            }
+            const video = (this.lifeVideoOptions || []).find(v => v.videoId === lifeVideoId);
+            if (!video) {
+                return;
+            }
+            this.form.videoUrl = video.videoUrl;
+            this.videoUrl = video.videoUrl || "";
+            this.form.fileName = video.fileName;
+            if (video.duration) {
+                this.form.duration = Number(video.duration);
+                this.form.durationTime = this.secondsToTime(this.form.duration);
+                if (typeof this.timeChange === 'function') {
+                    this.timeChange();
+                }
+            }
+            if (!this.form.liveImgUrl && video.coverUrl) {
+                this.form.liveImgUrl = video.coverUrl;
+            }
+        },
         /** 删除按钮操作 */
         handleDelete(row) {
             const liveIds = row.liveId || this.ids;

+ 10 - 0
src/views/live/liveConfig/liveCoupon.vue

@@ -153,6 +153,16 @@
             label="优惠券有效时间"
             class-name="clickable-column"
           ></el-table-column>
+          <el-table-column
+            prop="limitTime"
+            label="领取结束时间"
+            width="160"
+            class-name="clickable-column"
+          >
+            <template slot-scope="scope">
+              <span>{{ scope.row.limitTime ? parseTime(scope.row.limitTime, '{y}-{m}-{d} {h}时') : '-' }}</span>
+            </template>
+          </el-table-column>
         </el-table>
 
         <pagination

+ 27 - 17
src/views/live/liveConfig/liveReplay.vue

@@ -111,6 +111,7 @@
         <el-table-column label="视频地址" prop="videoUrl" >
           <template slot-scope="scope">
             <el-tooltip
+              v-if="scope.row.videoUrl"
               :content="scope.row.videoUrl"
               placement="top"
               effect="dark"
@@ -129,6 +130,7 @@
                 <i class="el-icon-external-link video-url-icon"></i>
               </a>
             </el-tooltip>
+            <span v-else>-</span>
           </template>
         </el-table-column>
 <!--        <el-table-column label="操作" prop="videoUrl" >-->
@@ -223,21 +225,28 @@ export default {
   },
   methods: {
     getLiveVideo() {
+      this.videoList = [];
+      if (!this.liveId) {
+        return;
+      }
       getLiveVideoByLiveId(this.liveId).then(res => {
-        let dataEntity =
-        {
-          duration: "00:00",
-          status: "回放中",
-          updateTime: "2025-09-08 14:28:24",
-        };
-
-        //将秒数转为时分秒
-        dataEntity.duration = this.convertSeconds(res.data.duration);
-        dataEntity.updateTime = res.data.updateTime;
-        dataEntity.videoUrl = res.data.videoUrl;
-        dataEntity.videoName = this.extractFileName(dataEntity.videoUrl)
-
-        this.videoList.push(dataEntity);
+        const raw = res && res.data;
+        if (!raw) {
+          return;
+        }
+        // 接口可能返回单条对象或数组
+        const list = Array.isArray(raw) ? raw : [raw];
+        this.videoList = list
+          .filter(item => item && item.videoUrl)
+          .map(item => ({
+            duration: this.convertSeconds(item.duration),
+            status: "回放中",
+            updateTime: item.updateTime || "",
+            videoUrl: item.videoUrl,
+            videoName: this.extractFileName(item.videoUrl),
+          }));
+      }).catch(() => {
+        this.videoList = [];
       });
     },
     /**
@@ -286,12 +295,13 @@ export default {
     getLive() {
       getLive(this.liveId).then(res => {
         this.liveInfo = res.data;
-        if(res.data.liveConfig){
+        if (res.data.liveConfig) {
           this.replayForm = JSON.parse(res.data.liveConfig);
-          this.getLiveVideo();
-        }else{
+        } else {
           this.resetForm();
         }
+        // 无论回放开关开/关,都展示回放视频内容
+        this.getLiveVideo();
         this.loading = false;
       });
     },

+ 66 - 8
src/views/live/liveConsole/LiveConsole.vue

@@ -113,7 +113,7 @@
         </div>
         <div class="message-container" @click="handleMessageBoxClick">
           <el-scrollbar class="custom-scrollbar" style="height: 500px; width: 100%;" ref="manageRightRef">
-            <el-row v-for="m in msgList" :key="m.msgId">
+            <el-row v-for="(m, msgIndex) in msgList" :key="msgRowKey(m, msgIndex)">
             <el-row v-if="m.userId !== userId" style="margin-top: 5px" type="flex" align="top" >
               <el-col :span="3" style="margin-left: 10px"><el-avatar :src="m.avatar"/></el-col>
               <el-col :span="15">
@@ -127,7 +127,7 @@
                   <el-col>
                     <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="changeUserState(m)">{{ m.msgStatus === 1 ? '解禁' : '禁言' }}</a>
                     <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="blockUser(m)">拉黑</a>
-                    <a v-if="m.singleVisible === 1" style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">解除用户自见</a>
+                    <a v-if="isUserSingleVisible(m)" style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">解除用户自见</a>
                     <a v-else style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="singleVisible(m)">用户自见</a>
                     <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="replyUser(m)">回复</a>
                     <a style="cursor: pointer;color: #ff0000;padding: 8px 8px 0 0;font-size: 12px;" @click.stop="deleteMsg(m)">删除</a>
@@ -293,6 +293,8 @@ export default {
         videoUrl: '',
       },
       msgList:[],
+      // 按用户维度维护自见状态,避免消息行共享/重渲染导致按钮状态串台
+      userSingleVisibleMap: {},
       currentTab: 'al',
       searchKeyword: '',
       globalVisible: false,
@@ -517,17 +519,46 @@ export default {
       // loadMsgList 中会自动滚动到底部,因为 isAutoScrollEnabled 已经是 true
       this.loadMsgList();
     },
+    msgRowKey(m, index) {
+      if (m && m.msgId != null && m.msgId !== '') {
+        return 'msg-' + m.msgId
+      }
+      return 'msg-tmp-' + (m && m.userId != null ? m.userId : 'x') + '-' + (m && m.createTime ? m.createTime : '') + '-' + index
+    },
+    isUserSingleVisible(m) {
+      if (!m || m.userId == null) {
+        return false
+      }
+      const uid = String(m.userId)
+      if (Object.prototype.hasOwnProperty.call(this.userSingleVisibleMap, uid)) {
+        return Number(this.userSingleVisibleMap[uid]) === 1
+      }
+      return Number(m.singleVisible) === 1
+    },
+    applyUserSingleVisible(userId, status) {
+      if (userId == null) {
+        return
+      }
+      const uid = String(userId)
+      const next = Number(status) === 1 ? 1 : 0
+      this.$set(this.userSingleVisibleMap, uid, next)
+      this.msgList.forEach(m1 => {
+        if (String(m1.userId) === uid) {
+          this.$set(m1, 'singleVisible', next)
+        }
+      })
+    },
     singleVisible(m){
-      // 过滤当前所有消息 找到userId的相同的消息 更改他们的自可见状态
-      m.singleVisible= m.singleVisible === 1 ? 0 : 1
-      this.msgList.forEach(m1 => {m1.singleVisible = m1.userId === m.userId ? m.singleVisible : !m.singleVisible})
+      // 仅更新同一用户的消息自见状态,不影响其他用户
+      const status = this.isUserSingleVisible(m) ? 0 : 1
+      this.applyUserSingleVisible(m.userId, status)
       // 消息自可见
       let msg = {
         liveId: this.liveId,
         userId: m.userId,
         userType: 0,
         cmd: 'singleVisible',
-        status:m.singleVisible
+        status
       }
       this.socket.send(JSON.stringify(msg))
     },
@@ -710,14 +741,15 @@ export default {
         return;
       }
 
+      // userId 使用专用 fs_user(400001),与后端入库账号一致
       let msg = {
         msg: this.newMsg,
         liveId: this.liveId,
-        userId: this.userId,
+        userId: 400001,
         userType: 1,
         cmd: 'sendMsg',
         avatar: this.$store.state.user.user.avatar,
-        nickName: this.$store.state.user.user.nickName,
+        nickName: this.$store.state.user.user.nickName || '直播管理员',
       }
 
       this.socket.send(JSON.stringify(msg))
@@ -748,6 +780,14 @@ export default {
             message.msgStatus = 0
           }
           delete message.params
+          if (message.userId != null) {
+            const uid = String(message.userId)
+            if (Object.prototype.hasOwnProperty.call(this.userSingleVisibleMap, uid)) {
+              message.singleVisible = Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0
+            } else {
+              this.$set(this.userSingleVisibleMap, uid, Number(message.singleVisible) === 1 ? 1 : 0)
+            }
+          }
           if(this.msgList.length > 50){
             this.msgList.shift()
           }
@@ -807,6 +847,8 @@ export default {
             this.silencedDisplayList = this.silencedDisplayList.filter(u => u.userId !== user.userId);
           }
 
+        } else if (cmd === 'singleVisible') {
+          this.applyUserSingleVisible(data.userId, data.status)
         } else if (cmd === 'live_start') {
 
         } else if (cmd === 'live_end') {
@@ -1202,6 +1244,14 @@ export default {
                 row.msgStatus = 0
               }
               this.msgList.push(row)
+              if (row.userId != null) {
+                const uid = String(row.userId)
+                if (!Object.prototype.hasOwnProperty.call(this.userSingleVisibleMap, uid)) {
+                  this.$set(this.userSingleVisibleMap, uid, Number(row.singleVisible) === 1 ? 1 : 0)
+                } else {
+                  this.$set(row, 'singleVisible', Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0)
+                }
+              }
             }
           })
 
@@ -1210,6 +1260,14 @@ export default {
           this.alDisplayList.forEach(u => {
             this.msgList.filter(m => m.userId === u.userId).forEach(m => m.msgStatus = u.msgStatus)
           })
+          Object.keys(this.userSingleVisibleMap).forEach(uid => {
+            const status = Number(this.userSingleVisibleMap[uid]) === 1 ? 1 : 0
+            this.msgList.forEach(m => {
+              if (String(m.userId) === uid) {
+                this.$set(m, 'singleVisible', status)
+              }
+            })
+          })
 
           // 所有消息加载完成后,根据自动滚动状态决定是否滚动
           this.$nextTick(() => {

+ 262 - 0
src/views/qw/QwSopTempVoiceGroup/index.vue

@@ -0,0 +1,262 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="${comment}" prop="voiceGroupName">
+        <el-select v-model="queryParams.voiceGroupName" placeholder="请选择${comment}" clearable size="small">
+          <el-option label="请选择字典生成" value="" />
+        </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="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:edit']"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:export']"
+        >导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="QwSopTempVoiceGroupList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="${comment}" align="center" prop="id" />
+      <el-table-column label="${comment}" align="center" prop="voiceGroupName" />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['QwSopTempVoiceGroup:QwSopTempVoiceGroup:remove']"
+          >删除</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-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="${comment}" prop="voiceGroupName">
+          <el-select v-model="form.voiceGroupName" placeholder="请选择${comment}">
+            <el-option label="请选择字典生成" value="" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listQwSopTempVoiceGroup, getQwSopTempVoiceGroup, delQwSopTempVoiceGroup, addQwSopTempVoiceGroup, updateQwSopTempVoiceGroup, exportQwSopTempVoiceGroup } from "@/api/qw/QwSopTempVoiceGroup";
+
+export default {
+  name: "QwSopTempVoiceGroup",
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 语音分类-主要是区分侧边栏语音类型表格数据
+      QwSopTempVoiceGroupList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        voiceGroupName: null,
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    /** 查询语音分类-主要是区分侧边栏语音类型列表 */
+    getList() {
+      this.loading = true;
+      listQwSopTempVoiceGroup(this.queryParams).then(response => {
+        this.QwSopTempVoiceGroupList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        voiceGroupName: null,
+        createTime: null,
+        updateTime: null
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加语音分类-主要是区分侧边栏语音类型";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids
+      getQwSopTempVoiceGroup(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改语音分类-主要是区分侧边栏语音类型";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.id != null) {
+            updateQwSopTempVoiceGroup(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addQwSopTempVoiceGroup(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除语音分类-主要是区分侧边栏语音类型编号为"' + ids + '"的数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(function() {
+          return delQwSopTempVoiceGroup(ids);
+        }).then(() => {
+          this.getList();
+          this.msgSuccess("删除成功");
+        }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有语音分类-主要是区分侧边栏语音类型数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(() => {
+          this.exportLoading = true;
+          return exportQwSopTempVoiceGroup(queryParams);
+        }).then(response => {
+          this.download(response.msg);
+          this.exportLoading = false;
+        }).catch(() => {});
+    }
+  }
+};
+</script>

+ 1 - 1
src/views/qw/QwWorkTaskNew/deptWorkTask.vue

@@ -80,7 +80,6 @@
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="客户昵称" align="center" prop="name" />
       <el-table-column label="企微账号" align="center" prop="qwUserName" />
-	  <el-table-column label="企微昵称" align="center" prop="nickName" />
       <el-table-column label="状态" align="center" prop="status">
 		 <template slot-scope="scope">
 		   <dict-tag :options="statusOptions" :value="scope.row.status"/>
@@ -97,6 +96,7 @@
 	    </template>
 	  </el-table-column>
       <el-table-column label="标题" align="center" prop="title" />
+      <el-table-column label="具体课节" align="center" prop="videoName" />
       <el-table-column label="描述" align="center" prop="description" />
       <el-table-column label="分值" align="center" prop="score">
         <template slot-scope="scope">

+ 1 - 0
src/views/qw/QwWorkTaskNew/index.vue

@@ -86,6 +86,7 @@
 	    </template>
 	  </el-table-column>
       <el-table-column label="标题" align="center" prop="title" />
+      <el-table-column label="具体课节" align="center" prop="videoName" />
       <el-table-column label="描述" align="center" prop="description" />
       <el-table-column label="分值" align="center" prop="score">
         <template slot-scope="scope">

+ 1 - 1
src/views/qw/QwWorkTaskNew/qwWorkTask.vue

@@ -80,7 +80,6 @@
       <el-table-column type="selection" width="55" align="center" />
       <el-table-column label="客户昵称" align="center" prop="name" />
       <el-table-column label="企微账号" align="center" prop="qwUserName" />
-	  <el-table-column label="企微昵称" align="center" prop="nickName" />
       <el-table-column label="状态" align="center" prop="status">
 		 <template slot-scope="scope">
 		   <dict-tag :options="statusOptions" :value="scope.row.status"/>
@@ -97,6 +96,7 @@
 	    </template>
 	  </el-table-column>
       <el-table-column label="标题" align="center" prop="title" />
+      <el-table-column label="具体课节" align="center" prop="videoName" />
       <el-table-column label="描述" align="center" prop="description" />
       <el-table-column label="分值" align="center" prop="score">
         <template slot-scope="scope">

+ 54 - 17
src/views/qw/contactWay/index.vue

@@ -150,8 +150,13 @@
             <el-table-column label="state参数" align="center" prop="state" />
             <el-table-column label="用户" align="center" prop="userIds" width="200px">
               <template slot-scope="scope">
-                <div v-for="i in JSON.parse(scope.row.userIds)" :key="i" style="display: inline;">
-                <el-tag type="success" v-for="ii in companyUserList" :key="ii.id" style="margin: 3px;" v-if="ii.qwUserId==i">{{ii.qwUserName}}</el-tag>
+                <div>
+                  <el-tag type="success" v-for="(user, idx) in getMatchedUsers(scope.row.userIds)" :key="user.id" style="margin: 3px;" v-if="idx < 3 || expandedUserRows[scope.$index]">
+                    {{ user.qwUserName }}
+                  </el-tag>
+                  <span v-if="getMatchedUsers(scope.row.userIds).length > 3" style="cursor: pointer; color: #409EFF; font-size: 12px; margin-left: 5px;" @click="toggleUserExpand(scope.$index)">
+                    {{ expandedUserRows[scope.$index] ? '收起' : '展开(' + (getMatchedUsers(scope.row.userIds).length - 3) + ')' }}
+                  </span>
                 </div>
               </template>
             </el-table-column>
@@ -299,7 +304,7 @@
               closable
               :disable-transitions="false"
               @close="handleCloseGroupUser(user)">
-              {{'【'+user.qwUserId +'】'+ user.qwUserName }}
+              {{user.qwUserName }}
             </el-tag>
           </div>
 
@@ -345,7 +350,7 @@
            <el-option
              v-for="dict in companyUserList"
              :key="dict.qwUserId"
-             :label="'【'+ dict.qwUserId +'】'+ dict.qwUserName"
+             :label="dict.qwUserName"
              :value="dict.qwUserId">
            </el-option>
          </el-select>
@@ -704,7 +709,7 @@
     </el-dialog>
 
     <el-dialog :title="listUser.title" :visible.sync="listUser.open" width="700px" append-to-body>
-      <qwUserList ref="QwUserList" @selectUserList="selectUserList"></qwUserList>
+      <qwUserListWay ref="qwUserListWay" @selectUserList="selectUserList" @removeUser="handleRemoveUser"></qwUserListWay>
     </el-dialog>
 
     </div>
@@ -727,10 +732,10 @@ import ImageUpload from '@/views/qw/material/ImageUpload.vue'
 import statisDetails from '@/views/qw/contactWay/statisDetails';
 import { getMyQwUserList,getMyQwCompanyList } from "@/api/qw/user";
 import {courseList, videoList} from "@/api/qw/sop";
-import qwUserList from "@/views/qw/user/qwUserList.vue";
+import qwUserListWay from "@/views/qw/user/qwUserListWay.vue";
 export default {
   name: "ContactWay",
-   components: {qwUserList, contactWayGroup,ImageUpload,statisDetails},
+   components: {qwUserListWay, contactWayGroup,ImageUpload,statisDetails},
   data() {
     return {
       // 遮罩层
@@ -868,6 +873,7 @@ export default {
       },
       // 是否发送欢迎语字典
       companyUserList:[],
+      expandedUserRows: {},
       // 表单校验
       rules: {
         name:[{required:true,message:"名称不能为空",trigger:"blur"}],
@@ -939,6 +945,13 @@ export default {
 
   },
   methods: {
+    getMatchedUsers(userIdsStr) {
+      const userIds = JSON.parse(userIdsStr || '[]');
+      return this.companyUserList.filter(u => userIds.includes(u.qwUserId));
+    },
+    toggleUserExpand(index) {
+      this.$set(this.expandedUserRows, index, !this.expandedUserRows[index]);
+    },
     updateCorpId(){
       setTimeout(() => {
         this.$refs.contactWayGroup.getDetails(this.queryParams.corpId);
@@ -1564,7 +1577,7 @@ export default {
     //选择企微员工时
     handleListUser(type, sendType, selectOne) {
       setTimeout(() => {
-        this.$refs.QwUserList.getDetails(this.queryParams.corpId, type, sendType, selectOne);
+        this.$refs.qwUserListWay.getDetails(this.queryParams.corpId, type, sendType, selectOne, this.userIds);
       }, 1);
 
       this.listUser.title = "选择企微员工"
@@ -1620,10 +1633,14 @@ export default {
     },
     // 全天在线模式 - 删除员工标签
     handleCloseGroupUser(user) {
-      const index = this.userSelectList.findIndex(t => t.id === user.id);
-      if (index !== -1) {
-        this.userSelectList.splice(index, 1);
-        this.userIds.splice(index, 1);
+      const userSelectIndex = this.userSelectList.findIndex(t => t.id === user.id);
+      if (userSelectIndex !== -1) {
+        this.userSelectList.splice(userSelectIndex, 1);
+      }
+
+      const userIdsIndex = this.userIds.indexOf(user.qwUserId);
+      if (userIdsIndex !== -1) {
+        this.userIds.splice(userIdsIndex, 1);
       }
 
       for (let i = this.userLimitJson.length - 1; i >= 0; i--) {
@@ -1632,12 +1649,28 @@ export default {
         }
       }
 
+      if (this.$refs.qwUserListWay) {
+        this.$refs.qwUserListWay.selectedUserIds = [...this.userIds];
+      }
+
+    },
+    // 从员工选择弹窗中移除已选中员工
+    handleRemoveUser(user) {
+      if (this.currentEditingTimeSlotIndex === -1) {
+        // 全天在线模式
+        this.handleCloseGroupUser(user);
+      } else {
+        // 自动上下线模式
+        this.handleCloseTimeSlotUser(this.currentEditingTimeSlotIndex, user);
+      }
     },
     // 自动上下线模式 - 打开员工选择弹窗
     handleListUserForTimeSlot(timeSlotIndex) {
       this.currentEditingTimeSlotIndex = timeSlotIndex;
+      const timeSlot = this.userTimeJson[timeSlotIndex];
+      const selectedUserIds = timeSlot.userIds || [];
       setTimeout(() => {
-        this.$refs.QwUserList.getDetails(this.queryParams.corpId, this.form.type, this.form.sendType, true);
+        this.$refs.qwUserListWay.getDetails(this.queryParams.corpId, this.form.type, this.form.sendType, true, selectedUserIds);
       }, 1);
       this.listUser.title = "选择企微员工"
       this.listUser.open = true;
@@ -1647,12 +1680,16 @@ export default {
       const timeSlot = this.userTimeJson[timeSlotIndex];
       const userListIndex = timeSlot.userList.findIndex(t => t.id === user.id);
       if (userListIndex !== -1) {
-        // 使用 splice 删除并强制更新
         timeSlot.userList.splice(userListIndex, 1);
-        timeSlot.userIds.splice(userListIndex, 1);
-        // 强制更新整个 userTimeJson 数组以触发响应式更新
-        this.$set(this.userTimeJson, timeSlotIndex, {...timeSlot});
       }
+
+      const userIdsIndex = timeSlot.userIds.indexOf(user.qwUserId);
+      if (userIdsIndex !== -1) {
+        timeSlot.userIds.splice(userIdsIndex, 1);
+      }
+
+      this.$set(this.userTimeJson, timeSlotIndex, {...timeSlot});
+      this.$refs.qwUserListWay.selectedUserIds = timeSlot.userIds || [];
     },
   }
 };

+ 96 - 1
src/views/qw/externalContact/deptIndex.vue

@@ -307,6 +307,29 @@
           v-hasPermi="['qw:externalContactInfo:deptUpdateTalk']"
         >批量更改交流状态</el-button>
       </el-col>
+
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevel"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级
+            </el-button>
+        </el-col>
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevelFilter"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级(筛选条件)
+            </el-button>
+        </el-col>>
       <!--       <el-col :span="1.5">-->
       <!--        <el-button-->
       <!--          type="primary"-->
@@ -898,6 +921,22 @@
         <el-tag v-for="item in repeat.list">{{item.qwUserName}}</el-tag>
       </el-row>
     </el-dialog>
+    <el-dialog :title="ratingOpen.title" :visible.sync="ratingOpen.open" width="600px" append-to-body>
+          <div style="padding: 20px 0;">
+              <el-select v-model="ratingOpen.level" placeholder="客户等级" clearable size="small">
+                  <el-option
+                      v-for="dict in ratingType"
+                      :key="dict.dictValue"
+                      :label="dict.dictLabel"
+                      :value="dict.dictValue"
+                  />
+              </el-select>
+          </div>
+          <div slot="footer" class="dialog-footer">
+              <el-button type="primary" @click="ratingSubmitForm()">确 定</el-button>
+              <el-button @click="ratingCancel">取 消</el-button>
+          </div>
+    </el-dialog>
 
     <!-- 重粉记录 -->
     <el-drawer title="重粉记录" :visible.sync="repeatRecord.open" size="75%" append-to-body>
@@ -1020,7 +1059,13 @@ export default {
   components:{PaginationMore, mycustomer,customerDetails,SopDialog,selectUser,info,healthRecordDetails,userDetails,customerDetail,createOrder},
   data() {
     return {
-
+     ratingOpen:{
+        open:false,
+        title:'',
+        level:null,
+        userIds:null,
+        filter:false,
+      },
       resultDialogVisible: false,
       resultMessage: '',
       resultTitle:'',
@@ -1308,6 +1353,56 @@ export default {
 
   },
   methods: {
+
+    handleBatchUpdateLevel(){
+
+      if (this.ids == null || this.ids == "") {
+          return this.$message('请选择需要添加备注的客户');
+      }
+
+      this.ratingOpen.open=true;
+      this.ratingOpen.title='批量修改客户等级'
+      this.ratingOpen.filter=false;
+    },
+
+    handleBatchUpdateLevelFilter(){
+      this.ratingOpen.open=true;
+      this.ratingOpen.title='批量修改客户等级'
+      this.ratingOpen.filter=true;
+    },
+
+    ratingSubmitForm(){
+
+          if (!this.ratingOpen.level){
+              return this.$message.error('请选择要修改的等级');
+          }
+
+          this.ratingOpen.userIds = this.ids;
+
+          let obj = JSON.parse(JSON.stringify(this.queryParams))
+          if(obj.tagIds !== null && obj.tagIds !== undefined && obj.tagIds !== ''){
+              obj.tagIds = obj.tagIds.split(",");
+          }
+          this.ratingOpen.param = obj;
+
+          extChangeLevel(this.ratingOpen).then(response => {
+              this.msgSuccess("修改完成");
+              this.getList();
+          }).finally(() => {
+              this.ratingCancel();
+          });
+
+        },
+
+
+    ratingCancel(){
+          this.ratingOpen={
+              open: false,
+              title: '',
+              level: null,
+              userIds: null
+          }
+        },
     loadExternalContactFuncDict() {
       this.getDicts("sys_qw_external_contact_func").then(response => {
         const list = response.data || [];

+ 117 - 17
src/views/qw/externalContact/index.vue

@@ -355,6 +355,28 @@
         v-hasPermi="['qw:externalContactInfo:updateTalk']"
 	    >批量更改交流状态</el-button>
 	  </el-col>
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevel"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级
+            </el-button>
+        </el-col>
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevelFilter"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级(筛选条件)
+            </el-button>
+        </el-col>
 <!--       <el-col :span="1.5">-->
 <!--        <el-button-->
 <!--          type="primary"-->
@@ -1108,27 +1130,49 @@
       <el-button type="primary" @click="submitStatusForm">提 交</el-button>
     </div>
   </el-dialog>
+
+        <el-dialog :title="ratingOpen.title" :visible.sync="ratingOpen.open" width="600px" append-to-body>
+          <div style="padding: 20px 0;">
+              <el-select v-model="ratingOpen.level" placeholder="客户等级" clearable size="small">
+                  <el-option
+                      v-for="dict in ratingType"
+                      :key="dict.dictValue"
+                      :label="dict.dictLabel"
+                      :value="dict.dictValue"
+                  />
+              </el-select>
+          </div>
+          <div slot="footer" class="dialog-footer">
+              <el-button type="primary" @click="ratingSubmitForm()">确 定</el-button>
+              <el-button @click="ratingCancel">取 消</el-button>
+          </div>
+        </el-dialog>
   </div>
 </template>
 
 <script>
 import {
-  bindUserId,
-  addTag,
-  delTag,
-  batchUpdateExternalContactNotes,
-  listExternalContact,
-  getExternalContact,
-  delExternalContact,
-  addExternalContact,
-  updateExternalContact,
-  updateRemarkMobiles,
-  exportExternalContact,
-  editbindCustomer,
-  setCustomerCourseSop,
-  getCustomerCourseSop,
-  setCustomerCourseSopList,
-  unBindUserId, updateExternalContactCall,updateExternalContactStatus,getWatchLogList,getRepeatRecordList
+    bindUserId,
+    addTag,
+    delTag,
+    batchUpdateExternalContactNotes,
+    listExternalContact,
+    getExternalContact,
+    delExternalContact,
+    addExternalContact,
+    updateExternalContact,
+    updateRemarkMobiles,
+    exportExternalContact,
+    editbindCustomer,
+    setCustomerCourseSop,
+    getCustomerCourseSop,
+    setCustomerCourseSopList,
+    unBindUserId,
+    updateExternalContactCall,
+    updateExternalContactStatus,
+    getWatchLogList,
+    getRepeatRecordList,
+    extChangeLevel
 } from '@/api/qw/externalContact'
 import {getMyQwUserList, getMyQwCompanyList, updateUser,getQwUserListLikeName} from "@/api/qw/user";
 import {listTag, getTag, searchTags} from "@/api/qw/tag";
@@ -1407,6 +1451,13 @@ export default {
         corpId: null,
         customerRow: null,
       },
+        ratingOpen:{
+            open:false,
+            title:'',
+            level:null,
+            userIds:null,
+            filter:false,
+        },
     };
   },
   created() {
@@ -1460,7 +1511,56 @@ export default {
 
   },
   methods: {
-    loadExternalContactFuncDict() {
+
+      handleBatchUpdateLevel(){
+
+          if (this.ids == null || this.ids == "") {
+              return this.$message('请选择需要添加备注的客户');
+          }
+
+          this.ratingOpen.open=true;
+          this.ratingOpen.title='批量修改客户等级'
+          this.ratingOpen.filter=false;
+      },
+
+      handleBatchUpdateLevelFilter(){
+          this.ratingOpen.open=true;
+          this.ratingOpen.title='批量修改客户等级'
+          this.ratingOpen.filter=true;
+      },
+      ratingSubmitForm(){
+
+          if (!this.ratingOpen.level){
+              return this.$message.error('请选择要修改的等级');
+          }
+
+          this.ratingOpen.userIds = this.ids;
+
+          let obj = JSON.parse(JSON.stringify(this.queryParams))
+          if(obj.tagIds !== null && obj.tagIds !== undefined && obj.tagIds !== ''){
+              obj.tagIds = obj.tagIds.split(",");
+          }
+          this.ratingOpen.param = obj;
+
+          extChangeLevel(this.ratingOpen).then(response => {
+              this.msgSuccess("修改完成");
+              this.getList();
+          }).finally(() => {
+              this.ratingCancel();
+          });
+
+      },
+
+      ratingCancel(){
+          this.ratingOpen={
+              open: false,
+              title: '',
+              level: null,
+              userIds: null
+          }
+      },
+
+      loadExternalContactFuncDict() {
       this.getDicts("sys_qw_external_contact_func").then(response => {
         const list = response.data || [];
         const btnDict = list.find(item => String(item.dictSort) === "1");

+ 125 - 20
src/views/qw/externalContact/myExternalContact.vue

@@ -339,6 +339,28 @@
         v-hasPermi="['qw:externalContactInfo:myUpdateAllTalk']"
 	    >更改全部交流状态</el-button>
 	  </el-col>
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevel"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级
+            </el-button>
+        </el-col>
+        <el-col :span="1.5">
+            <el-button
+                type="warning"
+                plain
+                icon="el-icon-edit"
+                size="mini"
+                @click="handleBatchUpdateLevelFilter"
+                v-hasPermi="['qw:externalContact:changeLevel']"
+            >批量修改客户等级(筛选条件)
+            </el-button>
+        </el-col>
 <!--     <el-col :span="1.5">-->
 <!--        <el-button-->
 <!--          type="primary"-->
@@ -1192,29 +1214,53 @@
       <el-button type="primary" @click="submitStatusForm">提 交</el-button>
     </div>
   </el-dialog>
+
+      <el-dialog :title="ratingOpen.title" :visible.sync="ratingOpen.open" width="600px" append-to-body>
+          <div style="padding: 20px 0;">
+              <el-select v-model="ratingOpen.level" placeholder="客户等级" clearable size="small">
+                  <el-option
+                      v-for="dict in ratingType"
+                      :key="dict.dictValue"
+                      :label="dict.dictLabel"
+                      :value="dict.dictValue"
+                  />
+              </el-select>
+          </div>
+          <div slot="footer" class="dialog-footer">
+              <el-button type="primary" @click="ratingSubmitForm()">确 定</el-button>
+              <el-button @click="ratingCancel">取 消</el-button>
+          </div>
+      </el-dialog>
   </div>
 </template>
 
 <script>
 import {
-  myList,
-  bindUserId,
-  addTag,
-  batchUpdateExternalContactNotes,
-  delTag,
-  listExternalContact,
-  getExternalContact,
-  delExternalContact,
-  addExternalContact,
-  updateExternalContact,
-  updateRemarkMobiles,
-  exportExternalContact,
-  editbindCustomer,
-  syncMyAddExternalContact,
-  setCustomerCourseSop,
-  getCustomerCourseSop,
-  setCustomerCourseSopList,
-  syncMyExternalContact, unBindUserId, updateExternalContactCall,exportMyExternalContact,updateExternalContactStatus,getWatchLogList,getRepeatRecordList
+    myList,
+    bindUserId,
+    addTag,
+    batchUpdateExternalContactNotes,
+    delTag,
+    listExternalContact,
+    getExternalContact,
+    delExternalContact,
+    addExternalContact,
+    updateExternalContact,
+    updateRemarkMobiles,
+    exportExternalContact,
+    editbindCustomer,
+    syncMyAddExternalContact,
+    setCustomerCourseSop,
+    getCustomerCourseSop,
+    setCustomerCourseSopList,
+    syncMyExternalContact,
+    unBindUserId,
+    updateExternalContactCall,
+    exportMyExternalContact,
+    updateExternalContactStatus,
+    getWatchLogList,
+    getRepeatRecordList,
+    extChangeLevel
 } from '@/api/qw/externalContact'
 import info from "@/views/qw/externalContact/info.vue";
 import {getMyQwUserList, getMyQwCompanyList, handleInputAuthAppKey, updateUser} from "@/api/qw/user";
@@ -1239,7 +1285,15 @@ export default {
   components:{PaginationMore, mycustomer,customerDetails,SopDialog,selectUser,info,Collection,userDetails,customerDetail,createOrder},
   data() {
     return {
-      aiAnalyze: {
+        ratingOpen:{
+            open:false,
+            title:'',
+            level:null,
+            userIds:null,
+            filter:false,
+        },
+
+        aiAnalyze: {
         title: "AI 分析",
         open: false,
         userId: null,
@@ -1534,7 +1588,58 @@ export default {
 
   },
   methods: {
-    loadExternalContactFuncDict() {
+
+      handleBatchUpdateLevel(){
+
+          if (this.ids == null || this.ids == "") {
+              return this.$message('请选择需要添加备注的客户');
+          }
+
+          this.ratingOpen.open=true;
+          this.ratingOpen.title='批量修改客户等级'
+          this.ratingOpen.filter=false;
+      },
+
+      handleBatchUpdateLevelFilter(){
+          this.ratingOpen.open=true;
+          this.ratingOpen.title='批量修改客户等级'
+          this.ratingOpen.filter=true;
+      },
+
+      ratingSubmitForm(){
+
+          if (!this.ratingOpen.level){
+              return this.$message.error('请选择要修改的等级');
+          }
+
+          this.ratingOpen.userIds = this.ids;
+
+          let obj = JSON.parse(JSON.stringify(this.queryParams))
+          if(obj.tagIds !== null && obj.tagIds !== undefined && obj.tagIds !== ''){
+              obj.tagIds = obj.tagIds.split(",");
+          }
+          this.ratingOpen.param = obj;
+
+          extChangeLevel(this.ratingOpen).then(response => {
+              this.msgSuccess("修改完成");
+              this.getList();
+          }).finally(() => {
+              this.ratingCancel();
+          });
+
+      },
+
+
+      ratingCancel(){
+          this.ratingOpen={
+              open: false,
+              title: '',
+              level: null,
+              userIds: null
+          }
+      },
+
+      loadExternalContactFuncDict() {
       this.getDicts("sys_qw_external_contact_func").then(response => {
         const list = response.data || [];
         const btnDict = list.find(item => String(item.dictSort) === "1");

+ 377 - 0
src/views/qw/feishuAccount/index.vue

@@ -0,0 +1,377 @@
+<template>
+  <div class="app-container">
+    <!-- 搜索表单 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="AppId" prop="appId">
+        <el-input
+          v-model="queryParams.appId"
+          placeholder="请输入AppId"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="账号" prop="phone">
+        <el-input
+          v-model="queryParams.phone"
+          placeholder="请输入归属账号"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable size="small">
+          <el-option label="启用" :value="1" />
+          <el-option label="禁用" :value="0" />
+        </el-select>
+      </el-form-item>
+      <!-- 销售名称(nickName) -->
+      <el-form-item label="销售名称" prop="nickName">
+        <el-input
+          v-model="queryParams.nickName"
+          placeholder="请输入销售名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <!-- 归属组别(deptId) -->
+      <el-form-item label="归属组别" prop="deptId">
+        <el-cascader
+          v-model="queryParams.deptId"
+          :options="deptOptions"
+          :props="cascaderProps"
+          placeholder="请选择组别"
+          clearable
+          size="small"
+          style="width: 200px"
+          @change="handleDeptChange"
+        />
+      </el-form-item>
+      <!-- 我的飞书应用勾选框 -->
+      <el-form-item label="我的应用" prop="myAccount">
+        <el-checkbox v-model="queryParams.myAccount"></el-checkbox>
+      </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="primary"
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+        >删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 表格 -->
+    <el-table v-loading="loading" :data="accountList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="AppId" align="center" prop="appId" min-width="180" />
+      <el-table-column label="AppSecret" align="center" prop="appSecret" min-width="200" show-overflow-tooltip />
+      <el-table-column label="状态" align="center" prop="status" width="80">
+        <template slot-scope="scope">
+          <el-switch
+            v-model="scope.row.status"
+            :active-value="1"
+            :inactive-value="0"
+            :disabled="!scope.row.isMyAccount"
+            @change="handleStatusChange(scope.row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="错误信息" align="center" prop="errorMsg" min-width="150" show-overflow-tooltip />
+      <el-table-column label="归属账号" align="center" prop="phone" width="120" />
+      <el-table-column label="所属分组" align="center" prop="deptName" width="120" />
+      <el-table-column label="所属员工" align="center" prop="nickName" width="120" />
+<!--      <el-table-column label="已使用次数" align="center" prop="numberUse" width="100" />-->
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <!-- 仅当 isMyAccount 为 true 时显示修改和删除按钮 -->
+          <el-button
+            v-if="scope.row.isMyAccount"
+            size="mini"
+            type="text"
+            icon="el-icon-edit"
+            @click="handleUpdate(scope.row)"
+          >修改</el-button>
+          <el-button
+            v-if="scope.row.isMyAccount"
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(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-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="AppId" prop="appId">
+          <el-input v-model="form.appId" placeholder="请输入飞书AppId" />
+        </el-form-item>
+        <el-form-item label="AppSecret" prop="appSecret">
+          <el-input v-model="form.appSecret" placeholder="请输入飞书AppSecret" show-password />
+        </el-form-item>
+        <el-form-item label="归属手机号" prop="phone">
+          <el-input v-model="form.phone" placeholder="请输入归属手机号" />
+        </el-form-item>
+        <el-form-item label="已使用次数" prop="numberUse">
+          <el-input-number v-model="form.numberUse" :min="0" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">禁用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listFeishuAccount,
+  getFeishuAccount,
+  delFeishuAccount,
+  addFeishuAccount,
+  updateFeishuAccount,
+  changeFeishuAccountStatus
+} from "@/api/qw/feishuAccount";
+// 导入部门树接口(根据实际路径调整)
+import { treeselect } from "@/api/company/companyDept";
+import {parseTime} from "../../../utils/common";
+
+export default {
+  name: "FeishuAccount",
+  data() {
+    return {
+      loading: true,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      total: 0,
+      accountList: [],
+      title: '',
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        appId: null,
+        phone: null,
+        status: null,
+        nickName: null,    // 销售名称
+        deptId: null,      // 归属组别ID
+        myAccount: true    // 默认勾选“我的飞书应用”
+      },
+      form: {
+        id: null,
+        appId: null,
+        appSecret: null,
+        status: 1,
+        errorMsg: null,
+        phone: null,
+        numberUse: null
+      },
+      rules: {
+        appId: [{ required: true, message: 'AppId不能为空', trigger: 'blur' }],
+        appSecret: [{ required: true, message: 'AppSecret不能为空', trigger: 'blur' }]
+      },
+      // 部门树数据(用于归属组别下拉)
+      deptOptions: [],
+      // 级联选择器配置
+      cascaderProps: {
+        value: 'id',
+        label: 'label',
+        children: 'children',
+        checkStrictly: true,   // 允许选择任意层级
+        emitPath: false        // 只返回选中节点的 value(即 id)
+      }
+    };
+  },
+  created() {
+    this.getList();
+    this.getDeptTree(); // 加载部门树
+  },
+  methods: {
+    parseTime,
+    /** 查询账号列表 */
+    getList() {
+      this.loading = true;
+      listFeishuAccount(this.queryParams).then(response => {
+        this.accountList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    /** 加载部门树 */
+    getDeptTree() {
+      treeselect()
+        .then(response => {
+          this.deptOptions = response.data || [];
+        })
+        .catch(() => {
+          this.deptOptions = [];
+        });
+    },
+    /** 搜索按钮 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置搜索 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      // 清空查询字段,并重置 myAccount 为 true
+      this.queryParams.nickName = null;
+      this.queryParams.deptId = null;
+      this.queryParams.myAccount = true;
+      this.handleQuery();
+    },
+    /** 归属组别变化时自动触发搜索 */
+    handleDeptChange(value) {
+      this.handleQuery();
+    },
+    /** 多选变化 */
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id);
+      this.single = selection.length !== 1;
+      this.multiple = !selection.length;
+    },
+    /** 重置表单 */
+    reset() {
+      this.form = {
+        id: null,
+        appId: null,
+        appSecret: null,
+        status: 1,
+        errorMsg: null,
+        phone: null,
+        numberUse: null,
+      };
+      this.resetForm("form");
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 新增按钮 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "新增飞书应用账号";
+    },
+    /** 修改按钮 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids[0];
+      getFeishuAccount(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改飞书应用账号";
+      });
+    },
+    /** 提交表单 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.id != null) {
+            updateFeishuAccount(this.form).then(() => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addFeishuAccount(this.form).then(() => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除编号为"' + ids + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        return delFeishuAccount(ids);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      });
+    },
+    /** 状态切换 */
+    handleStatusChange(row) {
+      const newStatus = row.status;
+      const oldStatus = newStatus === 1 ? 0 : 1; // 用于回滚
+      const text = newStatus === 1 ? '启用' : '禁用';
+      this.$confirm('确认要' + text + '该账号吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        changeFeishuAccountStatus({
+          id: row.id,
+          status: newStatus,
+          errorMsg: newStatus === 1 ? '' : row.errorMsg
+        }).then(() => {
+          this.msgSuccess('状态修改成功');
+          this.getList();
+        }).catch(() => {
+          // 恢复状态
+          row.status = oldStatus;
+        });
+      }).catch(() => {
+        // 恢复状态
+        row.status = oldStatus;
+      });
+    }
+  }
+};
+</script>

+ 1207 - 0
src/views/qw/groupVoiceMsg/customerGroupDetails.vue

@@ -0,0 +1,1207 @@
+<template>
+  <div class="customer-group-details-container">
+    <el-card class="main-card">
+      <div class="section-header">
+        <i class="el-icon-data-analysis section-icon"></i>
+        <h2 class="section-title">数据统计</h2>
+      </div>
+      <div class="statistics-container">
+        <el-row :gutter="24">
+          <el-col :span="8" v-for="item in firstRowStats" :key="item.label">
+            <el-card class="statistic-card">
+              <div class="statistic-content">
+                <div class="statistic-value">{{ item.count }}</div>
+                <div class="statistic-label">{{ item.label }}</div>
+              </div>
+            </el-card>
+          </el-col>
+        </el-row>
+        <el-row :gutter="24" style="margin-top: 24px">
+          <el-col :span="8" v-for="item in secondRowStats" :key="item.label">
+            <el-card class="statistic-card">
+              <div class="statistic-content">
+                <div class="statistic-value">{{ item.count }}</div>
+                <div class="statistic-label">{{ item.label }}</div>
+              </div>
+            </el-card>
+          </el-col>
+        </el-row>
+      </div>
+    </el-card>
+
+    <el-card class="main-card">
+      <div class="section-header">
+        <i class="el-icon-phone-outline section-icon"></i>
+        <h2 class="section-title">呼叫详情</h2>
+      </div>
+
+      <div class="search-container">
+        <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="120px">
+          <el-form-item label="成员姓名" prop="nickName">
+            <el-input
+              v-model="queryParams.nickName"
+              placeholder="请输入想查询的成员姓名"
+              clearable
+              size="small"
+              @keyup.enter.native="handleQuery"
+              prefix-icon="el-icon-search"
+            />
+          </el-form-item>
+          <el-form-item label="客户名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              placeholder="请输入想查询的客户姓名"
+              clearable
+              size="small"
+              @keyup.enter.native="handleQuery"
+              prefix-icon="el-icon-user"
+            />
+          </el-form-item>
+          <el-form-item label="呼叫状态" prop="sendStatus">
+              <el-select v-model="queryParams.sendStatus" placeholder="请选择客户拨通状态">
+                <el-option v-for="item in groupMsgSendStatusOptions"
+                           :key="item.dictValue"
+                           :label="item.dictLabel"
+                           :value="item.dictValue">
+                </el-option>
+              </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>
+      </div>
+
+      <el-table v-loading="loading" :data="groupMsgUserDetailsList" class="data-table" stripe>
+        <el-table-column label="企微成员" align="center" prop="nickName" />
+        <el-table-column label="客户" align="center" prop="name" >
+          <template slot-scope="scope">
+            <div class="customer-info">
+              <el-image
+                v-if="scope.row.avatar"
+                class="customer-avatar"
+                :src="scope.row.avatar"
+                fit="cover"
+                @click="openImageViewer(scope.row.avatar)"
+              />
+              <span class="customer-name">{{scope.row.name}}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="预呼叫时间" align="center" prop="sendTime" />
+        <el-table-column label="呼叫状态" align="center" prop="sendStatus" >
+          <template slot-scope="scope">
+            <dict-tag :options="groupMsgSendStatusOptions" :value="scope.row.sendStatus"/>
+          </template>
+        </el-table-column>
+        <el-table-column label="会话Id" align="center" prop="sessionId" />
+        <el-table-column label="外呼开始" align="center">
+          <template slot-scope="scope">
+            {{ scope.row.startCallTime ? $moment(scope.row.startCallTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="外呼结束" align="center">
+          <template slot-scope="scope">
+            {{ scope.row.endCallTime ? $moment(scope.row.endCallTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="外呼时长" align="center" prop="durationMsStr" />
+        <el-table-column label="通话详情" align="center">
+          <template slot-scope="scope">
+            <el-tooltip :content="scope.row.sessionId ? '查看通话详情' : '无通话记录'" placement="top">
+              <el-button
+                :type="scope.row.sessionId ? 'primary' : 'info'"
+                size="mini"
+                :disabled="!scope.row.sessionId"
+                @click="scope.row.sessionId && handleViewCallDetails(scope.row.sessionId)"
+                v-hasPermi="['qw:groupVoiceMsgUser:details:queryBySessionId']"
+                :icon="scope.row.sessionId ? 'el-icon-view' : 'el-icon-close-notification'"
+                class="detail-btn"
+              >
+                {{ scope.row.sessionId ? '查看详情' : '无详情' }}
+              </el-button>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container">
+        <pagination
+          v-show="total>0"
+          :total="total"
+          :page.sync="queryParams.pageNum"
+          :limit.sync="queryParams.pageSize"
+          @pagination="getUserList"
+        />
+      </div>
+    </el-card>
+
+    <!-- 通话详情弹窗 -->
+    <el-dialog
+      title="通话详情"
+      :visible.sync="callDetailsDialogVisible"
+      width="60%"
+      class="detail-dialog"
+      append-to-body
+      :close-on-click-modal="false"
+    >
+      <div v-loading="callDetailsLoading">
+        <div v-if="callDetails">
+          <!-- 基本信息 -->
+          <el-card class="info-card">
+            <div slot="header" class="info-header">
+              <span class="info-title"><i class="el-icon-info"></i> 通话概况</span>
+            </div>
+            <div class="info-grid">
+              <div class="info-item session-id">
+                <div class="info-icon session-id-bg">
+                  <i class="el-icon-connection"></i>
+                </div>
+                <div class="info-content">
+                  <div class="info-label">会话ID</div>
+                  <div class="info-value session-id-value">
+                    {{ callDetails.sessionId || '-' }}
+                    <el-tooltip content="复制会话ID" placement="top">
+                      <el-button
+                        v-if="callDetails.sessionId"
+                        type="text"
+                        size="mini"
+                        icon="el-icon-document-copy"
+                        @click="copySessionId(callDetails.sessionId)"
+                        class="copy-btn"
+                      />
+                    </el-tooltip>
+                  </div>
+                </div>
+              </div>
+
+              <div class="info-item agent">
+                <div class="info-icon agent-bg">
+                  <i class="el-icon-service"></i>
+                </div>
+                <div class="info-content">
+                  <div class="info-label">客服</div>
+                  <div class="info-value">{{ callDetails.aiSopCallRoleName || '-' }}</div>
+                </div>
+              </div>
+
+              <div class="info-item customer">
+                <div class="info-icon customer-bg">
+                  <i class="el-icon-user"></i>
+                </div>
+                <div class="info-content">
+                  <div class="info-label">用户</div>
+                  <div class="info-value">{{ callDetails.qwUserNickName || '-' }}</div>
+                </div>
+              </div>
+
+              <el-tooltip
+                :content="getSatisfactionTooltip()"
+                placement="top"
+              >
+                <div class="info-item satisfaction">
+                  <div class="info-icon satisfaction-bg">
+                    <i class="el-icon-star-on"></i>
+                  </div>
+                  <div class="info-content">
+                    <div class="info-label">满意度</div>
+                    <div class="info-value satisfaction-stars">
+                      <span
+                        v-for="star in 5"
+                        :key="star"
+                        :class="['star', getStarClass(star)]"
+                      >
+                        <i :class="getStarIcon(star)"></i>
+                      </span>
+                    </div>
+                  </div>
+                </div>
+              </el-tooltip>
+            </div>
+          </el-card>
+
+          <!-- 聊天记录 -->
+          <el-card class="chat-card">
+            <div slot="header" class="info-header">
+              <span class="info-title"><i class="el-icon-chat-line-round"></i> 聊天记录</span>
+            </div>
+            <chat-record-viewer
+              :chat-data="callDetails.aiChatDetails"
+              :initial-display-count="4"
+              :agent-name="callDetails.aiSopCallRoleName"
+              :customer-name="callDetails.externalUserName"
+              class="chat-viewer"
+            />
+          </el-card>
+
+          <!-- 语音记录 -->
+          <el-card class="voice-card">
+            <div slot="header" class="info-header">
+              <span class="info-title"><i class="el-icon-microphone"></i> 语音记录</span>
+            </div>
+            <div class="voice-container">
+              <div v-if="callDetails.voiceText" class="voice-list">
+                <!-- 只显示前两条语音 -->
+                <div v-for="(voice, index) in getVoiceList(callDetails.voiceText).slice(0, 2)"
+                     :key="index"
+                     class="voice-item">
+                  <div class="voice-row">
+                    <div class="voice-info">
+                      <i class="el-icon-headset voice-icon"></i>
+                      <span class="voice-title">语音{{ getChineseNumber(index + 1) }}</span>
+                    </div>
+                    <div class="voice-player">
+                      <audio
+                          :src="voice"
+                          controls
+                          class="audio-player"
+                      ></audio>
+                    </div>
+                  </div>
+                </div>
+
+                <!-- 显示更多语音按钮(展开后隐藏) -->
+                <div v-if="getVoiceList(callDetails.voiceText).length > 2 && !showAllVoices" class="expand-voice">
+                  <el-button
+                      type="text"
+                      size="mini"
+                      @click="showAllVoices = true"
+                      class="expand-btn"
+                  >
+                    {{ `展开全部 (${getVoiceList(callDetails.voiceText).length - 2}条)` }}
+                  </el-button>
+                </div>
+
+                <!-- 展开后的额外语音 -->
+                <div v-if="showAllVoices">
+                  <div v-for="(voice, index) in getVoiceList(callDetails.voiceText).slice(2)"
+                       :key="index + 2"
+                       class="voice-item">
+                    <div class="voice-row">
+                      <div class="voice-info">
+                        <i class="el-icon-headset voice-icon"></i>
+                        <span class="voice-title">语音{{ getChineseNumber(index + 3) }}</span>
+                      </div>
+                      <div class="voice-player">
+                        <audio
+                            :src="voice"
+                            controls
+                            class="audio-player"
+                        ></audio>
+                      </div>
+                    </div>
+                  </div>
+
+                  <!-- 收起按钮只有在语音总数超过2条时才显示 -->
+                  <div class="expand-voice" v-if="getVoiceList(callDetails.voiceText).length > 2">
+                    <el-button
+                        type="text"
+                        size="mini"
+                        @click="showAllVoices = false"
+                        class="expand-btn"
+                    >
+                      收起
+                    </el-button>
+                  </div>
+                </div>
+              </div>
+              <div v-else class="no-voice-data">
+                <i class="el-icon-microphone"></i>
+                <p>暂无语音记录</p>
+              </div>
+            </div>
+          </el-card>
+
+        </div>
+
+        <div v-else-if="!callDetailsLoading" class="no-data">
+          <i class="el-icon-warning-outline"></i>
+          <p>暂无呼叫详情数据</p>
+        </div>
+      </div>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="callDetailsDialogVisible = false">关闭</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { countGroupVoiceMsgUser, CountGroupVoiceMsgUserDetails} from '@/api/qw/groupVoiceMsg'
+import { getBySessionIdDetails } from '@/api/qw/groupVoiceMsgUserDetails'
+import ChatRecordViewer from '@/components/chat/ChatRecordViewer.vue'
+
+export default {
+  name: "Customer-group-details",
+  components:{
+    ChatRecordViewer
+  },
+
+  props:{
+    rowDetailFrom:{},
+  },
+
+  watch:{
+    rowDetailFrom:{
+      handler(newVal){
+        // 当formData变化时重新查询
+        this.getUserList(newVal);
+        this.getcountGroupVoiceMsgUser(newVal)
+      },
+      deep: true
+    }
+  },
+  data() {
+    return {
+      //详情记录id
+      groupMsgId: null,
+      //数据统计
+      countGroupVoiceMsgUserList: [],
+
+      //成员详情
+      groupMsgUserDetailsList:[],
+
+      //客户拨通状态
+      groupMsgSendStatusOptions:[],
+
+      //放大图片
+      dialogImageUrl:null,
+      dialogVisible:false,
+
+      // 呼叫详情弹窗
+      callDetailsDialogVisible: false,
+      callDetails: null,
+      callDetailsLoading: false,
+      showAllVoices: false,
+
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+
+
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        qwMsgId:null,
+        msgId:null,
+        nickName:null,
+        name:null,
+        status:null,
+        sendStatus:null,
+        chatType: 1 ,
+
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+
+      },
+
+    };
+  },
+  computed: {
+    firstRowStats() {
+      const order = ['待呼叫', '已呼叫', '呼叫失败'];
+      return order.map(label => {
+        const item = this.countGroupVoiceMsgUserList.find(stat => stat.label === label);
+        return item ? item : { label, count: 0 };
+      });
+    },
+    secondRowStats() {
+      const order = ['不是好友呼叫失败', '已作废', '系统处理异常'];
+      return order.map(label => {
+        const item = this.countGroupVoiceMsgUserList.find(stat => stat.label === label);
+        return item ? item : { label, count: 0 };
+      });
+    }
+  },
+  created() {
+    this.getUserList(this.rowDetailFrom)
+    this.getcountGroupVoiceMsgUser(this.rowDetailFrom)
+    //客户接收状态
+    this.getDicts("sys_qw_ai_call_sendStatus").then(response => {
+      this.groupMsgSendStatusOptions = response.data;
+    });
+
+
+  },
+
+
+methods: {
+  //获取成员详细
+  getUserList(val){
+    this.queryParams.msgId = val.id || this.rowDetailFrom.msgId;
+    // 检查是否为 null、undefined 或空字符串
+    if (this.queryParams.msgId) {
+      this.groupMsgId = this.queryParams.msgId;
+    }else{
+      this.queryParams.msgId = this.groupMsgId;
+    }
+    this.loading=false;
+    CountGroupVoiceMsgUserDetails(this.queryParams).then(res=>{
+          this.groupMsgUserDetailsList=res.rows
+          this.total = res.total;
+          this.loading = false;
+    })
+  },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getUserList(this.rowDetailFrom);
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+
+    openImageViewer(url) {
+      // 打开大图预览对话框
+      this.dialogImageUrl=url
+      this.dialogVisible = true;
+    },
+    //数据详情
+    getcountGroupVoiceMsgUser(val){
+      countGroupVoiceMsgUser(val.msgid).then(res=>{
+        this.countGroupVoiceMsgUserList=Object.keys(res.data).map(key=>{
+          return {label: key,count:res.data[key]}
+        })
+      })
+    },
+
+    /** 查看呼叫详情 */
+    handleViewCallDetails(sessionId) {
+      this.callDetailsDialogVisible = true;
+      this.callDetailsLoading = true;
+      this.callDetails = null;
+      this.showAllVoices = false; // 重置语音展开状态
+
+      getBySessionIdDetails(sessionId).then(response => {
+        this.callDetails = response.data;
+        this.callDetailsLoading = false;
+
+        // 自动展开所有语音记录(如果只有2条或更少)
+        if (this.callDetails.voiceText) {
+          const voiceCount = this.getVoiceList(this.callDetails.voiceText).length;
+          if (voiceCount <= 2) {
+            this.showAllVoices = true;
+          }
+        }
+      }).catch(() => {
+        this.callDetailsLoading = false;
+      });
+    },
+
+    /** 获取语音记录列表 */
+    getVoiceList(voiceText) {
+      if (!voiceText) return [];
+      // 替换中文逗号为英文逗号,然后分割
+      return voiceText.split(',')
+        .map(voice => voice.trim())
+        .filter(voice => voice.length > 0);
+    },
+
+    /** 获取中文数字 */
+    getChineseNumber(num) {
+      const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
+      if (num >= 1 && num <= 10) {
+        return chineseNumbers[num - 1];
+      }
+      return num;
+    },
+
+    /** 获取星星样式类 */
+    getStarClass(starIndex) {
+      const satisfaction = this.callDetails?.satisfaction || 0;
+
+      if (satisfaction === 1) {
+        // 1级满意度:第1个星星显示灰色,其余为空心
+        return starIndex <= 1 ? 'star-gray' : 'star-empty';
+      } else if (satisfaction === 2) {
+        // 2级满意度:前3个星星显示橙色,其余为空心
+        return starIndex <= 3 ? 'star-orange' : 'star-empty';
+      } else if (satisfaction === 3) {
+        // 3级满意度:5个星星都显示红色
+        return 'star-red';
+      } else {
+        // 无满意度:全部为空心
+        return 'star-empty';
+      }
+    },
+
+    /** 获取星星图标 */
+    getStarIcon(starIndex) {
+      const satisfaction = this.callDetails?.satisfaction || 0;
+
+      if (satisfaction === 1) {
+        // 1级满意度:第1个星星实心灰色,其余空心
+        return starIndex <= 1 ? 'el-icon-star-on' : 'el-icon-star-off';
+      } else if (satisfaction === 2) {
+        // 2级满意度:前3个星星实心橙色,其余空心
+        return starIndex <= 3 ? 'el-icon-star-on' : 'el-icon-star-off';
+      } else if (satisfaction === 3) {
+        // 3级满意度:5个星星都实心红色
+        return 'el-icon-star-on';
+      } else {
+        // 无满意度:全部空心
+        return 'el-icon-star-off';
+      }
+    },
+
+    /** 获取满意度提示文本 */
+    getSatisfactionTooltip() {
+      const satisfaction = this.callDetails?.satisfaction;
+      if (satisfaction === 1) return '满意度:低';
+      if (satisfaction === 2) return '满意度:中等';
+      if (satisfaction === 3) return '满意度:高';
+      return '暂无满意度评价';
+    },
+
+    /** 复制会话ID */
+    copySessionId(sessionId) {
+      if (!sessionId) return;
+
+      // 创建临时textarea元素进行复制
+      const textarea = document.createElement('textarea');
+      textarea.value = sessionId;
+      textarea.style.position = 'fixed';
+      textarea.style.opacity = '0';
+      document.body.appendChild(textarea);
+
+      try {
+        textarea.select();
+        const successful = document.execCommand('copy');
+        if (successful) {
+          this.msgSuccess('会话ID已复制到剪贴板');
+        } else {
+          this.msgError('复制失败,请手动复制');
+        }
+      } catch (err) {
+        this.msgError('复制失败: ' + err.message);
+      } finally {
+        document.body.removeChild(textarea);
+      }
+    },
+  }
+};
+</script>
+<style scoped>
+.customer-group-details-container {
+  padding: 16px;
+  background-color: #f5f7fa;
+  min-height: 100vh;
+  font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+  overflow-x: hidden;
+  box-sizing: border-box;
+}
+
+.main-card {
+  margin-bottom: 16px;
+  border: none;
+  border-radius: 12px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.section-icon {
+  font-size: 20px;
+  margin-right: 10px;
+  color: #409EFF;
+}
+
+.section-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+/* 统计卡片 */
+.statistics-container {
+  margin-top: 16px;
+  width: 100%;
+  overflow: hidden;
+}
+
+.statistic-card {
+  border: none;
+  background: linear-gradient(135deg, #ffffff, #fdfdfd);
+  border-radius: 10px;
+  padding: 16px;
+  height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s ease;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.statistic-card:hover {
+  transform: translateY(-3px);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
+}
+
+.statistic-content {
+  text-align: center;
+  width: 100%;
+  overflow: hidden;
+}
+
+.statistic-value {
+  font-size: 28px;
+  font-weight: 700;
+  color: #409EFF;
+  margin-bottom: 4px;
+  line-height: 1.2;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.statistic-label {
+  font-size: 14px;
+  color: #606266;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+/* 搜索表单 */
+.search-container {
+  background: #fff;
+  padding: 20px;
+  border-radius: 8px;
+  margin-bottom: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+  overflow: hidden;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+/* 数据表格 */
+.data-table {
+  width: 100%;
+  background: #fff;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+  margin-bottom: 8px;
+}
+
+.customer-info {
+  display: flex;
+  align-items: center;
+}
+
+.customer-avatar {
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  margin-right: 8px;
+  cursor: pointer;
+  border: 1px solid #e4e7f1;
+}
+
+.customer-name {
+  vertical-align: middle;
+}
+
+.detail-btn {
+  transition: all 0.2s ease;
+  border-radius: 6px;
+  padding: 6px 12px;
+}
+
+.detail-btn:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
+}
+
+.pagination-container {
+  margin-top: 10px;
+  text-align: right;
+  padding: 8px 0;
+  background: transparent;
+}
+
+/* 通话详情弹窗 */
+.detail-dialog ::v-deep .el-dialog {
+  border-radius: 16px;
+  overflow: hidden;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
+  margin: 0;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  max-height: 85vh;
+  overflow-y: auto;
+}
+
+.detail-dialog ::v-deep .el-dialog__header {
+  background: linear-gradient(135deg, #409EFF 0%, #4a9eff 100%);
+  padding: 12px 16px;
+  margin: 0;
+}
+
+.detail-dialog ::v-deep .el-dialog__title {
+  color: white;
+  font-weight: 600;
+  font-size: 16px;
+}
+
+.detail-dialog ::v-deep .el-dialog__headerbtn {
+  top: 12px;
+  right: 16px;
+}
+
+.detail-dialog ::v-deep .el-dialog__headerbtn .el-dialog__close {
+  color: white;
+  font-size: 16px;
+}
+
+.detail-dialog ::v-deep .el-dialog__body {
+  padding: 12px;
+  background: #f9fafc;
+  max-height: 65vh;
+  overflow-y: auto;
+}
+
+.detail-dialog ::v-deep .el-dialog__footer {
+  padding: 8px 12px;
+  background: #f9fafc;
+  border-top: 1px solid #e6e9f0;
+}
+
+/* 信息卡片 */
+.info-header {
+  padding: 8px 12px !important;
+  background: #f8f9fc;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.info-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  display: flex;
+  align-items: center;
+}
+
+.info-title i {
+  margin-right: 6px;
+  color: #409EFF;
+}
+
+.info-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+  gap: 10px;
+  padding: 8px;
+}
+
+.info-item {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  background: white;
+  border-radius: 8px;
+  border: 1px solid #e6ebf5;
+  transition: all 0.3s ease;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
+}
+
+.info-item:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  border-color: #409EFF;
+}
+
+.info-icon {
+  width: 32px;
+  height: 32px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8px;
+  flex-shrink: 0;
+  font-size: 14px;
+  color: white;
+}
+
+.session-id-bg {
+  background: linear-gradient(135deg, #409EFF 0%, #5dade2 100%);
+}
+
+.agent-bg {
+  background: linear-gradient(135deg, #1abc9c 0%, #48c9b0 100%);
+}
+
+.customer-bg {
+  background: linear-gradient(135deg, #e74c3c 0%, #ec7063 100%);
+}
+
+.satisfaction-bg {
+  background: linear-gradient(135deg, #f39c12 0%, #f5b041 100%);
+}
+
+.info-content {
+  flex: 1;
+}
+
+.info-label {
+  font-size: 12px;
+  font-weight: 600;
+  color: #606266;
+  margin-bottom: 2px;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.info-value {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  word-break: break-all;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.session-id-value {
+  font-family: 'Courier New', Consolas, monospace;
+  color: #1890ff;
+  font-size: 14px;
+}
+
+.copy-btn {
+  margin-left: 6px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.copy-btn:hover {
+  color: #409EFF;
+}
+
+/* 聊天记录卡片 */
+.chat-card {
+  border: none;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
+  margin-bottom: 12px;
+  background: white;
+}
+
+.chat-viewer {
+  margin-top: -10px;
+}
+
+.chat-viewer ::v-deep .chat-messages {
+  padding: 8px 12px;
+}
+
+.chat-viewer ::v-deep .message {
+  margin-bottom: 10px !important;
+  padding: 8px 12px !important;
+  font-size: 13px;
+  line-height: 1.4;
+  border-radius: 8px;
+  max-width: 80%;
+}
+
+.chat-viewer ::v-deep .message-agent {
+  background: #ecf5ff;
+  border: 1px solid #d9ecff;
+  align-self: flex-start;
+}
+
+.chat-viewer ::v-deep .message-customer {
+  background: #f0f9ff;
+  border: 1px solid #c6e2ff;
+  align-self: flex-end;
+}
+
+/* 语音记录卡片 */
+.voice-card {
+  border: none;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
+  background: white;
+  margin-bottom: 0;
+}
+
+.voice-container {
+  padding: 8px;
+}
+
+.voice-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.voice-item {
+  background: white;
+  border-radius: 8px;
+  border: 1px solid #e1e8ef;
+  padding: 10px;
+  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
+}
+
+.voice-item:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(64, 158, 255, 0.1);
+  border-color: rgba(64, 158, 255, 0.3);
+}
+
+.voice-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.voice-info {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.voice-icon {
+  width: 26px;
+  height: 26px;
+  background: linear-gradient(135deg, #409EFF 0%, #64B5FF 100%);
+  color: white;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8px;
+  font-size: 12px;
+}
+
+.voice-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.voice-player {
+  flex: 1;
+  min-width: 0;
+}
+
+.audio-player {
+  width: 100%;
+  height: 32px;
+  border-radius: 16px;
+  background: #f0f9ff;
+  border: 1px solid #d9ecff;
+}
+
+.no-voice-data {
+  text-align: center;
+  padding: 30px 16px;
+  background: #f8fafc;
+  border-radius: 8px;
+  color: #909399;
+}
+
+.no-voice-data i {
+  font-size: 32px;
+  margin-bottom: 10px;
+  display: block;
+  color: #c0c4cc;
+}
+
+.no-voice-data p {
+  font-size: 13px;
+  color: #909399;
+  margin: 0;
+}
+
+.no-data {
+  text-align: center;
+  padding: 24px 12px;
+}
+
+.no-data i {
+  font-size: 28px;
+  margin-bottom: 10px;
+  color: #c0c4cc;
+}
+
+.no-data p {
+  font-size: 13px;
+  color: #909399;
+  margin: 0;
+}
+
+/* 满意度星星样式 */
+.satisfaction-stars {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+
+.star {
+  font-size: 14px;
+  transition: all 0.3s ease;
+}
+
+.star i {
+  display: block;
+}
+
+/* 空心灰色星星(未点亮的星星) */
+.star-empty i {
+  color: #e4e7f1;
+}
+
+/* 灰色星星(1级满意度) */
+.star-gray i {
+  color: #909399;
+}
+
+/* 橙色星星(2级满意度) */
+.star-orange i {
+  color: #e6a23c;
+}
+
+/* 红色星星(3级满意度) */
+.star-red i {
+  color: #f56c6c;
+}
+
+/* 展开按钮 */
+.expand-voice {
+  text-align: center;
+  padding: 10px 0;
+}
+
+.expand-btn {
+  padding: 4px 10px !important;
+  font-size: 12px;
+  border-radius: 16px;
+  color: #409EFF;
+  border: 1px solid #d9ecff;
+  transition: all 0.3s ease;
+}
+
+.expand-btn:hover {
+  background-color: #ecf5ff;
+  color: #66b1ff;
+  border-color: #c6e2ff;
+}
+
+/* 底部按钮 */
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  padding: 12px 16px;
+}
+
+.dialog-footer .el-button {
+  padding: 6px 16px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .customer-group-details-container {
+    padding: 12px;
+  }
+
+  .info-grid {
+    grid-template-columns: 1fr;
+    gap: 10px;
+  }
+
+  .voice-row {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 10px;
+  }
+
+  .voice-player {
+    width: 100%;
+  }
+
+  .info-item {
+    padding: 10px;
+  }
+
+  .info-icon {
+    width: 32px;
+    height: 32px;
+    font-size: 14px;
+  }
+
+  .statistic-value {
+    font-size: 22px;
+  }
+
+  .statistic-label {
+    font-size: 12px;
+  }
+
+  .detail-dialog ::v-deep .el-dialog {
+    width: 90% !important;
+    margin: 0;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+  }
+
+  .chat-viewer ::v-deep .message {
+    max-width: 90%;
+    font-size: 12px;
+    padding: 6px 10px !important;
+  }
+
+  .detail-dialog ::v-deep .el-dialog__body {
+    max-height: 60vh;
+    padding: 12px;
+  }
+}
+</style>

+ 577 - 0
src/views/qw/groupVoiceMsg/detailsRecord/index.vue

@@ -0,0 +1,577 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never" class="search-card">
+      <div slot="header" class="clearfix">
+        <span class="card-title"><i class="el-icon-search"></i> 搜索条件</span>
+        <el-button
+          style="float: right; padding: 3px 0"
+          type="text"
+          @click="showSearch = !showSearch">
+          {{ showSearch ? '收起' : '展开' }}
+        </el-button>
+      </div>
+      <el-form v-show="showSearch" :model="queryParams" ref="queryForm" :inline="true" label-width="90px" class="compact-form">
+      <el-form-item label="会话ID" prop="sessionId">
+        <el-input
+          v-model="queryParams.sessionId"
+          placeholder="请输入会话ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+          style="width: 160px"
+        />
+      </el-form-item>
+
+      <el-form-item label="满意度" prop="satisfaction">
+        <el-select
+          v-model="queryParams.satisfaction"
+          placeholder="全部满意度"
+          clearable
+          size="small"
+          style="width: 120px"
+        >
+          <el-option
+            v-for="item in satisfactionOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="用户昵称" prop="qwUserNickName">
+        <el-input
+          v-model="queryParams.qwUserNickName"
+          placeholder="请输入用户昵称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+          style="width: 160px"
+        />
+      </el-form-item>
+
+      <el-form-item label="外呼角色" prop="aiSopCallRoleName">
+        <el-input
+          v-model="queryParams.aiSopCallRoleName"
+          placeholder="请输入外呼角色名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+          style="width: 160px"
+        />
+      </el-form-item>
+
+      <el-form-item label="外部联系人" prop="externalUserName">
+        <el-input
+          v-model="queryParams.externalUserName"
+          placeholder="请输入外部联系人名称"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+          style="width: 160px"
+        />
+      </el-form-item>
+
+      <el-form-item class="form-actions">
+        <el-button type="primary" icon="el-icon-search" size="small" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="small" @click="resetQuery">重置</el-button>
+      </el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-row :gutter="10" class="mb8 operation-row">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['company:details:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="success"
+          plain
+          icon="el-icon-edit"
+          size="mini"
+          :disabled="single"
+          @click="handleUpdate"
+          v-hasPermi="['company:details:edit']"
+        >修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="danger"
+          plain
+          icon="el-icon-delete"
+          size="mini"
+          :disabled="multiple"
+          @click="handleDelete"
+          v-hasPermi="['company:details:remove']"
+        >删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="warning"
+          plain
+          icon="el-icon-download"
+          size="mini"
+          :loading="exportLoading"
+          @click="handleExport"
+          v-hasPermi="['company:details:export']"
+        >导出</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-card shadow="never" class="table-card">
+      <div slot="header" class="clearfix">
+        <span class="card-title"><i class="el-icon-document"></i> 数据列表</span>
+        <span class="total-text">共 {{ total }} 条记录</span>
+      </div>
+      <el-table
+        v-loading="loading"
+        :data="detailsList"
+        @selection-change="handleSelectionChange"
+        stripe
+        style="width: 100%"
+        class="custom-table"
+      >
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="主键ID" align="center" prop="id" />
+      <el-table-column label="会话id" align="center" prop="sessionId" />
+      <el-table-column label="原语音" align="center" prop="voiceText" />
+      <el-table-column label="聊天详情" align="center" prop="aiChatDetails" />
+      <el-table-column label="满意度" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag
+            :type="scope.row.satisfaction === 3 ? 'success' : scope.row.satisfaction === 2 ? 'warning' : 'danger'"
+            size="small"
+          >
+            {{ scope.row.satisfaction === 1 ? '低' : scope.row.satisfaction === 2 ? '中' : scope.row.satisfaction === 3 ? '高' : '-' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="企微用户昵称" align="center" prop="qwUserNickName" />
+      <el-table-column label="外呼角色名称" align="center" prop="aiSopCallRoleName" />
+      <el-table-column label="外部联系人username" align="center" prop="externalUserName" />
+      <el-table-column label="token用量" align="center" prop="totalToken" />
+      <el-table-column label="记录总用量" align="center" prop="chatTotalToken" />
+      <el-table-column label="记录输入量" align="center" prop="chatPromptToken" />
+      <el-table-column label="记录输出用量" align="center" prop="chatCompletionToken" />
+      <el-table-column label="操作" align="center" width="120" fixed="right">
+        <template slot-scope="scope">
+          <el-tooltip content="修改" placement="top">
+            <el-button
+              size="mini"
+              type="primary"
+              icon="el-icon-edit"
+              circle
+              @click="handleUpdate(scope.row)"
+              v-hasPermi="['company:details:edit']"
+            />
+          </el-tooltip>
+          <el-tooltip content="删除" placement="top">
+            <el-button
+              size="mini"
+              type="danger"
+              icon="el-icon-delete"
+              circle
+              @click="handleDelete(scope.row)"
+              v-hasPermi="['company:details:remove']"
+            />
+          </el-tooltip>
+        </template>
+      </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-pagination
+      class="pagination-container"
+      :current-page="queryParams.pageNum"
+      :page-size="queryParams.pageSize"
+      :page-sizes="[10, 20, 50, 100]"
+      :total="total"
+      layout="total, sizes, prev, pager, next, jumper"
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+    />
+
+    <!-- 添加或修改AI外呼客服呼叫详情对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="会话id" prop="sessionId">
+          <el-input v-model="form.sessionId" placeholder="请输入会话id" />
+        </el-form-item>
+        <el-form-item label="原语音" prop="voiceText">
+          <el-input v-model="form.voiceText" placeholder="请输入原语音" />
+        </el-form-item>
+        <el-form-item label="原聊天详情" prop="chatDetails">
+          <el-input v-model="form.chatDetails" type="textarea" placeholder="请输入内容" />
+        </el-form-item>
+        <el-form-item label="ai质检语音" prop="aiVoiceText">
+          <el-input v-model="form.aiVoiceText" placeholder="请输入ai质检语音" />
+        </el-form-item>
+        <el-form-item label="ai质检聊天详情" prop="aiChatDetails">
+          <el-input v-model="form.aiChatDetails" type="textarea" placeholder="请输入内容" />
+        </el-form-item>
+        <el-form-item label="满意度" prop="satisfaction">
+          <el-select v-model="form.satisfaction" placeholder="请选择满意度" style="width: 100%">
+            <el-option
+              v-for="item in satisfactionOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="ID" prop="companyId">
+          <el-input v-model="form.companyId" placeholder="请输入ID" />
+        </el-form-item>
+        <el-form-item label="企业名" prop="companyName">
+          <el-input v-model="form.companyName" placeholder="请输入企业名" />
+        </el-form-item>
+        <el-form-item label="企微用户id" prop="qwUserId">
+          <el-input v-model="form.qwUserId" placeholder="请输入企微用户id" />
+        </el-form-item>
+        <el-form-item label="用户昵称" prop="qwUserNickName">
+          <el-input v-model="form.qwUserNickName" placeholder="请输入用户昵称" />
+        </el-form-item>
+        <el-form-item label="客服id" prop="aiSopCallRoleId">
+          <el-input v-model="form.aiSopCallRoleId" placeholder="请输入客服id" />
+        </el-form-item>
+        <el-form-item label="外呼角色名称" prop="aiSopCallRoleName">
+          <el-input v-model="form.aiSopCallRoleName" placeholder="请输入外呼角色名称" />
+        </el-form-item>
+        <el-form-item label="外部联系人id" prop="externalUserId">
+          <el-input v-model="form.externalUserId" placeholder="请输入外部联系人id" />
+        </el-form-item>
+        <el-form-item label="外部联系人username" prop="externalUserName">
+          <el-input v-model="form.externalUserName" placeholder="请输入外部联系人username" />
+        </el-form-item>
+        <el-form-item label="token用量" prop="totalToken">
+          <el-input v-model="form.totalToken" placeholder="请输入token用量" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" placeholder="请输入备注" />
+        </el-form-item>
+        <el-form-item label="语音token总用量" prop="voiceTotalToken">
+          <el-input v-model="form.voiceTotalToken" placeholder="请输入语音token总用量" />
+        </el-form-item>
+        <el-form-item label="语音输入量" prop="voicePromptToken">
+          <el-input v-model="form.voicePromptToken" placeholder="请输入语音输入量" />
+        </el-form-item>
+        <el-form-item label="语音输出量" prop="voiceCompletionToken">
+          <el-input v-model="form.voiceCompletionToken" placeholder="请输入语音输出量" />
+        </el-form-item>
+        <el-form-item label="记录总用量" prop="chatTotalToken">
+          <el-input v-model="form.chatTotalToken" placeholder="请输入记录总用量" />
+        </el-form-item>
+        <el-form-item label="记录输入量" prop="chatPromptToken">
+          <el-input v-model="form.chatPromptToken" placeholder="请输入记录输入量" />
+        </el-form-item>
+        <el-form-item label="记录输出用量" prop="chatCompletionToken">
+          <el-input v-model="form.chatCompletionToken" placeholder="请输入记录输出用量" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listDetails, getDetails, delDetails, addDetails, updateDetails, exportDetails } from "@/api/qw/groupVoiceMsgUserDetails";
+
+export default {
+  name: "detailsRecord",
+  data() {
+    return {
+      // 满意度选项
+      satisfactionOptions: [
+        { value: '1', label: '低' },
+        { value: '2', label: '中' },
+        { value: '3', label: '高' }
+      ],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // AI外呼客服呼叫详情表格数据
+      detailsList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        sessionId: null,
+        voiceText: null,
+        chatDetails: null,
+        aiVoiceText: null,
+        aiChatDetails: null,
+        satisfaction: undefined,
+        companyId: null,
+        companyName: null,
+        qwUserId: null,
+        qwUserNickName: null,
+        aiSopCallRoleId: null,
+        aiSopCallRoleName: null,
+        externalUserId: null,
+        externalUserName: null,
+        totalToken: null,
+        voiceTotalToken: null,
+        voicePromptToken: null,
+        voiceCompletionToken: null,
+        chatTotalToken: null,
+        chatPromptToken: null,
+        chatCompletionToken: null
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        qwUserNickName: [
+          { required: true, message: "用户昵称不能为空", trigger: "blur" }
+        ],
+      }
+    };
+  },
+  created() {
+    // 从路由参数中获取sessionId
+    if (this.$route.query.sessionId) {
+      this.queryParams.sessionId = this.$route.query.sessionId;
+    }
+    // 清理可能存在的无效参数
+    if (this.$route.query.aiSopCallRoleId === '0') {
+      delete this.queryParams.aiSopCallRoleId;
+    }
+    this.getList();
+  },
+  methods: {
+    /** 查询AI外呼客服呼叫详情列表 */
+    getList() {
+      this.loading = true;
+      listDetails(this.queryParams).then(response => {
+        this.detailsList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 分页大小变化
+    handleSizeChange(val) {
+      this.queryParams.pageSize = val;
+      this.getList();
+    },
+    // 当前页码变化
+    handleCurrentChange(val) {
+      this.queryParams.pageNum = val;
+      this.getList();
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        sessionId: null,
+        voiceText: null,
+        chatDetails: null,
+        aiVoiceText: null,
+        aiChatDetails: null,
+        satisfaction: undefined,
+        companyId: null,
+        companyName: null,
+        qwUserId: null,
+        qwUserNickName: null,
+        aiSopCallRoleId: null,
+        aiSopCallRoleName: null,
+        externalUserId: null,
+        externalUserName: null,
+        totalToken: null,
+        remark: null,
+        voiceTotalToken: null,
+        createTime: null,
+        voicePromptToken: null,
+        voiceCompletionToken: null,
+        chatTotalToken: null,
+        chatPromptToken: null,
+        chatCompletionToken: null
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加AI外呼客服呼叫详情";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids
+      getDetails(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改AI外呼客服呼叫详情";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.id != null) {
+            updateDetails(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addDetails(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除AI外呼客服呼叫详情编号为"' + ids + '"的数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(function() {
+          return delDetails(ids);
+        }).then(() => {
+          this.getList();
+          this.msgSuccess("删除成功");
+        }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有AI外呼客服呼叫详情数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(() => {
+          this.exportLoading = true;
+          return exportDetails(queryParams);
+        }).then(response => {
+          this.download(response.msg);
+          this.exportLoading = false;
+        }).catch(() => {});
+    }
+  }
+};
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+  background: #f5f7fa;
+}
+
+.search-card {
+  margin-bottom: 20px;
+  border-radius: 8px;
+}
+
+.card-title {
+  font-weight: 600;
+  color: #303133;
+}
+
+.table-card {
+  border-radius: 8px;
+}
+
+.total-text {
+  float: right;
+  color: #909399;
+  font-size: 14px;
+}
+
+.compact-form {
+  margin-bottom: -18px;
+}
+
+.compact-form .el-form-item {
+  margin-bottom: 16px;
+}
+
+.form-actions {
+  margin-left: auto;
+}
+
+.operation-row {
+  margin-bottom: 16px;
+}
+
+.custom-table {
+  border-radius: 8px;
+}
+
+.custom-table ::v-deep .el-table__header th {
+  background-color: #f5f7fa;
+  color: #606266;
+  font-weight: 600;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  text-align: right;
+  padding: 16px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+}
+
+.el-tag {
+  margin: 2px;
+}
+</style>
+
+<style>
+.compact-form .el-form-item__label {
+  padding-right: 8px;
+}
+</style>

+ 1611 - 0
src/views/qw/groupVoiceMsg/detailsRecord/statistics.vue

@@ -0,0 +1,1611 @@
+<template>
+  <div class="ai-statistics-container">
+
+    <!-- 统计卡片区域 -->
+    <div class="stats-dashboard">
+      <el-row :gutter="24">
+        <el-col :xs="24" :sm="12" :md="6">
+          <div class="stat-card stat-session-card">
+            <div class="card-content">
+              <div class="stat-value">{{ formatNumber(totalSession) }}</div>
+              <div class="stat-label">总会话数</div>
+            </div>
+          </div>
+        </el-col>
+        <el-col :xs="24" :sm="12" :md="6">
+          <div class="stat-card stat-token-card">
+            <div class="card-content">
+              <div class="stat-value">{{ formatNumber(totalToken) }}</div>
+              <div class="stat-label">总消耗Token</div>
+            </div>
+          </div>
+        </el-col>
+        <el-col :xs="24" :sm="12" :md="6">
+          <div class="stat-card stat-input-card">
+            <div class="card-content">
+              <div class="stat-value">{{ formatNumber(chatPromptToken) }}</div>
+              <div class="stat-label">入参消耗Token</div>
+            </div>
+          </div>
+        </el-col>
+        <el-col :xs="24" :sm="12" :md="6">
+          <div class="stat-card stat-output-card">
+            <div class="card-content">
+              <div class="stat-value">{{ formatNumber(chatCompletionToken) }}</div>
+              <div class="stat-label">出参消耗Token</div>
+            </div>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- 筛选区域 -->
+    <el-card shadow="hover" class="filter-card">
+      <div class="filter-header">
+        <span class="filter-title"><i class="el-icon-search"></i> 筛选条件</span>
+        <el-button-group>
+          <el-button v-for="preset in presets" :key="preset.value" :type="activePreset === preset.value ? 'primary' : 'default'" size="small" round @click="selectPreset(preset.value)">{{ preset.label }}</el-button>
+        </el-button-group>
+      </div>
+      <el-form :inline="true" :model="queryParams" class="filter-form">
+        <el-form-item>
+          <el-date-picker v-model="queryParams.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" size="small" style="width: 260px" @change="handleDateChange" />
+        </el-form-item>
+        <el-form-item>
+          <el-select v-model="queryParams.aiSopCallRoleId" placeholder="选择外呼角色" clearable filterable size="small" style="width: 160px" @change="handleQuery">
+            <el-option v-for="item in aiRoleOptions" :key="item.id" :label="item.callRoleName" :value="item.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery" v-hasPermi="['qw:groupVoiceMsgUser:details:statistics']">搜索</el-button>
+          <el-button size="small" icon="el-icon-refresh" @click="resetQuery">重置</el-button>
+          <el-button type="success" size="small" icon="el-icon-download" @click="handleExport" v-hasPermi="['qw:groupVoiceMsgUser:details:exportStatistics']">导出</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 数据展示区域 -->
+    <el-card shadow="never" class="data-table-card">
+      <div slot="header" class="table-header">
+        <div class="header-left">
+          <i class="el-icon-data-analysis"></i>
+          <span class="header-title">智能数据分析</span>
+          <el-tag size="mini" type="info">共 {{ total }} 条记录</el-tag>
+        </div>
+        <div class="header-actions">
+          <el-button
+            type="text"
+            icon="el-icon-refresh"
+            :loading="loading"
+            @click="getList"
+            class="refresh-btn"
+          />
+        </div>
+      </div>
+      <el-table
+        v-loading="loading"
+        :data="detailsList"
+        stripe
+        border
+        class="modern-table"
+        :header-cell-style="headerStyle"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column type="index" label="序号" width="100" align="center" />
+        <el-table-column prop="aiSopCallRoleName" label="外呼角色" width="200" sortable>
+          <template slot-scope="{row}">
+            <div class="role-cell">
+              <div class="role-avatar">
+                <i class="el-icon-service"></i>
+                <div class="avatar-glow"></div>
+              </div>
+              <span class="role-name">{{ row.aiSopCallRoleName || '-' }}</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="durationMsStr" label="总时长" width="200" align="center" sortable />
+        <el-table-column prop="totalSession" label="总会话数" align="center" sortable>
+          <template slot-scope="{row}">
+            <span class="metric-value primary">{{ formatNumber(row.totalSession) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="totalToken" label="总Token" align="center" sortable>
+          <template slot-scope="{row}">
+            <span class="metric-value success">{{ formatNumber(row.totalToken) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="chatPromptToken" label="输入Token" align="center" sortable>
+          <template slot-scope="{row}">
+            <span class="metric-value warning">{{ formatNumber(row.chatPromptToken) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="chatCompletionToken" label="输出Token" align="center" sortable>
+          <template slot-scope="{row}">
+            <span class="metric-value info">{{ formatNumber(row.chatCompletionToken) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createTime" label="创建时间" min-width="160" align="center" sortable />
+        <el-table-column label="操作" width="120" align="center" fixed="right">
+          <template slot-scope="{row}">
+            <div class="action-cell">
+              <el-tooltip content="查看详细数据" placement="top">
+                <el-button
+                  type="primary"
+                  size="mini"
+                  icon="el-icon-view"
+                  circle
+                  @click="handleView(row)"
+                  v-hasPermi="['qw:groupVoiceMsgUser:details:list']"
+                  class="view-btn"
+                />
+              </el-tooltip>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="table-footer">
+        <div class="pagination-info">
+          <span>显示第 {{ (queryParams.pageNum - 1) * queryParams.pageSize + 1 }} 到 {{ Math.min(queryParams.pageNum * queryParams.pageSize, total) }} 条,共 {{ total }} 条记录</span>
+        </div>
+        <pagination
+          v-show="total>0"
+          :total="total"
+          :page.sync="queryParams.pageNum"
+          :limit.sync="queryParams.pageSize"
+          @pagination="getList"
+          class="modern-pagination"
+        />
+      </div>
+    </el-card>
+
+
+    <!-- 数据展示页面 -->
+    <el-dialog
+      :title="`会话详情 - ${currentRow.aiSopCallRoleName || '未知客服'}`"
+      :visible.sync="detailDialogVisible"
+      width="85%"
+      top="3vh"
+      append-to-body
+      class="detail-dialog-enhanced"
+      :close-on-click-modal="false"
+      :modal-append-to-body="true"
+    >
+      <div v-loading="detailLoading" class="dialog-content-wrapper">
+        <!-- 数据表格 -->
+        <el-card shadow="never" class="detail-table-card">
+          <div slot="header" class="table-header-enhanced">
+            <span><i class="el-icon-chat-line-round"></i> 会话记录详情</span>
+            <el-tag type="info" size="medium">共 {{ detailTotal }} 条记录</el-tag>
+          </div>
+          <el-table
+            :data="detailList"
+            stripe
+            border
+            v-loading="detailLoading"
+            style="width: 100%"
+            class="clean-table"
+          >
+            <el-table-column prop="sessionId" label="会话ID" align="center" min-width="120" />
+            <el-table-column prop="externalUserName" label="联系人" align="center" min-width="100" />
+            <el-table-column prop="satisfaction" label="满意度" align="center" min-width="100">
+              <template slot-scope="{row}">
+                <el-tag
+                  :type="row.satisfaction === 3 ? 'success' : row.satisfaction === 2 ? 'warning' : 'danger'"
+                  size="small"
+                  effect="dark"
+                >
+                  <i :class="row.satisfaction === 3 ? 'el-icon-success' : row.satisfaction === 2 ? 'el-icon-warning' : 'el-icon-error'"></i>
+                  {{ formatSatisfaction(row.satisfaction) }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="totalToken" label="总Token" align="center" min-width="100" />
+            <el-table-column prop="chatPromptToken" label="输入Token" align="center" min-width="100" />
+            <el-table-column prop="chatCompletionToken" label="输出Token" align="center" min-width="100" />
+            <el-table-column prop="aiChatDetails" label="内容" align="center" min-width="300">
+              <template slot-scope="{row}">
+                <div
+                  class="content-text"
+                  :title="formatChatContent(row.aiChatDetails, row.aiSopCallRoleName, row.externalUserName)"
+                >
+                  {{ truncateText(formatChatContent(row.aiChatDetails, row.aiSopCallRoleName, row.externalUserName), 3) }}
+                </div>
+              </template>
+            </el-table-column>
+            <el-table-column label="语音" align="center" min-width="140">
+              <template slot-scope="{row}">
+                <div v-if="isValidVoiceData(row.voiceText)" class="audio-container">
+                  <audio
+                      :src="getAudioSource(row.voiceText)"
+                      controls
+                      style="width: 120px; height: 32px"
+                      @error="handleAudioError"
+                      preload="none"
+                      :title="isAudioFailed(getAudioSource(row.voiceText)) ? '音频加载失败' : '语音播放'"
+                  ></audio>
+                  <el-tag
+                      v-if="isAudioFailed(getAudioSource(row.voiceText))"
+                      size="mini"
+                      type="danger"
+                      class="audio-error"
+                  >
+                    加载失败
+                  </el-tag>
+                  <el-tooltip
+                      v-else-if="Array.isArray(row.voiceText) && row.voiceText.length > 1"
+                      content="点击查看所有语音">
+                    <el-tag size="mini" type="info" class="audio-count">
+                      +{{ row.voiceText.length - 1 }}
+                    </el-tag>
+                  </el-tooltip>
+                </div>
+                <span v-else class="no-data">-</span>
+              </template>
+            </el-table-column>
+
+            <el-table-column prop="createTime" label="创建时间" align="center" min-width="150" />
+          </el-table>
+
+          <!-- 分页 -->
+          <pagination
+              v-show="detailTotal>0"
+              :total="detailTotal"
+              :page.sync="detailQuery.pageNum"
+              :limit.sync="detailQuery.pageSize"
+              @pagination="handleDetailPagination"
+              class="detail-pagination"
+          />
+        </el-card>
+      </div>
+
+      <div slot="footer" class="dialog-footer-enhanced">
+        <el-button @click="detailDialogVisible = false" class="close-btn">
+          <i class="el-icon-close"></i> 关闭
+        </el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listDetails,statisticsAiCallDetails,exportStatistics } from "@/api/qw/groupVoiceMsgUserDetails";
+import { listRole } from "@/api/aiSopCall/aiSopCallRole";
+import Pagination from "@/components/Pagination";
+
+export default {
+  name: "detailsStatistics",
+  components: { Pagination },
+  data() {
+    return {
+      // AI智能分析相关
+      aiAnalysisDialogVisible: false,
+      aiAnalysisLoading: false,
+
+      // 查看详情相关数据
+      detailDialogVisible: false,
+      detailLoading: false,
+      currentRow: {},
+      detailData: {},
+      detailList: [],
+      detailTotal: 0,
+      detailQuery: {
+        pageNum: 1,
+        pageSize: 10,
+        sessionId: null
+      },
+
+      // 时间快捷选项
+      presets: [
+        { value: 'today', label: '今天' },
+        { value: 'week', label: '本周' },
+        { value: 'month', label: '本月' },
+        { value: 'quarter', label: '本季度' },
+        { value: 'year', label: '本年' }
+      ],
+      activePreset: 'month',
+
+      // 统计卡片数据
+      totalSession: 0,
+      totalToken: 0,
+      chatPromptToken: 0,
+      chatCompletionToken: 0,
+      failedAudioSources: new Set(), // 跟踪加载失败的音频
+      // 外呼角色选项
+      aiRoleOptions: [],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 总条数
+      total: 0,
+      // AI外呼客服呼叫详情表格数据
+      detailsList: [],
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        aiSopCallRoleId: null,
+        aiSopCallRoleName: null,
+        externalUserId: null,
+        externalUserName: null,
+        dateRange: [],
+        startTime: null,
+        endTime: null
+      },
+      // 查询参数
+      queryDetailsParams: {
+        pageNum: 1,
+        pageSize: 10,
+        aiSopCallRoleId: null,
+        startTime: null,
+        endTime: null
+      },
+      // 表头样式
+      headerStyle: {
+        background: '#ffffff',
+        color: '#4c5161',
+        fontWeight: '600',
+        fontSize: '14px',
+        textAlign: 'center'
+      }
+    };
+  },
+  created() {
+    // 设置默认时间范围:本月第一天到当前时间
+    this.setDefaultDateRange();
+    this.loadAiRoles();
+    this.getList();
+  },
+
+  methods: {
+
+    // 设置默认时间范围
+    setDefaultDateRange() {
+      const now = new Date();
+      const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
+
+      this.queryParams.dateRange = [
+        this.formatDate(firstDay),
+        this.formatDate(now)
+      ];
+      this.queryParams.startTime = this.formatDate(firstDay);
+      this.queryParams.endTime = this.formatDate(now);
+    },
+
+
+    // 格式化日期
+    formatDate(date) {
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      return `${year}-${month}-${day}`;
+    },
+
+    /** 日期变化处理 */
+    handleDateChange(val) {
+      if (val && val.length >= 2) {
+        this.queryParams.startTime = val[0];
+        this.queryParams.endTime = val[1];
+      } else {
+        this.queryParams.startTime = null;
+        this.queryParams.endTime = null;
+      }
+    },
+
+    /** 加载外呼角色列表 */
+    loadAiRoles() {
+      listRole({ pageSize: 100, pageNum: 1 }).then(response => {
+        if (response.code === 200) {
+          this.aiRoleOptions = response.rows || [];
+        }
+      }).catch(error => {
+        console.error('加载外呼角色失败:', error);
+        this.$message.error('加载外呼角色失败');
+      });
+    },
+
+    /** 查询AI外呼客服呼叫详情列表 */
+    getList() {
+      this.loading = true;
+      statisticsAiCallDetails(this.queryParams).then(response => {
+        this.detailsList = response.rows || [];
+        this.total = response.total || 0;
+        this.calculateStatistics();
+        this.loading = false;
+      });
+    },
+
+    /** 计算统计数据 */
+    calculateStatistics() {
+      const stats = { totalSession: 0, totalToken: 0, chatPromptToken: 0, chatCompletionToken: 0 };
+      this.detailsList.forEach(item => {
+        stats.totalSession += (item.totalSession || 0);
+        stats.totalToken += (item.totalToken || 0);
+        stats.chatPromptToken += (item.chatPromptToken || 0);
+        stats.chatCompletionToken += (item.chatCompletionToken || 0);
+      });
+      this.totalSession = stats.totalSession;
+      this.totalToken = stats.totalToken;
+      this.chatPromptToken = stats.chatPromptToken;
+      this.chatCompletionToken = stats.chatCompletionToken;
+    },
+
+    /** 格式化数字 */
+    formatNumber(num) {
+      if (num === null || num === undefined) return '0';
+      return num.toLocaleString();
+    },
+
+    /** 格式化聊天内容 */
+    formatChatContent(content, roleName, externalName) {
+      if (!content) return '';
+      return content
+        .replace(/\[客服\]/g, roleName || '客服')
+        .replace(/\[客户\]/g, externalName || '客户');
+    },
+
+    /** 格式化满意度 */
+    formatSatisfaction(value) {
+      if (value === 1) return '低';
+      if (value === 2) return '中';
+      if (value === 3) return '高';
+      return '-';
+    },
+
+    /** 截断文本显示 */
+    truncateText(text, maxLines = 2) {
+      if (!text) return '-';
+      const lines = text.split('\n');
+      if (lines.length <= maxLines) return text;
+      return lines.slice(0, maxLines).join('\n') + '...';
+    },
+
+    /** 格式化语音文本 */
+    formatVoiceText(voiceText) {
+      if (!voiceText) return '';
+      if (Array.isArray(voiceText)) {
+        return voiceText.join(', ');
+      }
+      if (typeof voiceText === 'string') {
+        return voiceText;
+      }
+      return String(voiceText);
+    },
+
+    /** 获取音频源 */
+    getAudioSource(voiceText) {
+      if (!this.isValidVoiceData(voiceText)) {
+        return '';
+      }
+
+      // 处理数组形式的语音
+      if (Array.isArray(voiceText)) {
+        // 找到第一个有效的音频URL
+        const validUrl = voiceText.find(url => this.isValidAudioUrl(url));
+        return validUrl || '';
+      }
+
+      // 处理字符串形式的语音
+      if (typeof voiceText === 'string') {
+        if (this.isValidAudioUrl(voiceText)) {
+          let url = voiceText.trim();
+
+          // 验证URL格式
+          try {
+            // 如果已经是完整URL,则直接返回
+            if (url.startsWith('http://') || url.startsWith('https://')) {
+              return url;
+            }
+
+            // 如果是相对路径,添加基础URL
+            if (url.startsWith('/')) {
+              return url;
+            }
+
+            // 如果是有效的URL片段,构建完整URL
+            if (url && !url.startsWith('http') && !url.startsWith('/') && !url.startsWith('blob:')) {
+              return process.env.VUE_APP_BASE_API + '/' + url;
+            }
+
+            return url;
+          } catch (e) {
+            console.error('URL格式错误:', e);
+            return '';
+          }
+        }
+      }
+
+      return '';
+    },
+
+    /**
+     * 验证语音URL格式是否有效
+     * @param {string} url - 待验证的URL
+     * @returns {boolean} - 是否为有效的语音URL格式
+     */
+    isValidAudioUrl(url) {
+      if (!url || typeof url !== 'string') {
+        return false;
+      }
+
+      // 去除首尾空白字符
+      url = url.trim();
+
+      // 检查是否为空
+      if (!url) {
+        return false;
+      }
+
+      // 定义有效的音频文件扩展名
+      const validExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
+      const lowerCaseUrl = url.toLowerCase();
+
+      // 检查URL是否包含有效的音频扩展名
+      const hasValidExtension = validExtensions.some(ext => lowerCaseUrl.includes(ext));
+
+      // 如果包含有效扩展名,直接返回true
+      if (hasValidExtension) {
+        return true;
+      }
+
+      // 检查是否为有效的HTTP/HTTPS URL
+      try {
+        const parsedUrl = new URL(url);
+        return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
+      } catch (e) {
+        // 如果URL解析失败,检查是否为相对路径且包含音频相关信息
+        return /^\/.*\.(mp3|wav|ogg|m4a|aac|flac)/i.test(url) ||
+            /audio/i.test(url) ||
+            /voice/i.test(url) ||
+            /record/i.test(url);
+      }
+    },
+
+    /**
+     * 检查语音数据是否有效
+     * @param {any} voiceText - 语音数据
+     * @returns {boolean} - 语音数据是否有效
+     */
+    isValidVoiceData(voiceText) {
+      if (!voiceText) {
+        return false;
+      }
+
+      if (Array.isArray(voiceText)) {
+        // 数组情况下,检查是否有至少一个有效的音频URL
+        return voiceText.some(url => this.isValidAudioUrl(url));
+      }
+
+      // 字符串情况下,检查是否为有效的音频URL
+      return this.isValidAudioUrl(voiceText);
+    },
+
+    /** 处理音频错误 */
+    handleAudioError(event) {
+      console.error('音频播放失败:', event);
+      const audioElement = event.target;
+      const src = audioElement.getAttribute('src');
+
+      // 将失败的源添加到集合中
+      if (src) {
+        this.failedAudioSources.add(src);
+      }
+
+      // 清除当前音频源
+      audioElement.src = '';
+      audioElement.removeAttribute('src');
+
+      // 停止加载
+      audioElement.load();
+    },
+    /** 检查音频是否加载失败 */
+    isAudioFailed(src) {
+      return this.failedAudioSources.has(src);
+    },
+
+    /** 重置音频失败状态 */
+    resetAudioFailures() {
+      this.failedAudioSources.clear();
+    },
+
+    /** 截断语音文本显示 */
+    truncateVoiceText(voiceText) {
+      const formatted = this.formatVoiceText(voiceText);
+      if (!formatted) return '-';
+
+      const lines = formatted.split(',');
+      if (lines.length <= 2) return formatted;
+      return lines.slice(0, 2).join(',') + '...';
+    },
+
+    /** 选择快捷时间 */
+    selectPreset(value) {
+      this.activePreset = value;
+      const now = new Date();
+      let start = now;
+
+      switch (value) {
+        case 'today':
+          start = new Date(now.setHours(0, 0, 0, 0));
+          break;
+        case 'week':
+          start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+          start = new Date(start.setHours(0, 0, 0, 0));
+          break;
+        case 'month':
+          start = new Date(now.getFullYear(), now.getMonth(), 1);
+          break;
+        case 'quarter':
+          start = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
+          break;
+        case 'year':
+          start = new Date(now.getFullYear(), 0, 1);
+          break;
+      }
+
+      this.queryParams.dateRange = [this.formatDate(start), this.formatDate(now)];
+      this.queryParams.startTime = this.formatDate(start);
+      this.queryParams.endTime = this.formatDate(now);
+      this.handleQuery();
+    },
+    /** 详情分页处理 */
+    handleDetailPagination() {
+      this.getDetailList();
+    },
+    /** 查看详情 */
+    handleView(row) {
+      this.currentRow = row;
+      // 重置分页参数
+      this.detailQuery.pageNum = 1;
+      this.detailQuery.pageSize = 10;
+      this.getDetailList();
+    },
+    getDetailList(){
+      this.detailDialogVisible = true;
+      this.queryDetailsParams = {
+        pageNum: this.detailQuery.pageNum,
+        pageSize: this.detailQuery.pageSize,
+        aiSopCallRoleId: this.currentRow.aiSopCallRoleId,
+        startTime: this.currentRow.startTime,
+        endTime: this.currentRow.endTime
+      };
+      listDetails(this.queryDetailsParams).then(response => {
+        this.detailList = response.rows || [];
+        this.detailTotal = response.total || 0;
+        this.detailLoading = false;
+      }).catch(() => {
+        this.detailLoading = false;
+        this.$message.error('获取详情列表失败');
+      });
+    },
+
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    // 分页大小变化
+    handleSizeChange(val) {
+      this.queryParams.pageSize = val;
+      this.getList();
+    },
+    // 当前页码变化
+    handleCurrentChange(val) {
+      this.queryParams.pageNum = val;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      // 重置时间范围为默认值(本月第一天到当前时间)
+      this.setDefaultDateRange();
+      // 重置预设时间为本月
+      this.activePreset = 'month';
+      this.handleQuery();
+    },
+
+
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有AI外呼客服统计数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        this.exportLoading = true;
+        return exportStatistics(queryParams);
+      }).then(response => {
+        this.download(response.msg);
+        this.exportLoading = false;
+      }).catch(() => {});
+    },
+
+    // 排序处理
+    handleSortChange({ column, prop, order }) {
+      console.log('排序字段:', prop, '排序方式:', order);
+      // 可以在这里添加自定义排序逻辑
+    },
+
+    // 计算效率
+    calculateEfficiency() {
+      if (!this.currentRow.totalSession || !this.currentRow.totalToken) return '0';
+      const efficiency = (this.currentRow.totalSession / this.currentRow.totalToken * 10000).toFixed(1);
+      return efficiency;
+    },
+
+    // 预测下月数据
+    predictNextMonth() {
+      if (!this.currentRow.totalSession) return '0';
+      // 简单的线性预测
+      const predicted = Math.round(this.currentRow.totalSession * 1.15);
+      return this.formatNumber(predicted);
+    },
+
+    // 获取优化建议
+    getRecommendations() {
+      const tips = [
+        '建议优化提示词以提高响应准确性',
+        '可以考虑调整模型温度参数',
+        '增加训练数据可提升模型表现',
+        '定期更新知识库保持时效性'
+      ];
+      return tips;
+    },
+
+    // 导出AI报告
+    exportAIReport() {
+      this.$message.success('AI分析报告已导出');
+      this.aiAnalysisDialogVisible = false;
+    },
+
+    // 格式化时长
+    formatDuration(durationStr) {
+      if (!durationStr) return '00:00:00';
+      return durationStr;
+    }
+  }
+};
+</script>
+
+<style scoped>
+.ai-statistics-container {
+  padding: 20px;
+  background: #ffffff;
+  min-height: 100vh;
+}
+
+.search-card {
+  margin-bottom: 20px;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  background: #ffffff;
+}
+
+.card-title {
+  font-weight: 600;
+  color: #303133;
+}
+
+.table-card {
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  background: #ffffff;
+}
+
+.total-text {
+  float: right;
+  color: #909399;
+  font-size: 14px;
+}
+
+.compact-form {
+  margin-bottom: -18px;
+}
+
+.form-actions {
+  margin-left: auto;
+}
+
+.operation-row {
+  margin-bottom: 16px;
+}
+
+.custom-table {
+  border-radius: 8px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+}
+
+.custom-table ::v-deep .el-table__header th {
+  background: #ffffff;
+  color: #4c5161;
+  font-weight: 600;
+  border-color: #e2e8f0;
+  text-align: center;
+}
+
+.custom-table ::v-deep .el-table__row td {
+  text-align: center;
+  vertical-align: middle;
+  border-color: #e2e8f0;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  text-align: right;
+  padding: 16px;
+  background: #ffffff;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+}
+
+/* 统计仪表盘 */
+.stats-dashboard {
+  margin-bottom: 24px;
+  padding: 0;
+}
+
+.stat-card {
+  position: relative;
+  border-radius: 12px;
+  padding: 20px;
+  margin-bottom: 20px;
+  transition: all 0.3s ease;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  height: 140px;
+  display: flex;
+  flex-direction: column;
+}
+
+.stat-card:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
+}
+
+.stat-session-card {
+  border-top: 4px solid #667eea;
+}
+
+.stat-token-card {
+  border-top: 4px solid #11998e;
+}
+
+.stat-input-card {
+  border-top: 4px solid #f2994a;
+}
+
+.stat-output-card {
+  border-top: 4px solid #2193b0;
+}
+
+.card-icon-wrapper {
+  width: 40px;
+  height: 40px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  margin-bottom: 12px;
+  color: white;
+}
+
+.session-icon {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.token-icon {
+  background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
+}
+
+.input-icon {
+  background: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%);
+}
+
+.output-icon {
+  background: linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%);
+}
+
+.card-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.stat-value {
+  font-size: 28px;
+  font-weight: 700;
+  line-height: 1.2;
+  margin-bottom: 6px;
+}
+
+.stat-label {
+  font-size: 13px;
+  opacity: 0.8;
+  margin-bottom: 0;
+  font-weight: 500;
+}
+
+
+
+
+
+/* 智能筛选卡片 */
+.smart-filter-card {
+  border-radius: 16px;
+  border: none;
+  margin-bottom: 32px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
+  background: #ffffff;
+  overflow: hidden;
+}
+
+.smart-filter-card ::v-deep .el-card__header {
+  background: #ffffff;
+  border-bottom: 1px solid #e2e8f0;
+  padding: 20px 24px;
+}
+
+.filter-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.filter-title-section {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #2d3748;
+}
+
+.filter-title-section i {
+  color: #667eea;
+  font-size: 20px;
+}
+
+.preset-buttons {
+  display: flex;
+  gap: 8px;
+}
+
+.preset-btn {
+  transition: all 0.3s ease;
+  border: 1px solid #e2e8f0;
+}
+
+.preset-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.filter-form {
+  padding: 24px;
+}
+
+.filter-row {
+  display: flex;
+  align-items: end;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+
+.filter-row ::v-deep .el-form-item {
+  margin-bottom: 0;
+}
+
+.filter-row ::v-deep .el-form-item__label {
+  font-weight: 500;
+  color: #4a5568;
+}
+
+.date-picker {
+  width: 280px;
+}
+
+.role-select {
+  width: 200px;
+}
+
+.action-buttons {
+  display: flex;
+  gap: 12px;
+  margin-left: auto;
+}
+
+.search-btn {
+  background: #667eea;
+  border: none;
+  transition: all 0.3s ease;
+}
+
+.search-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
+}
+
+.reset-btn {
+  border: 1px solid #cbd5e0;
+  color: #4a5568;
+  transition: all 0.3s ease;
+}
+
+.reset-btn:hover {
+  background: #f7fafc;
+  transform: translateY(-1px);
+}
+
+.export-btn {
+  background: #11998e;
+  border: none;
+  transition: all 0.3s ease;
+}
+
+.export-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(17, 153, 142, 0.4);
+}
+
+/* 数据表格卡片 */
+.data-table-card {
+  border-radius: 16px;
+  border: none;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
+  background: #ffffff;
+  overflow: hidden;
+}
+
+.data-table-card ::v-deep .el-card__header {
+  background: #ffffff;
+  padding: 20px 24px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.table-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: #2d3748;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.header-title {
+  font-size: 18px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.header-title i {
+  font-size: 20px;
+}
+
+
+
+.header-actions {
+  display: flex;
+  gap: 12px;
+}
+
+.refresh-btn {
+  color: #667eea;
+  font-size: 18px;
+  transition: all 0.3s ease;
+}
+
+.refresh-btn:hover {
+  transform: rotate(180deg);
+}
+
+.modern-table {
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+}
+
+.modern-table ::v-deep .el-table__header th {
+  background: #ffffff;
+  color: #4c5161;
+  font-weight: 600;
+  text-align: center;
+  height: 56px;
+  padding: 8px 4px;
+  border: 1px solid #e2e8f0;
+}
+
+.modern-table ::v-deep .el-table__body td {
+  padding: 16px 4px;
+  text-align: center;
+  vertical-align: middle;
+  border-color: #edf2f7;
+  transition: background-color 0.2s ease;
+  color: #000000;
+}
+
+.modern-table ::v-deep .el-table__row:hover td {
+  background-color: #f8f9fa !important;
+}
+
+.modern-table ::v-deep .el-table__row:nth-child(even) td {
+  background-color: #fdfdfd;
+}
+
+.role-cell {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  justify-content: center;
+}
+
+.role-avatar {
+  position: relative;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  background: #667eea;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 16px;
+  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+}
+
+
+
+.role-name {
+  font-weight: 500;
+  color: #2d3748;
+}
+
+.action-cell {
+  display: flex;
+  justify-content: center;
+  gap: 8px;
+}
+
+.view-btn {
+  background: #ffffff;
+  border: 1px solid #000000;
+  transition: all 0.3s ease;
+  color: #000000;
+}
+
+.view-btn:hover {
+  transform: translateY(-2px) scale(1.1);
+  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
+  background: #f0f0f0;
+}
+
+.ai-btn {
+  background: #ffffff;
+  border: 1px solid #000000;
+  transition: all 0.3s ease;
+  color: #000000;
+}
+
+.ai-btn:hover {
+  transform: translateY(-2px) scale(1.1);
+  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
+  background: #f0f0f0;
+}
+
+.table-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20px 24px;
+  background: #ffffff;
+  border-top: 1px solid #e2e8f0;
+}
+
+.pagination-info {
+  color: #718096;
+  font-size: 14px;
+}
+
+.modern-pagination ::v-deep .el-pagination {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.modern-pagination ::v-deep .el-pager li {
+  margin: 0 4px;
+  border-radius: 8px;
+  transition: all 0.3s ease;
+}
+
+.modern-pagination ::v-deep .el-pager li:hover {
+  color: #667eea;
+  transform: translateY(-2px);
+}
+
+/* AI分析对话框 */
+.ai-analysis-dialog ::v-deep .el-dialog {
+  border-radius: 16px;
+  overflow: hidden;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+}
+
+.ai-analysis-dialog ::v-deep .el-dialog__header {
+  background: #ffffff;
+  padding: 24px;
+  color: #2d3748;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.ai-analysis-dialog ::v-deep .el-dialog__title {
+  color: #2d3748;
+  font-weight: 600;
+  font-size: 20px;
+}
+
+.ai-analysis-content {
+  padding: 24px;
+}
+
+.analysis-header {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  margin-bottom: 32px;
+  padding: 20px;
+  background: #ffffff;
+  border-radius: 12px;
+  border: 1px solid #f0f0f0;
+}
+
+.ai-avatar {
+  position: relative;
+  width: 60px;
+  height: 60px;
+  border-radius: 50%;
+  background: #667eea;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 24px;
+}
+
+.ai-pulse {
+  position: absolute;
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  bottom: -4px;
+  border-radius: 50%;
+  border: 2px solid rgba(102, 126, 234, 0.4);
+  animation: aiPulse 2s infinite;
+}
+
+@keyframes aiPulse {
+  0% { transform: scale(0.8); opacity: 1; }
+  100% { transform: scale(1.5); opacity: 0; }
+}
+
+.analysis-info h3 {
+  margin: 0 0 8px 0;
+  color: #2d3748;
+  font-size: 20px;
+}
+
+.analysis-info p {
+  margin: 0;
+  color: #718096;
+  font-size: 14px;
+}
+
+.analysis-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+  margin-bottom: 32px;
+}
+
+.analysis-card {
+  background: #ffffff;
+  border-radius: 12px;
+  padding: 20px;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e2e8f0;
+  transition: all 0.3s ease;
+}
+
+.analysis-card:hover {
+  transform: translateY(-4px);
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
+  color: #2d3748;
+  font-weight: 600;
+  font-size: 16px;
+}
+
+.card-header i {
+  color: #667eea;
+  font-size: 18px;
+}
+
+.metric, .prediction {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 0;
+  border-bottom: 1px solid #edf2f7;
+}
+
+.metric:last-child, .prediction:last-child {
+  border-bottom: none;
+}
+
+.metric-label, .prediction-label {
+  color: #718096;
+  font-size: 14px;
+}
+
+.metric-value, .prediction-value {
+  font-weight: 700;
+  font-size: 16px;
+  /* 移除了固定颜色设置,使用默认文本颜色 */
+}
+
+/* 已移除 .metric-value.success 的颜色样式 */
+
+.prediction-trend {
+  padding: 4px 12px;
+  border-radius: 16px;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.prediction-trend.up {
+  background: rgba(56, 239, 125, 0.2);
+  color: #11998e;
+}
+
+.recommendations {
+  background: #ffffff;
+  border-radius: 12px;
+  padding: 24px;
+  border: 1px solid #f0f0f0;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 20px;
+  color: #2d3748;
+  font-weight: 600;
+  font-size: 18px;
+}
+
+.section-title i {
+  color: #f6e05e;
+  font-size: 20px;
+}
+
+.recommendation-list {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.recommendation-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 0;
+  border-bottom: 1px solid #edf2f7;
+  color: #4a5568;
+}
+
+.recommendation-item:last-child {
+  border-bottom: none;
+}
+
+.recommendation-item i {
+  color: #38ef7d;
+  font-size: 16px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 16px;
+  padding: 20px 24px;
+  background: #ffffff;
+  border-top: 1px solid #e2e8f0;
+}
+
+.close-btn {
+  background: #e2e8f0;
+  border: 1px solid #cbd5e0;
+  color: #4a5568;
+  padding: 10px 24px;
+  transition: all 0.3s ease;
+}
+
+.close-btn:hover {
+  background: #cbd5e0;
+  transform: translateY(-2px);
+}
+
+.export-btn {
+  background: #667eea;
+  border: none;
+  color: white;
+  padding: 10px 24px;
+  transition: all 0.3s ease;
+}
+
+.export-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
+}
+
+/* 响应式设计 */
+@media (max-width: 1200px) {
+  .page-header {
+    flex-direction: column;
+    text-align: center;
+    gap: 20px;
+  }
+
+  .analysis-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .filter-row {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .action-buttons {
+    margin-left: 0;
+    justify-content: center;
+  }
+
+  .ai-statistics-container {
+    background: #ffffff;
+  }
+}
+
+@media (max-width: 768px) {
+  .ai-statistics-container {
+    padding: 16px;
+    background: #ffffff;
+  }
+
+  .page-header {
+    padding: 20px;
+  }
+
+  .page-title {
+    font-size: 24px;
+  }
+
+  .stat-card {
+    padding: 20px;
+    height: 140px;
+  }
+
+  .stat-value {
+    font-size: 28px;
+  }
+
+  .filter-form {
+    padding: 20px;
+  }
+
+  .date-picker, .role-select {
+    width: 100%;
+  }
+
+  .table-footer {
+    flex-direction: column;
+    gap: 16px;
+    align-items: stretch;
+  }
+
+  .pagination-info {
+    text-align: center;
+  }
+
+  .ai-powered-badge {
+    position: static;
+    margin-bottom: 16px;
+    justify-content: center;
+  }
+}
+
+@media (max-width: 480px) {
+  .stat-card {
+    padding: 16px;
+    height: 120px;
+  }
+
+  .stat-value {
+    font-size: 24px;
+  }
+
+  .stat-label {
+    font-size: 13px;
+  }
+
+  .card-icon-wrapper {
+    width: 48px;
+    height: 48px;
+    font-size: 20px;
+  }
+
+  .page-title {
+    font-size: 20px;
+  }
+
+  .analysis-header {
+    flex-direction: column;
+    text-align: center;
+    gap: 16px;
+  }
+
+  .dialog-footer {
+    flex-direction: column;
+  }
+
+  .export-btn, .close-btn {
+    width: 100%;
+  }
+
+  .ai-statistics-container {
+    background: #ffffff;
+  }
+}
+
+/* 性能优化 */
+.stat-card, .analysis-card, .smart-filter-card, .data-table-card {
+  will-change: transform, box-shadow;
+  backface-visibility: hidden;
+  transform: translateZ(0);
+}
+
+/* 滚动条美化 */
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background: #f8f9fa;
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb {
+  background: #667eea;
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: #5a6fd8;
+}
+</style>

+ 1151 - 0
src/views/qw/groupVoiceMsg/index.vue

@@ -0,0 +1,1151 @@
+<template>
+  <div>
+    <div class="app-container">
+    </div>
+    <div class="app-container">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+        <el-form-item label="企微公司" prop="corpId">
+                        <el-select v-model="queryParams.corpId" placeholder="请选择企微公司"  size="small" @change="updateCorpId()">
+                          <el-option
+                            v-for="dict in myQwCompanyList"
+                            :key="dict.dictValue"
+                            :label="dict.dictLabel"
+                            :value="dict.dictValue"
+                          />
+                        </el-select>
+        </el-form-item>
+        <el-form-item label="AI客服" prop="aiCustomerRoleId">
+          <el-select
+            v-model="queryParams.aiCustomerRoleId"
+            placeholder="请选择AI客服角色"
+            clearable
+            size="small"
+            @keyup.enter.native="handleQuery">
+            <el-option
+              v-for="item in aiCustomerRoleOptions"
+              :key="item.id"
+              :label="item.callRoleName"
+              :value="item.id">
+            </el-option>
+          </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-button type="primary" size="mini" @click="handleAdd()" v-hasPermi="['qw:groupVoiceMsg:add']">新建群呼</el-button>
+        </el-form-item>
+      </el-form>
+      <el-row :gutter="10" class="mb8">
+        <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+      </el-row>
+
+      <el-table v-loading="loading" :data="groupMsgList" @selection-change="handleSelectionChange" border>
+        <el-table-column label="AI外呼客服" align="left" prop="aiCallRoleName"/>
+        <el-table-column label="任务类型" align="center" prop="chatType">
+          <template slot-scope="scope">
+            <span v-if="scope.row.chatType === 1">AI外呼群发</span>
+            <span v-else-if="scope.row.chatType === 2">手动</span>
+            <span v-else-if="scope.row.chatType === 3">sop模板提醒</span>
+            <span v-else-if="scope.row.chatType === 4">新客对话</span>
+            <span v-else-if="scope.row.chatType === 5">sop任务</span>
+            <span v-else>{{scope.row.chatType}}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="呼叫时效" align="center" prop="isTimerSend">
+          <template slot-scope="scope">
+            <span v-if="scope.row.isTimerSend === 1">定时呼叫</span>
+            <span v-else-if="scope.row.isTimerSend === 2">立即呼叫</span>
+            <span v-else>{{scope.row.isTimerSend}}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="呼叫时间" align="center">
+          <template slot-scope="scope">
+            <span v-if="scope.row.isTimerSend===2">{{scope.row.createTime}}</span>
+            <span v-else>{{ scope.row.timerSend }}</span>
+          </template>
+        </el-table-column>
+
+
+        <el-table-column label="呼叫状态" align="center"  width="110px">
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.isSend===1"
+              type="success"
+              size="mini"
+              plain>
+              呼叫成功
+            </el-tag >
+            <el-tag
+              v-else-if="scope.row.isSend===0"
+              type="danger"
+              size="mini"
+              plain>
+              呼叫失败
+            </el-tag >
+            <el-tag
+              v-else-if="scope.row.isSend===3"
+              type="warning"
+              size="mini"
+              plain>
+              待呼叫
+            </el-tag >
+          </template>
+        </el-table-column>
+        <el-table-column label="已呼数" align="center" prop="calledCallUserSum" />
+        <el-table-column label="待呼数" align="center" prop="pendingCalledCallUserSum" />
+        <el-table-column label="失败数" align="center" prop="failCallUserSum" />
+        <el-table-column label="总呼数" align="center" prop="callUserSum" />
+        <el-table-column label="创建人" align="center" prop="createName" width="120px" />
+        <el-table-column label="创建时间" align="center" prop="createTime" width="160px"/>
+        <el-table-column label="操作" align="center" class-name="small-padding" width="180px" fixed="right">
+          <template slot-scope="scope">
+            <el-button
+              size="mini"
+              type="text"
+              icon="el-icon-notebook-2"
+              @click="hangdleDitels(scope.row)"
+              v-hasPermi="['qw:groupVoiceMsg:query']"
+            >详情</el-button>
+            <el-button
+              size="mini"
+              type="text"
+              icon="el-icon-delete"
+              @click="handleDelete(scope.row)"
+              v-hasPermi="['qw:groupVoiceMsg:remove']"
+            >删除</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-dialog v-loading="loading" :title="title" :visible.sync="open" width="1500px" @close="closeData" style="font-weight: bold;font-size: 20px" append-to-body>
+        <div style="display: flex;flex-wrap: wrap">
+          <div style="width: 1000px;">
+            <el-form ref="form" :model="form" :rules="rules" label-width="160px" size="medium" style="font-size: 20px" >
+              <div>
+                <span style="font-size: 20px">基础信息</span>
+                <el-divider></el-divider>
+              </div>
+
+              <el-form-item label="选择外呼成员账号:" prop="userIdsSelectList" style="font-size: 30px">
+                <div>
+                  <div style="display: flex; align-items: center; margin-bottom: 10px;">
+                    <el-button
+                      size="medium"
+                      icon="el-icon-circle-plus-outline"
+                      plain
+                      @click="handlelistUser">请选择外呼账号</el-button>
+                    <div style="margin-left: 15px; color: #909399; font-size: 14px; display: flex; align-items: center;">
+                      <i class="el-icon-info" style="margin-right: 5px;"></i>
+                      <span>外呼账号需先在【系统管理】-【员工管理】-【操作】-【绑定外呼角色】后才能选择外呼成员</span>
+                    </div>
+                  </div>
+                  <div>
+                    <el-tag
+                      style="margin-left: 5px; margin-top: 5px;"
+                      size="medium"
+                      :key="list.id"
+                      v-for="list in userSelectList"
+                      closable
+                      :disable-transitions="false"
+                      @close="handleClosegroupUser(list)">
+                      {{list.qwUserName}}({{list.nickName}})
+                    </el-tag>
+                  </div>
+                </div>
+              </el-form-item>
+
+
+              <el-form-item label="选择客户:">
+                <el-radio-group v-model="form.selectType" @input="clearExpectCountCustomer" size="medium ">
+                  <el-radio :label="item.dictValue" v-for="item in groupMsgTypeOptions"  >{{item.dictLabel}}</el-radio>
+                </el-radio-group>
+                <div style="background-color:#ecf8fe;width: 100%;margin-top: 1%" v-if="form.selectType==1">
+                  <span style="padding: 3%;font-size: 15px" >查看该消息预计呼叫人数:</span>
+                  <el-link style="color: #1aa4ff;" v-if="this.expectCountCustomer==null" @click="viewReceiveNum">点我查看
+                  </el-link>
+                  <span style="color: #d440ec;margin-left: 2%" v-if="this.expectCountCustomer!=null">{{this.expectCountCustomer}} 人</span>
+                </div>
+                <div v-if="form.selectType==2" style="padding-top: 1%;background-color: #fbfbfb">
+                  <el-form-item label="按性别筛选客户:" class="elFormItemClass">
+                    <el-radio-group v-model="form.gender"  @input="clearExpectCountCustomer">
+                      <el-radio v-for="item in sysUserSex" :label="item.dictValue">{{item.dictLabel}}</el-radio>
+                    </el-radio-group>
+                  </el-form-item>
+                  <el-form-item label="按所在群聊筛选:" class="elFormItemClass">
+                    <div>
+                      <div>
+                        <el-button
+                          icon="el-icon-s-comment"
+                          plain
+                          @click="handleGroupChatList">请选择群聊</el-button>
+                      </div>
+                      <div>
+                        <div>
+                          <el-tag
+                            style="margin-left: 5px"
+                            size="medium"
+                            :key="list.chatId"
+                            v-for="list in groupChatList"
+                            closable
+                            :disable-transitions="false"
+                            @close="handleClosegroupChat(list)">
+                            {{list.name}}
+                          </el-tag>
+
+                        </div>
+                      </div>
+                    </div>
+
+                  </el-form-item>
+                  <el-form-item label="按添加时间筛选" class="elFormItemClass">
+                    <div style="width: 100%;display: flex">
+                      <el-date-picker
+                        v-model="form.timeScreenCharge"
+                        @change="clearExpectCountCustomer"
+                        type="daterange"
+                        align="right"
+                        unlink-panels
+                        range-separator="至"
+                        start-placeholder="开始日期"
+                        end-placeholder="结束日期"
+                        :picker-options="pickerOptions"
+                        :default-time="['00:00:00', '23:59:59']">
+                      </el-date-picker>
+                    </div>
+
+                  </el-form-item>
+                  <el-form-item label="按客户标签筛选:" class="elFormItemClass">
+                    <div>
+                      <div>
+                        <el-button icon="el-icon-s-check" plain @click="hangleChangeTags">请选择标签</el-button>
+                      </div>
+                      <el-tag type="success"
+                              closable
+                              :disable-transitions="false"
+                              v-for="list in tagsIdsChangeSelectList"
+                              :key="list.tagId"
+                              @close="handleCloseTags(list)"
+                              style="margin: 3px;"
+                      >{{list.name}}
+                      </el-tag>
+                    </div>
+                  </el-form-item>
+
+                  <el-form-item label="排除标签筛选" class="elFormItemClass">
+                    <div>
+                      <div>
+                        <el-button icon="el-icon-s-check" plain @click="hangleChangeOutTags">请选择排除的标签</el-button>
+                      </div>
+
+                      <div>
+                        <el-tag type="success"
+                                closable
+                                :disable-transitions="false"
+                                v-for="list in outTagsIdsChangeSelectList"
+                                :key="list.tagId"
+                                @close="handleCloseOutTags(list)"
+                                style="margin: 3px;"
+                        >{{list.name}}
+                        </el-tag>
+                      </div>
+
+                    </div>
+                  </el-form-item>
+
+                  <div style="background-color:#ecf8fe;width: 100%;height: 10%;margin-top: 3%">
+                    <span style="margin-left: 2%;font-size: 15px" v-if="this.expectCountGroupChat==null">查看该消息预计呼叫人数:</span>
+                    <el-link style="color: #1aa4ff;margin-left: 3%" v-if="this.expectCountGroupChat==null" @click="viewReceiveGroupChatNum">点我查看
+                    </el-link>
+                    <span style="color: #d440ec;margin-left: 2%" v-if="this.expectCountGroupChat!=null">{{this.expectCountGroupChat}} 人</span>
+                  </div>
+                </div>
+
+
+              </el-form-item>
+              <div>
+                <span>AI外呼客服选择</span>
+                <el-divider></el-divider>
+              </div>
+            </el-form>
+          </div>
+        </div>
+
+        <WelComeAddAIVoice ref="WelComeAddAIVoice"></WelComeAddAIVoice>
+
+        <div slot="footer" class="dialog-footer" style="display: flex;justify-content: center">
+          <el-button type="primary" @click="submitForm">确定</el-button>
+          <el-button @click="cancel">取 消</el-button>
+        </div>
+      </el-dialog>
+
+      <!-- 选择群发账号弹窗   -->
+      <el-dialog :title="listUser.title" :visible.sync="listUser.open" width="1600px"  append-to-body>
+        <qwUserList ref="qwUserList" @selectUserList="selectUserList"></qwUserList>
+      </el-dialog>
+
+      <!-- 选择群聊    -->
+      <el-dialog :title="groupChart.title" :visible.sync="groupChart.open" width="1300px"  append-to-body>
+        <group-chat ref="GroupChat" @selectGroupChatList="selectGroupChatList"></group-chat>
+      </el-dialog>
+
+      <!--  选择的标签   -->
+      <el-dialog :title="changeTagDialog.title" :visible.sync="changeTagDialog.open" width="1000px"  append-to-body>
+
+        <div>搜索标签:
+          <el-input v-model="tagChange.tagName" placeholder="请输入标签名称" clearable size="small" style="width: 200px;margin-right: 10px" />
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleSearchTags(tagChange.tagName)">搜索</el-button>
+          <el-button type="primary" icon="el-icon-plus" size="mini" @click="cancelSearchTags">重置</el-button>
+        </div>
+        <div v-for="item in tagGroupList" :key="item.id"  >
+          <div style="font-size: 20px;margin-top: 20px;margin-bottom: 20px;">
+            <span class="name-background">{{ item.name }}</span>
+          </div>
+          <div class="tag-container">
+            <a
+              v-for="tagItem in item.tag"
+              class="tag-box"
+              @click="tagSelection(tagItem)"
+              :class="{ 'tag-selected': tagItem.isSelected }"
+            >
+              {{ tagItem.name }}
+            </a>
+          </div>
+        </div>
+        <div slot="footer" class="dialog-footer">
+          <el-button type="primary" @click="tagSubmitForm(changeTagDialog.type)">确 定</el-button>
+          <el-button @click="tagCancel(changeTagDialog.type)">取消</el-button>
+        </div>
+      </el-dialog>
+
+      <!--  详情  -->
+      <el-drawer :title="detailsDialog.title" :visible.sync="detailsDialog.open" size="80%" style="font-weight: bolder">
+        <div style="background-color:  rgb(240 242 245)">
+          <div style="width: 100%">
+            <customer-group-details ref="customerGroupDetails" :rowDetailFrom="formDetails"></customer-group-details>
+          </div>
+        </div>
+      </el-drawer>
+    </div>
+
+  </div>
+</template>
+
+<script>
+import {
+  myList,
+  getGroupVoiceMsg,
+  delGroupVoiceMsg,
+  addGroupVoiceMsg, remindGroupVoiceMsg
+} from '@/api/qw/groupVoiceMsg'
+import GroupMsgUser from '@/views/qw/groupMsgUser/groupMsg'
+import ImageUpload from "@/views/qw/material/ImageUpload";
+import qwUserList from "@/views/qw/user/qwUserList"
+import { selectCountCustomer } from '@/api/qw/externalContact'
+import CustomerGroupDetails from '@/views/qw/groupVoiceMsg/customerGroupDetails'
+import { getMyQwCompanyList } from "@/api/qw/user";
+import {allListTagGroup} from "@/api/qw/tagGroup";
+import {listTag, searchTags} from "@/api/qw/tag";
+import  MaterialQw  from "@/views/qw/materialQw/index.vue";
+import WelComeAddAIVoice from "../welcome/welComeAddAIVoice.vue";
+import {listRole} from "../../../api/aiSopCall/aiSopCallRole";
+export default {
+  name: "GroupVoiceMsg",
+  components: { CustomerGroupDetails, GroupMsgUser,ImageUpload,qwUserList,WelComeAddAIVoice,MaterialQw},
+  data() {
+    return {
+      imageArr:[],
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      myQwCompanyList:[],
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      dialogImageUrl:null,
+      dialogVisible:false,
+
+      // 客户群发记录主表格数据
+      groupMsgList: [],
+
+      //发送类型
+      groupMsgTimerType:[],
+      //呼叫ai客服角色
+      aiCustomerRoleOptions: [],
+
+      //选择客户类型
+      groupMsgTypeOptions:[],
+      //是否允许成员在待发送列表中再次选择
+      allowSelectOptions:[],
+
+      //消息类型
+      msgTypeOptions:[],
+      //客户性别
+      sysUserSex:[],
+      genderSex:null,
+      //预期发送客户人数
+      expectCountCustomer:null,
+
+      //部分客户预期发送客户人数
+      expectCountGroupChat:null,
+
+      //客户群选择列表
+      groupChatList:[],
+
+      //成员选择列表
+      userSelectList:[],
+
+      //查询预计通知人数
+      countList:{
+        //根据成员查
+        userIds:{},
+        //根据筛选客户条件查
+        //1.群聊
+        chartIds:{},
+
+      },
+
+      //选择的标签
+      tagsIdsChangeSelectList:null,
+      //排除的标签
+      outTagsIdsChangeSelectList:null,
+
+      //选择群发账号列表
+      listUser:{
+        title:"",
+        open:false
+      },
+      //选择所在群聊里的人
+      groupChart:{
+        title:"",
+        open:false,
+      },
+
+      //标签弹窗选择
+      tagChange:{
+        open:false,
+        index:null,
+      },
+      //选择标签
+      changeTagDialog:{
+        title:"",
+        open:false,
+        type:null,
+      },
+      detailsDialog:{
+        title:"",
+        open:false,
+        type:null,
+      },
+      //标签组
+      tagGroupList:[],
+      //所有标签
+      tagList:[],
+
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        chatType: 1,
+        sender: null,
+        allowSelect: null,
+        textContent: null,
+        msgid: null,
+        qwMsgId:null,
+        corpId: null,
+        companyId: null,
+        tagNames: null,
+        groupChatNames: null,
+        fromUserCount: null,
+        toUserCount: null,
+        fromUserNum: null,
+        toUserNum: null,
+        aiCustomerRoleId: null,
+      },
+      // 表单参数
+      form: {},
+      //详情
+      formDetails:{},
+      // 表单校验
+      rules: {
+
+        chatType: [
+          { required: true, message: "群发任务类型 好几种暂定 1AI外呼群发的  2手动的 3 sop模板提醒的", trigger: "change" }
+        ],
+
+        userIdsSelectList: [
+          { required: true, message: "发送企业群发消息的成员账号不能为空", trigger: "blur" }
+        ],
+        allowSelect: [
+          { required: true, message: "是否允许成员在待发送客户列表中重新进行选择,默认为true", trigger: "blur" }
+        ],
+        textContent:[
+          { required: true, message: "消息文本不能为空噢", trigger: "blur" }
+        ],
+        createName:[
+          { required: true, message: "创建人不能为空", trigger: "blur" }
+        ]
+
+      },
+      //日期快捷选择
+      pickerOptions: {
+        shortcuts: [
+          {
+            text: '最近三天',
+            onClick(picker) {
+              const end = new Date();
+              const start = new Date();
+              start.setTime(start.getTime() - 3600 * 1000 * 24 * 3);
+              picker.$emit('pick', [start, end]);
+            }
+          },{
+            text: '最近一周',
+            onClick(picker) {
+              const end = new Date();
+              const start = new Date();
+              start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
+              picker.$emit('pick', [start, end]);
+            }
+          }, {
+            text: '最近一个月',
+            onClick(picker) {
+              const end = new Date();
+              const start = new Date();
+              start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
+              picker.$emit('pick', [start, end]);
+            }
+          }, {
+            text: '最近三个月',
+            onClick(picker) {
+              const end = new Date();
+              const start = new Date();
+              start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
+              picker.$emit('pick', [start, end]);
+            }
+          }]
+      },
+
+    };
+  },
+  created() {
+    getMyQwCompanyList().then(response => {
+            this.myQwCompanyList = response.data;
+            if(this.myQwCompanyList!=null){
+              this.queryParams.corpId=this.myQwCompanyList[0].dictValue;
+              this.getList();
+            }
+    });
+
+    listRole().then(response => {
+      this.aiCustomerRoleOptions = response.rows;
+    });
+
+    //全部客户还是部分客户
+    this.getDicts("sys_qw_groupMsg_type").then(response => {
+      this.groupMsgTypeOptions = response.data;
+    });
+
+    //文本类型字典(复用)
+    this.getDicts("sys_qw_welcome_type").then(response => {
+      this.msgTypeOptions = response.data;
+    });
+
+
+    //发送类型
+    this.getDicts("sys_qw_group_msg_timer_type").then(response => {
+      this.groupMsgTimerType = response.data;
+    });
+
+    //客户性别
+    this.getDicts("sys_qw_external_contact_gender").then(response => {
+      this.sysUserSex = response.data;
+
+      // 要添加的新对象数据
+      let newUserData = {
+        dictValue: "99",
+        dictLabel: '全部',
+      };
+      // 添加对象到数组
+      this.sysUserSex.push(newUserData);
+
+    });
+
+    //是否允许成员在待发送列表中再次选择
+    this.getDicts("sys_qw_allow_select").then(response => {
+      this.allowSelectOptions = response.data;
+    });
+  },
+
+  watch:{
+    // 监听 userSelectList 的变化,并同步更新 form.userIdsSelectList
+    userSelectList(newList) {
+      this.form.userIdsSelectList = newList.map(item => item.qwUserId);
+    },
+    groupChatList(newList){
+      this.form.groupChatSelectList=newList.map(item=>item.chatId)
+    },
+  },
+  methods: {
+    updateCorpId(){
+           this.getList();
+     },
+
+    //关闭弹窗-清空页面
+    closeData(){
+      this.reset();
+    },
+    /** 查询客户群发记录主列表 */
+    getList() {
+      this.loading = true;
+      myList(this.queryParams).then(response => {
+        this.groupMsgList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+
+    //标签的选择
+    tagSelection(row){
+      row.isSelected= !row.isSelected;
+      this.$forceUpdate();
+    },
+    /** 提醒成员群发 */
+    triggerremindGroupVoiceMsg(row){
+      this.queryParams.qwMsgId=row.msgId
+      remindGroupVoiceMsg(this.queryParams).then(res=>{
+
+        if (res.code==200){
+
+          if (res.data.errcode==0){
+            return this.msgSuccess("提醒成员群发成功")
+          }else if (res.data.errcode==41094){
+            return this.msgWarning("此条记录的每日提醒次数超过限制了噢")
+          }else {
+            return this.msgError(res.data.errmsg)
+          }
+
+        }else {
+          return this.msgError(res.msg)
+        }
+      })
+    },
+    handleSearchTags(name){
+
+      searchTags({name:name,corpId:this.queryParams.corpId}).then(response => {
+        this.tagGroupList = response.rows;
+      });
+
+    },
+    cancelSearchTags(){
+      this.afreshData()
+    },
+    //清除点我查看
+    clearExpectCountCustomer(){
+
+      //选择全部客户的统计
+      this.expectCountCustomer=null;
+      //选择部分客户的统计
+      this.expectCountGroupChat=null;
+    },
+
+    //查看预计接收人数
+    viewReceiveNum(){
+      if (this.userSelectList.length===0){
+        return this.msgError("请选择群发成员账号");
+      }else {
+        this.form.corpId=this.queryParams.corpId;
+        console.log("this.queryParams.corpId="+ this.queryParams.corpId);
+        selectCountCustomer(this.form).then(res=>{
+          this.expectCountCustomer=res.data;
+        })
+      }
+    },
+
+    //查看部分客户预计接收人数
+    viewReceiveGroupChatNum(){
+
+      if (this.userSelectList.length===0){
+        return  this.msgError("请选择群发成员账号");
+      }else {
+
+        // 使用 map 方法和解构赋值提取每个子数组的第一个元素
+        // 选择的标签
+        if (this.tagsIdsChangeSelectList!=null){
+          this.form.tagsIdsSelectList=this.tagsIdsChangeSelectList.map(item => item.tagId);
+        }
+
+        //排除的标签
+        if (this.outTagsIdsChangeSelectList!=null){
+          this.form.outTagsIdsSelectList = this.outTagsIdsChangeSelectList.map(item => item.tagId);
+        }
+        this.form.corpId=this.queryParams.corpId;
+        selectCountCustomer(this.form).then(res=>{
+          this.expectCountGroupChat=res.data;
+        })
+
+      }
+    },
+
+    //选择的成员账号列表
+    selectUserList(list){
+      this.listUser.open=false;
+      //用于显示
+      this.userSelectList=list;
+
+    },
+
+    //选择的部分客户时,选择的群聊里的
+    selectGroupChatList(list){
+
+      this.groupChart.open=false;
+      //用于显示
+      this.groupChatList=list;
+
+    },
+    //确定选择标签
+    tagSubmitForm(type){
+        if (type===1){
+          for (let i = 0; i < this.tagGroupList.length; i++) {
+            for (let x = 0; x < this.tagGroupList[i].tag.length; x++) {
+              if (this.tagGroupList[i].tag[x].isSelected === true) {
+
+                if (!this.tagsIdsChangeSelectList) {
+                  this.tagsIdsChangeSelectList = [];
+                }
+
+                // 检查当前 tag 是否已经存在于 tagListFormIndex[index] 中
+                let tagExists = this.tagsIdsChangeSelectList.some(
+                  tag => tag.id === this.tagGroupList[i].tag[x].id
+                );
+
+                // 如果 tag 不存在于 tagListFormIndex[index] 中,则新增
+                if (!tagExists) {
+                  this.tagsIdsChangeSelectList.push(this.tagGroupList[i].tag[x]);
+                }
+              }
+            }
+          }
+          if (!this.tagsIdsChangeSelectList || this.tagsIdsChangeSelectList.length === 0) {
+            return this.$message('请选择标签');
+          }
+
+        }else if (type===2){
+
+          for (let i = 0; i < this.tagGroupList.length; i++) {
+            for (let x = 0; x < this.tagGroupList[i].tag.length; x++) {
+              if (this.tagGroupList[i].tag[x].isSelected === true) {
+
+                if (!this.outTagsIdsChangeSelectList) {
+                  this.outTagsIdsChangeSelectList = [];
+                }
+
+                // 检查当前 tag 是否已经存在于 tagListFormIndex[index] 中
+                let tagExists = this.outTagsIdsChangeSelectList.some(
+                  tag => tag.id === this.tagGroupList[i].tag[x].id
+                );
+
+                // 如果 tag 不存在于 tagListFormIndex[index] 中,则新增
+                if (!tagExists) {
+                  this.outTagsIdsChangeSelectList.push(this.tagGroupList[i].tag[x]);
+                }
+              }
+            }
+          }
+          if (!this.outTagsIdsChangeSelectList || this.outTagsIdsChangeSelectList.length === 0) {
+            return this.$message('请选择标签');
+          }
+
+        }
+
+
+
+      this.changeTagDialog.open = false;
+    },
+
+    //取消选择标签
+    tagCancel(){
+      this.changeTagDialog.open = false;
+    },
+
+    //删除一些选择了的账号
+    handleClosegroupUser(list){
+
+      // 假设 list 对象具有一个 id 属性
+      const index = this.userSelectList.findIndex(t => t.id === list.id);
+      if (index !== -1) {
+        this.userSelectList.splice(index, 1);
+      }
+      //表单同时清除
+      // this.form.userIdsSelectList= this.userSelectList.map(item => item.id);
+    },
+
+    //删除一些选择了的群聊
+    handleClosegroupChat(list){
+
+      // 假设 list 对象具有一个 id 属性
+      const index = this.groupChatList.findIndex(t => t.chatId === list.chatId);
+      if (index !== -1) {
+        this.groupChatList.splice(index, 1);
+      }
+      // //表单同时清除
+      // this.form.groupChatSelectList=this.groupChatList.map(item=>item.chatId);
+    },
+
+    //删除一些选择的标签
+    handleCloseTags(list){
+      // 假设 list 对象具有一个 id 属性
+      const ls = this.tagsIdsChangeSelectList.findIndex(t => t.tagId === list.tagId);
+      if (ls !== -1) {
+        this.tagsIdsChangeSelectList.splice(ls, 1);
+        this.tagsIdsChangeSelectList = [...this.tagsIdsChangeSelectList];
+      }
+    },
+
+    //删除一些排除的标签
+    handleCloseOutTags(list){
+      // 假设 list 对象具有一个 id 属性
+      const ls = this.outTagsIdsChangeSelectList.findIndex(t => t.tagId === list.tagId);
+      if (ls !== -1) {
+        this.outTagsIdsChangeSelectList.splice(ls, 1);
+        this.outTagsIdsChangeSelectList = [...this.outTagsIdsChangeSelectList];
+      }
+    },
+
+    //选择群发的企业成员账号
+    handlelistUser(){
+      this.listUser.title="选择企业成员"
+      this.listUser.open=true;
+      setTimeout(() => {
+        this.$refs.qwUserList.getBindAiUser(this.queryParams.corpId,1)
+      }, 200);
+
+
+      //预计人数制空
+      this.expectCountCustomer=null;
+    },
+
+    //选择群聊
+    handleGroupChatList(){
+
+      this.groupChart.title="选择所在群聊里的客户"
+      this.groupChart.open=true;
+
+      setTimeout(() => {
+        this.$refs.GroupChat.getDetailsList(this.queryParams.corpId)
+      }, 200);
+
+      //预计人数制空
+      this.expectCountGroupChat=null;
+
+    },
+
+    //选择标签
+    hangleChangeTags(){
+
+      this.changeTagDialog.title="选择标签"
+      this.changeTagDialog.open=true;
+      this.changeTagDialog.type=1;
+
+      // 获取 tagListFormIndex 中的所有 tagId,用于快速查找
+      const selectedTagIds = new Set(this.tagsIdsChangeSelectList.map(tagItem => tagItem.tagId));
+      for (let i = 0; i < this.tagGroupList.length; i++) {
+        for (let x = 0; x < this.tagGroupList[i].tag.length; x++) {
+          this.tagGroupList[i].tag[x].isSelected = selectedTagIds.has(this.tagGroupList[i].tag[x].tagId);
+        }
+      }
+
+      // setTimeout(() => {
+      //    this.$refs.TagGroupList.getTagGroupList(this.queryParams.corpId)
+      // }, 200);
+
+
+      //预计人数制空
+      this.expectCountGroupChat=null;
+    },
+
+    //选择排除标签
+    hangleChangeOutTags(){
+      this.changeTagDialog.title="选择排除的标签"
+      this.changeTagDialog.open=true;
+      this.changeTagDialog.type=2;
+
+      // 获取 tagListFormIndex 中的所有 tagId,用于快速查找
+      const selectedTagIds = new Set(this.outTagsIdsChangeSelectList.map(tagItem => tagItem.tagId));
+      for (let i = 0; i < this.tagGroupList.length; i++) {
+        for (let x = 0; x < this.tagGroupList[i].tag.length; x++) {
+          this.tagGroupList[i].tag[x].isSelected = selectedTagIds.has(this.tagGroupList[i].tag[x].tagId);
+        }
+      }
+
+      // setTimeout(() => {
+      //   this.$refs.TagGroupList.getTagGroupList(this.queryParams.corpId)
+      // }, 200);
+      //
+
+      //预计人数制空
+      this.expectCountGroupChat=null;
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        chatType: 1,
+        sender: null,
+        gender:"99",
+        allowSelect: "Y",
+        textContent: null,
+        msgid: null,
+        corpId: null,
+        companyId: null,
+        tagNames: null,
+        groupChatNamesList: [],
+        fromUserCount: null,
+        toUserCount: null,
+        fromUserNum: null,
+        toUserNum: null,
+        createTime: null,
+        updateTime: null,
+        msgType:null,
+        mediaId: null,
+        mediaPicUrl: null,
+        title: null,
+        picurl: null,
+        description: null,
+        url: null,
+        miniprogramTitle: null,
+        miniprogramPicMediaId: null,
+        miniprogramPicUrl: null,
+        miniprogramAppid: null,
+        miniprogramPage: null,
+        fileMediaId: null,
+        fileUrl: null,
+        videoMediaId: null,
+        videoUrl: null,
+        //选择的成员账号
+        userIdsSelectList:[],
+        //选择的群聊
+        groupChatSelectList:[],
+        //选择的时间
+        timeScreenCharge:[],
+
+        //全部客户还是部分客户
+        selectType:'1',
+
+        //选择的标签列表
+        tagsIdsSelectList:[],
+
+        //排除的标签
+        outTagsIdsSelectList:[],
+      };
+      this.userSelectList=[];
+      this.expectCountCustomer=null;
+      this.groupChatList=[];
+      this.tagsIdsChangeSelectList=[];
+      this.outTagsIdsChangeSelectList=[];
+      this.expectCountGroupChat=[];
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+
+    hangdleDitels(row){
+      this.reset();
+      const id = row.id || this.ids
+      getGroupVoiceMsg(id).then(response => {
+        this.formDetails = response.data;
+        this.detailsDialog.open = true;
+        this.detailsDialog.title = "客户外呼记录";
+      });
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      // 检查是否选择了企微公司
+      if (!this.queryParams.corpId) {
+        this.msgError("请先选择要操作的企微公司");
+        return;
+      }
+      this.reset();
+      this.open = true;
+      this.form.msgType="1"
+      this.title = "新建外呼";
+      this.afreshData();
+    },
+    /**
+     * 重新获取 部分数据
+     */
+    afreshData(){
+      //所有的标签组
+      allListTagGroup({corpId:this.queryParams.corpId}).then(response => {
+        this.tagGroupList = response.rows;
+      });
+
+      //所有的标签
+      listTag({corpId:this.queryParams.corpId}).then(response => {
+        this.tagList = response.rows;
+      });
+
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids
+      getGroupVoiceMsg(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改客户群发记录主";
+      });
+    },
+
+    openImageViewer(url) {
+      // 打开大图预览对话框
+      this.dialogImageUrl=url
+      this.dialogVisible = true;
+    },
+
+    /** 提交按钮 */
+    async  submitForm() {
+      // 选择的标签
+      if (this.tagsIdsChangeSelectList!=null){
+        this.form.tagsIdsSelectList=this.tagsIdsChangeSelectList.map(item => item.tagId);
+      }
+
+      //排除的标签
+      if (this.outTagsIdsChangeSelectList!=null){
+        this.form.outTagsIdsSelectList = this.outTagsIdsChangeSelectList.map(item => item.tagId);
+      }
+      this.form.corpId=this.queryParams.corpId;
+      try {
+        // 验证子组件表单
+        await this.$refs.WelComeAddAIVoice.validateChildForm();
+        // 父组件表单验证逻辑
+        await this.$refs["form"].validate(valid => {
+          if (valid) {
+            if (this.form.id != null) {
+              return this.msgError("没有修改选项");
+            } else {
+              this.loading=true;
+              Object.assign(this.form, this.$refs.WelComeAddAIVoice.form);
+
+              this.form.chatType= 1 ;
+
+              addGroupVoiceMsg(this.form).then(response => {
+                  this.msgSuccess("创建成功");
+                  this.open = false;
+                  this.loading = false;
+                  this.getList();
+              }).catch(error => {
+                this.open=true;
+                this.loading = false;
+                this.msgError("创建失败:"+ error);
+              }).finally(
+                  () => {
+                    this.$refs["form"].resetFields();
+                    this.$refs.WelComeAddAIVoice.reset();
+                    this.userSelectList=[];
+                    this.reset();
+                  }
+              );
+            }
+          }
+        });
+
+      } catch (error) {
+        console.error('表单编辑群发信息验证不用过:', error.message);
+      }
+
+
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const ids = row.id || this.ids;
+      this.$confirm('是否确认删除客户群发记录主编号为"' + ids + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delGroupVoiceMsg(ids);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+  }
+};
+</script>
+<style scoped>
+.elFormItemClass{
+  margin: 20px 0px
+
+}
+
+.message-stayle span {
+  word-break: break-all;
+}
+
+
+/* CSS 样式 */
+.tag-container {
+  display: flex;
+  flex-wrap: wrap; /* 超出宽度时自动换行 */
+  gap: 8px; /* 设置标签之间的间距 */
+}
+.name-background {
+  display: inline-block;
+  background-color: #abece6; /* 背景颜色 */
+  padding: 4px 8px; /* 调整内边距,让背景包裹文字 */
+  border-radius: 4px; /* 可选:设置圆角 */
+}
+.tag-box {
+  padding: 8px 12px;
+  border: 1px solid #989797;
+  border-radius: 4px;
+  cursor: pointer;
+  display: inline-block;
+}
+
+.tag-selected {
+  background-color: #00bc98;
+  color: #fff;
+  border-color: #00bc98;
+}
+
+</style>

+ 441 - 0
src/views/qw/qwSopTempVoice/index.vue

@@ -0,0 +1,441 @@
+<template>
+  <div class="app-container">
+    <div class="voice-layout">
+      <!-- 左侧分组列表 -->
+      <div class="voice-left-panel">
+        <div class="group-title">
+          <span>语音分组</span>
+          <el-button
+            type="primary"
+            size="mini"
+            icon="el-icon-plus"
+            circle
+            @click="handleAddGroup"
+          ></el-button>
+        </div>
+        <div class="group-list">
+          <div
+            class="group-item"
+            :class="{ active: currentGroupId === null }"
+            @click="selectGroup(null)"
+          >全部</div>
+          <div
+            v-for="item in groupList"
+            :key="item.id"
+            class="group-item"
+            :class="{ active: currentGroupId === item.id }"
+            @click="selectGroup(item.id)"
+          >
+            <span class="group-item-name">{{ item.voiceGroupName }}</span>
+            <i class="el-icon-edit group-item-edit" @click.stop="handleEditGroup(item)"></i>
+          </div>
+        </div>
+      </div>
+      <!-- 右侧内容区 -->
+      <div class="voice-right-panel">
+        <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="120px">
+          <el-form-item label="语音文本" prop="voiceTxt">
+            <el-input
+              v-model="queryParams.voiceTxt"
+              placeholder="请输入语音文本"
+              clearable
+              size="small"
+              @keyup.enter.native="handleQuery"
+            />
+          </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="primary"
+              plain
+              icon="el-icon-plus"
+              size="mini"
+              @click="handleAdd"
+              v-hasPermi="['qw:QwSopTempVoice:add']"
+            >新增</el-button>
+          </el-col>
+          <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+        </el-row>
+
+        <el-table border v-loading="loading" :data="QwSopTempVoiceList" @selection-change="handleSelectionChange">
+          <el-table-column type="selection" width="55" align="center" />
+          <el-table-column label="语音文本" align="center" prop="voiceTxt" />
+          <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+            <template slot-scope="scope">
+              <el-button
+                size="mini"
+                type="text"
+                icon="el-icon-delete"
+                @click="handleDelete(scope.row)"
+                v-hasPermi="['qw:QwSopTempVoice:remove']"
+              >删除</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"
+        />
+      </div>
+    </div>
+
+    <!-- 添加或修改模板对应的销售语音文件对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="语音分组" prop="groupId">
+          <el-select
+            v-model="form.groupId"
+            placeholder="请选择语音分组"
+            clearable
+            size="small"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in groupList"
+              :key="item.id"
+              :label="item.voiceGroupName"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="语音文本" prop="voiceTxt">
+          <el-input type="textarea" :rows="6" v-model="form.voiceTxt" placeholder="请输入语音文本"  />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script>
+import { listQwSopTempVoice, getQwSopTempVoice, delQwSopTempVoice, addQwSopTempVoice, updateQwSopTempVoice, exportQwSopTempVoice } from "@/api/qw/QwSopTempVoice";
+import { removeSidebar } from "@/api/qw/QwSopTempVoice";
+import { listQwSopTempVoiceGroup, addQwSopTempVoiceGroup, updateQwSopTempVoiceGroup } from "@/api/qw/QwSopTempVoiceGroup";
+
+export default {
+  name: "QwSopTempVoice",
+  data() {
+    return {
+      // 分组列表
+      groupList: [],
+      // 当前选中的分组ID,null表示全部
+      currentGroupId: null,
+
+      // 是否录制完成字典
+      recordTypeOptions: [
+        { dictLabel: '是', dictValue: '1' },
+        { dictLabel: '否', dictValue: '2' }
+      ],
+
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      ids: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 模板对应的销售语音文件表格数据
+      QwSopTempVoiceList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyUserId: null,
+        companyId: null,
+        qwUserId: null,
+        tempId: null,
+        ruleId: null,
+        dayId: null,
+        contentId: null,
+        groupId: null,
+        voiceTxt: null,
+        voiceUrl: null,
+        userVoiceUrl: null,
+        recordType: null,
+        duration: null,
+        status: null
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        groupId: [
+          { required: true, message: "请选择语音分组", trigger: "change" }
+        ]
+      }
+    };
+  },
+  created() {
+    this.getGroupList();
+    this.getList();
+  },
+  methods: {
+    /** 获取语音分组列表 */
+    getGroupList() {
+      listQwSopTempVoiceGroup({ pageNum: 1, pageSize: 999 }).then(response => {
+        this.groupList = response.rows;
+      });
+    },
+    /** 选择分组 */
+    selectGroup(groupId) {
+      this.currentGroupId = groupId;
+      this.queryParams.groupId = groupId;
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 新增分组 */
+    handleAddGroup() {
+      this.$prompt('请输入语音分组名称', '新增分组', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        inputPattern: /\S/,
+        inputErrorMessage: '分组名称不能为空'
+      }).then(({ value }) => {
+        addQwSopTempVoiceGroup({ voiceGroupName: value }).then(() => {
+          this.msgSuccess('新增成功');
+          this.getGroupList();
+        });
+      }).catch(() => {});
+    },
+    /** 修改分组名称 */
+    handleEditGroup(item) {
+      if (!item) return;
+      this.$prompt('请输入新的分组名称', '修改分组', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        inputPattern: /\S/,
+        inputErrorMessage: '分组名称不能为空',
+        inputValue: item.voiceGroupName
+      }).then(({ value }) => {
+        updateQwSopTempVoiceGroup({ id: item.id, voiceGroupName: value }).then(() => {
+          this.msgSuccess('修改成功');
+          this.getGroupList();
+        });
+      }).catch(() => {});
+    },
+    /** 查询模板对应的销售语音文件列表 */
+    getList() {
+      this.loading = true;
+      listQwSopTempVoice(this.queryParams).then(response => {
+        this.QwSopTempVoiceList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        companyUserId: null,
+        companyId: null,
+        qwUserId: null,
+        tempId: null,
+        ruleId: null,
+        dayId: null,
+        contentId: null,
+        groupId: null,
+        voiceTxt: null,
+        voiceUrl: null,
+        userVoiceUrl: null,
+        recordType: null,
+        duration: null,
+        createTime: null,
+        updateTime: null,
+        status: 0
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.id)
+      this.single = selection.length!==1
+      this.multiple = !selection.length
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加模板对应的销售语音文本";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id || this.ids
+      getQwSopTempVoice(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改模板对应的销售语音文本";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.id != null) {
+            updateQwSopTempVoice(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addQwSopTempVoice(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      this.$confirm('是否确认删除模板对应的销售语音文本---"' + row.voiceTxt + '"---的数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(function() {
+          return removeSidebar(row);
+        }).then(() => {
+          this.getList();
+          this.msgSuccess("删除成功");
+        }).catch(() => {});
+    },
+    /** 导出按钮操作 */
+    handleExport() {
+      const queryParams = this.queryParams;
+      this.$confirm('是否确认导出所有模板对应的销售语音文件数据项?', "警告", {
+          confirmButtonText: "确定",
+          cancelButtonText: "取消",
+          type: "warning"
+        }).then(() => {
+          this.exportLoading = true;
+          return exportQwSopTempVoice(queryParams);
+        }).then(response => {
+          this.download(response.msg);
+          this.exportLoading = false;
+        }).catch(() => {});
+    }
+  }
+};
+</script>
+
+<style scoped>
+.app-container {
+  position: relative;
+  height: calc(100vh - 150px);
+  overflow: hidden;
+  box-sizing: border-box;
+}
+.voice-layout {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  right: 20px;
+  bottom: 20px;
+  display: flex;
+}
+.voice-left-panel {
+  width: 200px;
+  min-width: 200px;
+  background: #fff;
+  border-right: 1px solid #e8e8e8;
+  display: flex;
+  flex-direction: column;
+}
+.group-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 16px;
+  font-weight: bold;
+  padding: 16px 16px 12px;
+  border-bottom: 1px solid #e8e8e8;
+  flex-shrink: 0;
+}
+.group-list {
+  padding: 8px 8px 0;
+  overflow-y: auto;
+  flex: 1;
+}
+.group-item {
+  padding: 10px 12px;
+  cursor: pointer;
+  border-radius: 4px;
+  font-size: 14px;
+  color: #333;
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.group-item-name {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.group-item-edit {
+  font-size: 13px;
+  color: #999;
+  flex-shrink: 0;
+  margin-left: 6px;
+}
+.group-item-edit:hover {
+  color: #1890ff;
+}
+.group-item:hover {
+  background: #f0f2f5;
+}
+.group-item.active {
+  background: #e6f7ff;
+  color: #1890ff;
+  font-weight: bold;
+}
+.voice-right-panel {
+  flex: 1;
+  padding-left: 16px;
+  min-width: 0;
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+</style>

+ 2 - 2
src/views/qw/qwUserVoiceLog/index.vue

@@ -501,8 +501,8 @@ export default {
     /** 查询企微用户通话记录列表 */
     getList() {
       this.loading = true;
-      const { qwUserName, ...queryParams } = this.queryParams;
-      listQwUserVoiceLog(queryParams).then(response => {
+      // const { qwUserName, ...queryParams } = this.queryParams;
+      listQwUserVoiceLog(this.queryParams).then(response => {
         this.qwUserVoiceLogList = response.rows;
         this.total = response.total;
         this.loading = false;

+ 29 - 2
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"
@@ -690,7 +697,7 @@
             <el-option
               v-for="chatId in sendMsgOpen.row && sendMsgOpen.row.chatId ? sendMsgOpen.row.chatId.split(',') : []"
               :key="chatId"
-              :label="chatList.filter(e => e.chatId == chatId) ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
+              :label="chatList.filter(e => e.chatId == chatId).length ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
               :value="chatId"
             />
           </el-select>
@@ -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 确保响应式更新

+ 29 - 2
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"
@@ -690,7 +697,7 @@
             <el-option
               v-for="chatId in sendMsgOpen.row && sendMsgOpen.row.chatId ? sendMsgOpen.row.chatId.split(',') : []"
               :key="chatId"
-              :label="chatList.filter(e => e.chatId == chatId) ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
+              :label="chatList.filter(e => e.chatId == chatId).length ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
               :value="chatId"
             />
           </el-select>
@@ -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) {

+ 29 - 2
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"
@@ -691,7 +698,7 @@
             <el-option
               v-for="chatId in sendMsgOpen.row && sendMsgOpen.row.chatId ? sendMsgOpen.row.chatId.split(',') : []"
               :key="chatId"
-              :label="chatList.filter(e => e.chatId == chatId) ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
+              :label="chatList.filter(e => e.chatId == chatId).length ? chatList.filter(e => e.chatId == chatId)[0].name : ''"
               :value="chatId"
             />
           </el-select>
@@ -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>

+ 11 - 10
src/views/qw/sopLogs/sopLogsList.vue

@@ -105,15 +105,15 @@
           @click="handleDelete"
           v-hasPermi="['qw:sopLogs:remove']"
         >批量删除</el-button>
-        <el-button
-          type="success"
-          plain
-          icon="el-icon-edit"
-          size="mini"
-          :disabled="multiple"
-          @click="handleEditCourse"
-          v-hasPermi="['qw:sopLogs:editCourse']"
-        >再次发送记录</el-button>
+<!--        <el-button-->
+<!--          type="success"-->
+<!--          plain-->
+<!--          icon="el-icon-edit"-->
+<!--          size="mini"-->
+<!--          :disabled="multiple"-->
+<!--          @click="handleEditCourse"-->
+<!--          v-hasPermi="['qw:sopLogs:editCourse']"-->
+<!--        >再次发送记录</el-button>-->
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
@@ -201,6 +201,7 @@
                 <span v-if="item.contentType == 23">APP看课跳转短链</span>
                 <span v-if="item.contentType == 20">APP直播</span>
                 <span v-if="item.contentType == 24">APP直播跳转短链</span>
+                <span v-if="item.contentType == 28">飞书自动看课</span>
               </div>
               <div v-if="item.sendStatus">
                 <span>发送状态:</span>
@@ -217,7 +218,7 @@
                 fit="contain"
                 @click.prevent="openImageViewer(item.imgUrl)" />
             </div>
-            <div v-if="item.contentType == 3 || item.contentType == 9" class="message-style">
+            <div v-if="item.contentType == 3 || item.contentType == 9 || item.contentType == 28" class="message-style">
               <div
                 style="background-color: white; padding: 10px; width: 100%"
                 @click="openLink(item.linkUrl)">

+ 70 - 14
src/views/qw/sopTemp/updateSopTemp.vue

@@ -215,6 +215,17 @@
                                   </el-radio-group>
                                 </el-form-item>
                                 <el-form-item label="直播间" v-if="content.type == 20">
+                                      <el-select  v-model="liveGroupTypeId"
+                                                  filterable
+                                                  placeholder="请选择直播间分组" clearable
+                                                  @change="liveGroupTypeChange" >
+                                        <el-option
+                                          v-for="dict in liveGroupTypeList"
+                                          :key="dict.id"
+                                          :label="dict.liveGroupType"
+                                          :value="dict.id"
+                                        />
+                                      </el-select>
                                       <el-select  v-model="content.liveId"
                                                   filterable
                                                   placeholder="请选择直播间" size="mini"
@@ -452,7 +463,7 @@
                                           </div>
 
                                           <div
-                                            v-if="(setList.contentType == 3 || item.contentType ==9) || (setList.contentType == 9 && content.type==2 )">
+                                            v-if="(setList.contentType == 3 || item.contentType ==9 || setList.contentType == 28) || (setList.contentType == 9 && content.type==2 )">
                                             <el-card class="box-card link-card">
                                               <div slot="header" class="card-header-mini">
                                                 <i class="el-icon-link"></i>
@@ -539,10 +550,23 @@
                                                 <i class="el-icon-video-camera"></i>
                                                 <span>直播小程序</span>
                                               </div>
+                                              <el-form-item label="直播间分组" label-width="120px">
+                                                <el-select  v-model="liveGroupTypeId"
+                                                            filterable
+                                                            placeholder="请选择直播间分组"  clearable
+                                                            @change="liveGroupTypeChange" >
+                                                  <el-option
+                                                    v-for="dict in liveGroupTypeList"
+                                                    :key="dict.id"
+                                                    :label="dict.liveGroupType"
+                                                    :value="dict.id"
+                                                  />
+                                                </el-select>
+                                              </el-form-item>
                                               <el-form-item label="直播间" prop="liveId"
                                                             :rules="[{ required: true, message: '请选择直播间', trigger: 'change' }]">
                                                 <el-select  v-model="setList.liveId"
-                                                            placeholder="请选择直播间" size="mini"
+                                                            placeholder="请选择直播间"
                                                             filterable
                                                             :disabled="content.type==20"
                                                             @change="liveChange(setList)" >
@@ -1827,7 +1851,7 @@ import {
   delRules
 } from "@/api/qw/sopTemp";
 import {courseList, videoList} from "@/api/qw/sop";
-import {listToLiveNoEnd} from "@/api/live/live";
+import {listToLiveNoEnd, listLiveGroupType} from "@/api/live/live";
 import ImageUpload from "@/views/qw/sop/ImageUpload";
 import userVideo from "@/views/qw/userVideo/userVideo.vue";
 import {listReward} from "@/api/qw/luckyBag";
@@ -1879,7 +1903,7 @@ export default {
       ruleList: [],
       ids: [],
       // startTimeRange: [],
-      courseTypeList: ['1','2', '4','5','6', '7','8','9','10','14','15','16','11','20'],
+      courseTypeList: ['1','2', '4','5','6', '7','8','9','10','14','15','16','11','20','28'],
       sysFsSopWatchStatus: [],
       //消息内容类型 企微版
       sysQwSopContentType: [],
@@ -1952,6 +1976,8 @@ export default {
         ],
       },
       liveList: [],
+      liveGroupTypeList: [],
+      liveGroupTypeId: null,
     };
   },
   created() {
@@ -1986,6 +2012,9 @@ export default {
 
     listToLiveNoEnd().then(response => {
       this.liveList = response.rows;
+    });
+    listLiveGroupType().then(response => {
+      this.liveGroupTypeList = response.rows || response.data || [];
     })
     listReward(this.queryParams1).then(response => {
       this.luckyBagList = response.rows;
@@ -2070,6 +2099,33 @@ export default {
         content.miniprogramPicUrl = '';
       }
     },
+    liveGroupTypeChange(val) {
+      if (val) {
+        listToLiveNoEnd({ liveGroupType: val }).then(response => {
+          this.liveList = response.rows;
+        });
+      } else {
+        listToLiveNoEnd().then(response => {
+          this.liveList = response.rows;
+        });
+      }
+      // 切换分组时清空已选的直播间
+      if (this.form && this.form.content) {
+        for (let i = 0; i < this.form.content.length; i++) {
+          const content = this.form.content[i];
+          if (content.liveId != null) {
+            content.liveId = null;
+          }
+          if (content.setting) {
+            for (let j = 0; j < content.setting.length; j++) {
+              if (content.setting[j].liveId != null) {
+                this.$set(content.setting[j], 'liveId', null);
+              }
+            }
+          }
+        }
+      }
+    },
     liveChangeContent(e){
       //循环修改当前content下面的直播间内容
       //并且禁用直播间编辑
@@ -2488,15 +2544,15 @@ export default {
                 this.$message.error("图片不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10 || data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkTitle == null || data.content[j].setting[k].linkTitle == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10 || data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23|| data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkTitle == null || data.content[j].setting[k].linkTitle == "")) {
                 this.$message.error("链接标题不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkDescribe == null || data.content[j].setting[k].linkDescribe == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23|| data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkDescribe == null || data.content[j].setting[k].linkDescribe == "")) {
                 this.$message.error("链接描述不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkImageUrl == null || data.content[j].setting[k].linkImageUrl == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23|| data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkImageUrl == null || data.content[j].setting[k].linkImageUrl == "")) {
                 this.$message.error("链接图片不能为空")
                 return false;
               }
@@ -2563,15 +2619,15 @@ export default {
                 this.$message.error("图片不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkTitle == null || set.linkTitle == "")) {
+              if ((set.contentType == 3 || set.contentType == 9|| set.contentType == 28) && (set.linkTitle == null || set.linkTitle == "")) {
                 this.$message.error("链接标题不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkDescribe == null || set.linkDescribe == "")) {
+              if ((set.contentType == 3 || set.contentType == 9|| set.contentType == 28) && (set.linkDescribe == null || set.linkDescribe == "")) {
                 this.$message.error("链接描述不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkImageUrl == null || set.linkImageUrl == "")) {
+              if ((set.contentType == 3 || set.contentType == 9|| set.contentType == 28) && (set.linkImageUrl == null || set.linkImageUrl == "")) {
                 this.$message.error("链接图片不能为空")
                 return false;
               }
@@ -2804,7 +2860,7 @@ export default {
         if (selectedCourse && content.type == 2 && content.courseId != null) {
           //响应式直接给链接的标题/封面上值
 
-          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
             this.$set(content.setting[i], 'linkTitle', selectedCourse.dictLabel);
             this.$set(content.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
           }
@@ -2967,7 +3023,7 @@ export default {
         for (let i = 0; i < content.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && content.type == 2 && content.courseId != null) {
-            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
               this.$set(content.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(content.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -2988,7 +3044,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo && content.type == 2 && content.videoId != null) {
 
-            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 ||  content.setting[i].contentType == 28 ) {
               this.$set(content.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -3032,7 +3088,7 @@ export default {
         //如果是链接的才上
         if (selectedVideo && content.type == 2 && content.videoId != null) {
 
-          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
             this.$set(content.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
             if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {

+ 70 - 50
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>
@@ -429,26 +418,26 @@
                                           </div>
 
                                           <div
-                                            v-if="(setList.contentType == 3 || item.contentType ==9) || (setList.contentType == 9 && content.type==2 )">
+                                            v-if="(setList.contentType == 3 || item.contentType ==9 || setList.contentType == 28) || (setList.contentType == 9 && content.type==2 )">
                                             <el-card class="box-card link-card">
                                               <div slot="header" class="card-header-mini">
                                                 <i class="el-icon-link"></i>
                                                 <span>链接内容</span>
                                               </div>
-                                              <el-form-item label="链接标题" label-width="100px" required>
+                                              <el-form-item label="链接标题" label-width="100px" prop="linkTitle" :rules="[{ required: true, message: '链接标题不能为空', trigger: ['blur', 'change'] }]">
                                                 <el-input :disabled="formType == 3 || (form.sendType == 11 && contentIndex != 0 && setIndex == 0)" v-model="setList.linkTitle"
                                                           @change="updateAll(setIndex, item, 'linkTitle', $event)"
                                                           placeholder="请输入链接标题"
                                                           style="width: 90%;"/>
                                               </el-form-item>
-                                              <el-form-item label="链接描述" label-width="100px" required>
+                                              <el-form-item label="链接描述" label-width="100px" prop="linkDescribe" :rules="[{ required: true, message: '链接描述不能为空', trigger: ['blur', 'change'] }]">
                                                 <el-input :disabled="formType == 3 || (form.sendType == 11 && contentIndex != 0 && setIndex == 0)" type="textarea" :rows="3"
                                                           v-model="setList.linkDescribe"
                                                           @change="updateAll(setIndex, item, 'linkDescribe', $event)"
                                                           placeholder="请输入链接描述"
                                                           style="width: 90%;margin-top: 1%;"/>
                                               </el-form-item>
-                                              <el-form-item label="链接封面" label-width="100px" required>
+                                              <el-form-item label="链接封面" label-width="100px" prop="linkImageUrl" :rules="[{ required: true, message: '链接封面不能为空', trigger: ['blur', 'change'] }]">
                                                 <ImageUpload :disabled="formType == 3 || (form.sendType == 11 && contentIndex != 0 && setIndex == 0)" v-model="setList.linkImageUrl"
                                                              type="image" :num="1"
                                                              @input="updateAll(setIndex, item, 'linkImageUrl', $event)"
@@ -456,7 +445,7 @@
                                                              style="margin-top: 1%;"/>
                                               </el-form-item>
                                               <div v-if="content.type != 2" class="link-url-section">
-                                                <el-form-item label="链接地址" label-width="100px" required>
+                                                <el-form-item label="链接地址" label-width="100px" prop="linkUrl" :rules="[{ required: true, message: '链接地址不能为空', trigger: ['blur', 'change'] }]">
                                                   <el-input :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')" v-model="setList.linkUrl"
                                                             placeholder="请输入链接地址"
                                                             style="width: 90%;"/>
@@ -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",
@@ -1842,7 +1832,7 @@ export default {
       ruleList: [],
       ids: [],
       // startTimeRange: [],
-      courseTypeList: ['1','2', '4','5','6', '7','8','9','10','14','15','16','11'],
+      courseTypeList: ['1','2', '4','5','6', '7','8','9','10','14','15','16','11','28'],
       sysFsSopWatchStatus: [],
       //消息内容类型 企微版
       sysQwSopContentType: [],
@@ -1890,7 +1880,7 @@ export default {
         loading: false,
         total: 0,
         pageNum: 1,
-        pageSize: 10,
+        pageSize: 5,
         currentSetList: null,
       },
       //1 修改 2 复制 3 查看
@@ -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("内容不能为空")
@@ -2462,15 +2482,15 @@ export default {
                 this.$message.error("图片不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10 || data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkTitle == null || data.content[j].setting[k].linkTitle == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10 || data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23 || data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkTitle == null || data.content[j].setting[k].linkTitle == "")) {
                 this.$message.error("链接标题不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkDescribe == null || data.content[j].setting[k].linkDescribe == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23 || data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkDescribe == null || data.content[j].setting[k].linkDescribe == "")) {
                 this.$message.error("链接描述不能为空")
                 return false;
               }
-              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23) && (data.content[j].setting[k].linkImageUrl == null || data.content[j].setting[k].linkImageUrl == "")) {
+              if ((data.content[j].setting[k].contentType == 3 || data.content[j].setting[k].contentType == 9 || data.content[j].setting[k].contentType == 10|| data.content[j].setting[k].contentType == 17|| data.content[j].setting[k].contentType == 23 || data.content[j].setting[k].contentType == 28) && (data.content[j].setting[k].linkImageUrl == null || data.content[j].setting[k].linkImageUrl == "")) {
                 this.$message.error("链接图片不能为空")
                 return false;
               }
@@ -2554,15 +2574,15 @@ export default {
                 this.$message.error("图片不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkTitle == null || set.linkTitle == "")) {
+              if ((set.contentType == 3 || set.contentType == 9 || set.contentType == 28) && (set.linkTitle == null || set.linkTitle == "")) {
                 this.$message.error("链接标题不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkDescribe == null || set.linkDescribe == "")) {
+              if ((set.contentType == 3 || set.contentType == 9 || set.contentType == 28) && (set.linkDescribe == null || set.linkDescribe == "")) {
                 this.$message.error("链接描述不能为空")
                 return false;
               }
-              if ((set.contentType == 3 || set.contentType == 9) && (set.linkImageUrl == null || set.linkImageUrl == "")) {
+              if ((set.contentType == 3 || set.contentType == 9 || set.contentType == 28) && (set.linkImageUrl == null || set.linkImageUrl == "")) {
                 this.$message.error("链接图片不能为空")
                 return false;
               }
@@ -2802,7 +2822,7 @@ export default {
         if (selectedCourse && content.type == 2 && content.courseId != null) {
           //响应式直接给链接的标题/封面上值
 
-          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
             this.$set(content.setting[i], 'linkTitle', selectedCourse.dictLabel);
             this.$set(content.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
           }
@@ -2965,7 +2985,7 @@ export default {
         for (let i = 0; i < content.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && content.type == 2 && content.courseId != null) {
-            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
               this.$set(content.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(content.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -2986,7 +3006,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo && content.type == 2 && content.videoId != null) {
 
-            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+            if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
               this.$set(content.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -3030,7 +3050,7 @@ export default {
         //如果是链接的才上
         if (selectedVideo && content.type == 2 && content.videoId != null) {
 
-          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9) {
+          if (content.setting[i].contentType == 3 || content.setting[i].contentType == 9 || content.setting[i].contentType == 28) {
             this.$set(content.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
             if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {

+ 192 - 81
src/views/qw/sopUserLogsInfo/sendMsgOpenTool.vue

@@ -11,16 +11,17 @@
                 </span>
         </template>
       </el-alert>
-      <el-form ref="msgForm" :model="msgForm" :rules="msgRules" label-width="100px">
+      <el-form ref="msgForm" :model="msgForm" :rules="msgRules">
         <el-form-item label="策略" prop="draftStrategy">
           <el-radio-group v-model="msgForm.draftStrategy">
             <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,17 @@
             />
           </el-select>
         </el-form-item>
-         <el-form-item label="选择直播间">
+         <el-form-item label="选择直播间" v-if="msgForm.draftStrategy!=4">
+             <el-select  v-model="liveGroupTypeId"
+                         placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+              </el-select>
              <el-select  v-model="msgForm.liveId"
                          filterable
                                       placeholder="请选择直播间" size="mini"
@@ -103,10 +114,23 @@
                   <el-form-item label="内容" style="margin-bottom: 2%" >
                     <div v-if="item.contentType == 12 || item.contentType == 18 || item.contentType == 19 || item.contentType == 24">
                       <el-card class="box-card">
+                        <el-form-item label="直播间分组" label-width="110px">
+                          <el-select  v-model="liveGroupTypeId"
+                                      filterable
+                                      placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+                          </el-select>
+                        </el-form-item>
                         <el-form-item label="直播间" >
                           <el-select  v-model="item.liveId"
                                       filterable
-                                      placeholder="请选择直播间" size="mini"
+                                      placeholder="请选择直播间"
                                       @change="liveChange(item)" :disabled="liveDisabled()">
                             <el-option
                               v-for="dict in liveList"
@@ -163,7 +187,7 @@
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
 
-                    <div v-if="item.contentType == 3 || item.contentType ==9 ">
+                    <div v-if="item.contentType == 3 || item.contentType ==9|| item.contentType ==28 ">
                       <el-card class="box-card">
                         <el-form-item label="链接标题:"  label-width="100px">
                           <el-input v-model="item.linkTitle" placeholder="请输入链接标题" style="width: 90%;"/>
@@ -312,9 +336,23 @@
                     </div>
                     <div v-if="item.contentType == 20">
                       <el-card class="box-card">
+                        <el-form-item label="直播间分组" label-width="110px">
+                          <el-select  v-model="liveGroupTypeId"
+                                      filterable
+                                      placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+                          </el-select>
+                        </el-form-item>
                         <el-form-item label="直播间" >
                           <el-select  v-model="item.liveId"
-                                      placeholder="请选择直播间" size="mini"
+                                      filterable
+                                      placeholder="请选择直播间"
                                       @change="liveChange(item)" >
                             <el-option
                               v-for="dict in liveList"
@@ -425,7 +463,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"
@@ -480,10 +530,11 @@ import { sendMsgSopType,} from "../../../api/qw/sopUserLogsInfo";
 import ImageUpload from "@/views/qw/sop/ImageUpload.vue";
 import {courseList, videoList} from "@/api/qw/sop";
 import userVideo from "@/views/qw/userVideo/userVideo";
-import {listToLiveNoEnd} from "@/api/live/live";
+import {listToLiveNoEnd, listLiveGroupType} 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 {
@@ -514,7 +565,7 @@ export default {
         loading: false,
         total: 0,
         pageNum: 1,
-        pageSize: 10,
+        pageSize: 5,
         currentItem: null,
       },
       //上传语音的遮罩层
@@ -577,6 +628,7 @@ export default {
         ids:null,
       },
       smsTemplateList: [],
+      aiSipCallTaskList: [],
       // 表单参数
       form: {},
       setting:[{contentType:'1', value: '',}],
@@ -604,6 +656,8 @@ export default {
       },
       msgRules:{},
       liveList: [],
+      liveGroupTypeList: [],
+      liveGroupTypeId: null,
       pickerOptions: {
             shortcuts: [
                 {
@@ -664,12 +718,44 @@ export default {
     listToLiveNoEnd().then(response => {
       this.liveList = response.rows;
     })
+    listLiveGroupType().then(response => {
+      this.liveGroupTypeList = response.rows || response.data || [];
+    })
     listReward(this.queryParams1).then(response => {
       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) {
@@ -697,6 +783,25 @@ export default {
         content.miniprogramPicUrl = '';
       }
     },
+    liveGroupTypeChange(val) {
+      if (val) {
+        listToLiveNoEnd({ liveGroupType: val }).then(response => {
+          this.liveList = response.rows;
+        });
+      } else {
+        listToLiveNoEnd().then(response => {
+          this.liveList = response.rows;
+        });
+      }
+      // 切换分组时清空已选的直播间
+      if (this.msgForm.liveId) {
+        this.msgForm.liveId = null;
+      }
+      // 清空规则中的直播间
+      for (let i = 0; i < this.setting.length; i++) {
+        this.$set(this.setting[i], 'liveId', null);
+      }
+    },
     qwUserVideoResult(val) {
 
       // 根据选中的内容,将返回的数据更新到相应的表单项
@@ -728,7 +833,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && this.msgForm.courseId != null) {
-            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -755,7 +860,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的描述上值
           if (selectedVideo && this.msgForm.videoId != null) {
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -986,7 +1091,7 @@ export default {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse  && this.msgForm.courseId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -1005,7 +1110,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo  && this.msgForm.videoId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -1030,6 +1135,7 @@ export default {
     },
 
     resetSendMsgSop() {
+      this.liveGroupTypeId = null;
       this.msgForm = {
         liveId:null,
         videoId:null,
@@ -1100,7 +1206,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 +1243,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].contentType == 28) && (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].contentType == 28) && (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].contentType == 28) && (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].contentType == 28) && 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 || '');
+                            }
+                        }
+                    }
                 }
-              }
             }
 
             // // 短信模板数据映射
@@ -1251,8 +1364,6 @@ export default {
       this.resetSendMsgSop();
     },
     liveChangemsgForm(liveId){
-      console.log(liveId);
-      console.log(this.setting);
       if(!!liveId){
         for(let i =0;i<this.setting.length;i++){
            this.$set(this.setting[i], 'liveId', liveId);

+ 187 - 59
src/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue

@@ -11,16 +11,17 @@
                 </span>
         </template>
       </el-alert>
-      <el-form ref="msgForm" :model="msgForm" :rules="msgRules" label-width="100px">
+      <el-form ref="msgForm" :model="msgForm" :rules="msgRules">
         <el-form-item label="策略" prop="draftStrategy">
           <el-radio-group v-model="msgForm.draftStrategy">
             <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"
@@ -46,6 +47,30 @@
             />
           </el-select>
         </el-form-item>
+          <el-form-item label="选择直播间" v-if="msgForm.draftStrategy!=4">
+              <el-select  v-model="liveGroupTypeId"
+                          placeholder="请选择直播间分组"  clearable
+                          @change="liveGroupTypeChange" >
+                  <el-option
+                      v-for="dict in liveGroupTypeList"
+                      :key="dict.id"
+                      :label="dict.liveGroupType"
+                      :value="dict.id"
+                  />
+              </el-select>
+              <el-select  v-model="msgForm.liveId"
+                          filterable
+                          placeholder="请选择直播间" size="mini"
+                          style=" margin-right: 10px;"
+                          @change="liveChangemsgForm(msgForm.liveId)" clearable >
+                  <el-option
+                      v-for="dict in liveList"
+                      :key="dict.liveId"
+                      :label="dict.liveName"
+                      :value="dict.liveId"
+                  />
+              </el-select>
+          </el-form-item>
         <el-form-item label="规则" prop="setting" v-if="msgForm.draftStrategy==1"  >
           <div v-for="(item, index) in setting" :key="index" style="background-color: #fdfdfd; border: 1px solid #e6e6e6; margin-bottom: 20px;">
             <el-row>
@@ -88,7 +113,7 @@
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
 
-                    <div v-if="item.contentType == 3 || item.contentType ==9 ">
+                    <div v-if="item.contentType == 3 || item.contentType ==9|| item.contentType ==28 ">
                       <el-card class="box-card">
                         <el-form-item label="链接标题:"  label-width="100px">
                           <el-input v-model="item.linkTitle" placeholder="请输入链接标题" style="width: 90%;"/>
@@ -205,12 +230,25 @@
                       </el-card>
                     </div>
 
-                    <div v-if="item.contentType == 12 || item.contentType == 18 || item.contentType == 19 || item.contentType == 24">
+                    <div v-if="item.contentType == 12 || item.contentType == 18 || item.contentType == 19 || item.contentType == 24 || item.contentType == 20">
                       <el-card class="box-card">
+                        <el-form-item label="直播间分组" label-width="110px">
+                          <el-select  v-model="liveGroupTypeId"
+                                      filterable
+                                      placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+                          </el-select>
+                        </el-form-item>
                         <el-form-item label="直播间" >
                           <el-select  v-model="item.liveId"
                                       filterable
-                                      placeholder="请选择直播间" size="mini"
+                                      placeholder="请选择直播间"
                                       @change="liveChange(item)" >
                             <el-option
                               v-for="dict in liveList"
@@ -300,7 +338,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"
@@ -356,8 +406,9 @@ import ImageUpload from "@/views/qw/sop/ImageUpload.vue";
 import {courseList, videoList} from "@/api/qw/sop";
 import userVideo from "@/views/qw/userVideo/userVideo";
 import {listReward} from "@/api/qw/luckyBag";
-import {listToLiveNoEnd} from "@/api/live/live";
+import {listToLiveNoEnd, listLiveGroupType} from "@/api/live/live";
 import { getQwSopTempVoiceCompanyUser } from "@/api/qw/QwSopTempVoice";
+import { listAiSipCallTask } from '@/api/aiSipCall/aiSipCallTask'
 
 
 export default {
@@ -365,6 +416,7 @@ export default {
   components: {ImageUpload,userVideo},
   data() {
     return {
+      aiSipCallTaskList: [],
       queryParams1: {
         pageNum: 1,
         pageSize: 10,
@@ -388,7 +440,7 @@ export default {
         loading: false,
         total: 0,
         pageNum: 1,
-        pageSize: 10,
+        pageSize: 5,
         currentItem: null,
       },
       //上传语音的遮罩层
@@ -446,6 +498,8 @@ export default {
       sysQwSopAiContentType:[],
 
       liveList: [],
+      liveGroupTypeList: [],
+      liveGroupTypeId: null,
       sendMsgOpen:{
         title:'一键批量群发',
         open:false,
@@ -532,6 +586,9 @@ export default {
     listToLiveNoEnd().then(response => {
       this.liveList = response.rows;
     });
+    listLiveGroupType().then(response => {
+      this.liveGroupTypeList = response.rows || response.data || [];
+    });
 
     courseList().then(response => {
       this.courseList = response.list;
@@ -539,8 +596,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) {
@@ -565,6 +651,26 @@ export default {
       }
     },
 
+    liveGroupTypeChange(val) {
+      if (val) {
+        listToLiveNoEnd({ liveGroupType: val }).then(response => {
+          this.liveList = response.rows;
+        });
+      } else {
+        listToLiveNoEnd().then(response => {
+          this.liveList = response.rows;
+        });
+      }
+      // 切换分组时清空已选的直播间
+      if (this.msgForm.liveId) {
+        this.msgForm.liveId = null;
+      }
+      // 清空规则中的直播间
+      for (let i = 0; i < this.setting.length; i++) {
+        this.$set(this.setting[i], 'liveId', null);
+      }
+    },
+
     qwUserVideoResult(val) {
 
       // 根据选中的内容,将返回的数据更新到相应的表单项
@@ -597,7 +703,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && this.msgForm.courseId != null) {
-            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -625,7 +731,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的描述上值
           if (selectedVideo && this.msgForm.videoId != null) {
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -857,7 +963,7 @@ export default {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse  && this.msgForm.courseId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
 
@@ -882,7 +988,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo  && this.msgForm.videoId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -908,6 +1014,7 @@ export default {
     },
 
     resetSendMsgSop() {
+      this.liveGroupTypeId = null;
       this.msgForm = {
         videoId:null,
         courseId:null,
@@ -930,7 +1037,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 +1075,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].contentType == 28) && (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].contentType == 28) && (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].contentType == 28) && (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].contentType == 28) && 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("直播间不能为空");
+                    }
+                }
             }
           }
 
@@ -1045,6 +1160,19 @@ export default {
       this.sendMsgOpen.open = false;
       this.resetSendMsgSop();
     },
+
+      liveChangemsgForm(liveId){
+          if(!!liveId){
+              for(let i =0;i<this.setting.length;i++){
+                  this.$set(this.setting[i], 'liveId', liveId);
+                  this.liveChange(this.setting[i]);
+              }
+          }
+      },
+      liveDisabled(){
+          return !!this.msgForm && !!this.msgForm.liveId ;
+      },
+
     openVoiceDialog(item) {
       this.voiceOptions.open = true;
       this.voiceOptions.currentItem = item;

+ 75 - 13
src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetails.vue

@@ -269,6 +269,16 @@
 
         </el-form-item>
         <el-form-item label="选择直播间">
+             <el-select  v-model="liveGroupTypeId"
+                         placeholder="请选择直播间分组" clearable
+                          @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+              </el-select>
              <el-select  v-model="msgForm.liveId"
                          filterable
                                       placeholder="请选择直播间" size="mini"
@@ -332,7 +342,7 @@
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
 
-                    <div v-if="item.contentType == 3 || item.contentType ==9 ">
+                    <div v-if="item.contentType == 3 || item.contentType ==9 || item.contentType ==28 ">
                       <el-card class="box-card">
                         <el-form-item label="链接标题:"  label-width="100px">
                           <el-input v-model="item.linkTitle" placeholder="请输入链接标题" style="width: 90%;"/>
@@ -411,10 +421,23 @@
                     <div v-if="item.contentType == 12 || item.contentType == 24">
                       <!--                                           <div >-->
                       <el-card class="box-card">
+                        <el-form-item label="直播间分组" label-width="120px">
+                          <el-select  v-model="liveGroupTypeId"
+                                      filterable
+                                      placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+                          </el-select>
+                        </el-form-item>
                         <el-form-item label="直播间" >
                           <el-select  v-model="item.liveId"
                                       filterable
-                                      placeholder="请选择直播间" size="mini"
+                                      placeholder="请选择直播间"
                                       @change="liveChange(item)" :disabled="liveDisabled()" >
                             <el-option
                               v-for="dict in liveList"
@@ -442,9 +465,23 @@
                     </div>
                     <div v-if="item.contentType == 20">
                       <el-card class="box-card">
+                        <el-form-item label="直播间分组" label-width="120px">
+                          <el-select  v-model="liveGroupTypeId"
+                                      filterable
+                                      placeholder="请选择直播间分组" clearable
+                                      @change="liveGroupTypeChange" >
+                            <el-option
+                              v-for="dict in liveGroupTypeList"
+                              :key="dict.id"
+                              :label="dict.liveGroupType"
+                              :value="dict.id"
+                            />
+                          </el-select>
+                        </el-form-item>
                         <el-form-item label="直播间" >
                           <el-select  v-model="item.liveId"
-                                      placeholder="请选择直播间" size="mini"
+                                      filterable
+                                      placeholder="请选择直播间"
                                       @change="liveChange(item)" >
                             <el-option
                               v-for="dict in liveList"
@@ -705,7 +742,7 @@ import {listTag} from "@/api/qw/tag";
 import {searchTags} from "../../../api/qw/tag";
 import userVideo from "@/views/qw/userVideo/userVideo.vue";
 import PaginationMore from "../../../components/PaginationMore/index.vue";
-import {listToLiveNoEnd} from "@/api/live/live";
+import {listToLiveNoEnd, listLiveGroupType} from "@/api/live/live";
 import {listReward} from "@/api/qw/luckyBag";
 import { getSmsTempList } from "@/api/company/companySmsTemp";
 import { getQwSopTempVoiceCompanyUser } from "@/api/qw/QwSopTempVoice";
@@ -758,7 +795,7 @@ export default {
         loading: false,
         total: 0,
         pageNum: 1,
-        pageSize: 10,
+        pageSize: 5,
         currentItem: null,
       },
       // sopUserLogsInfo表格数据
@@ -849,6 +886,8 @@ export default {
       },
       msgRules:{},
       liveList: [],
+      liveGroupTypeList: [],
+      liveGroupTypeId: null,
     };
   },
 
@@ -878,6 +917,9 @@ export default {
     listToLiveNoEnd().then(response => {
       this.liveList = response.rows;
     });
+    listLiveGroupType().then(response => {
+      this.liveGroupTypeList = response.rows || response.data || [];
+    });
     listReward(this.queryParams1).then(response => {
       this.luckyBagList = response.rows;
     });
@@ -912,6 +954,25 @@ export default {
         content.miniprogramPicUrl = '';
       }
     },
+    liveGroupTypeChange(val) {
+      if (val) {
+        listToLiveNoEnd({ liveGroupType: val }).then(response => {
+          this.liveList = response.rows;
+        });
+      } else {
+        listToLiveNoEnd().then(response => {
+          this.liveList = response.rows;
+        });
+      }
+      // 切换分组时清空已选的直播间
+      if (this.msgForm.liveId) {
+        this.msgForm.liveId = null;
+      }
+      // 清空规则中的直播间
+      for (let i = 0; i < this.setting.length; i++) {
+        this.$set(this.setting[i], 'liveId', null);
+      }
+    },
     selectSopUserLogsInfo(val,externalUserName){
 
       this.loading = true;
@@ -1077,7 +1138,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && this.msgForm.courseId != null) {
-            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -1104,7 +1165,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的描述上值
           if (selectedVideo && this.msgForm.videoId != null) {
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.projectFrom == 'sxjz' && selectedVideo.dictImgUrl) {
@@ -1328,7 +1389,7 @@ export default {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse  && this.msgForm.courseId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -1349,7 +1410,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo  && this.msgForm.videoId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
 
               if (this.msgForm.videoId != null) {
@@ -1416,6 +1477,7 @@ export default {
 
 
     resetSendMsgSop() {
+      this.liveGroupTypeId = null;
       this.msgForm = {
         liveId:null,
         // liveMsgType:null,
@@ -1582,16 +1644,16 @@ export default {
             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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28 ) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28 ) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28 ) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28)&& this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
               return this.$message.error("链接地址不能为空")
             }
 

+ 9 - 9
src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetailsOld.vue

@@ -301,7 +301,7 @@
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
 
-                    <div v-if="item.contentType == 3 || item.contentType ==9 ">
+                    <div v-if="item.contentType == 3 || item.contentType ==9 || item.contentType ==28 ">
                       <el-card class="box-card">
                         <el-form-item label="链接标题:"  label-width="100px">
                           <el-input v-model="item.linkTitle" placeholder="请输入链接标题" style="width: 90%;"/>
@@ -795,7 +795,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse && this.msgForm.courseId != null) {
-            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if ( this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28 ){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -822,7 +822,7 @@ export default {
         for (let i = 0; i < this.setting.length; i++) {
           //响应式直接给链接的描述上值
           if (selectedVideo && this.msgForm.videoId != null) {
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9 ){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
             }
 
@@ -1037,7 +1037,7 @@ export default {
           //响应式直接给链接的标题/封面上值
           if (selectedCourse  && this.msgForm.courseId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkTitle', selectedCourse.dictLabel);
               this.$set(this.setting[i], 'linkImageUrl', selectedCourse.dictImgUrl);
             }
@@ -1059,7 +1059,7 @@ export default {
           //响应式直接给链接的描述上值
           if (selectedVideo  && this.msgForm.videoId != null) {
 
-            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9){
+            if (this.setting[i].contentType == 3 || this.setting[i].contentType == 9|| this.setting[i].contentType == 28){
               this.$set(this.setting[i], 'linkDescribe', selectedVideo.dictLabel);
             }
             if (this.setting[i].contentType == 4 || this.setting[i].contentType == 10){
@@ -1222,16 +1222,16 @@ export default {
             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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28 ) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28) && (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 == "")) {
+            if ((this.setting[i].contentType == 3 || this.setting[i].contentType == 9 || this.setting[i].contentType == 28)&& this.setting[i].type == 1 && (this.setting[i].linkUrl == null || this.setting[i].linkUrl == "")) {
               return this.$message.error("链接地址不能为空")
             }
 

+ 0 - 1
src/views/qw/user/cuDeptIdIndex.vue

@@ -207,7 +207,6 @@
         <template slot-scope="scope">
 
           <el-button
-            v-if="scope.row.appKey==null"
             size="mini"
             type="text"
             icon="el-icon-s-check"

+ 0 - 1
src/views/qw/user/index.vue

@@ -274,7 +274,6 @@
         <template slot-scope="scope">
 
           <el-button
-            v-if="scope.row.appKey==null"
             size="mini"
             type="text"
             icon="el-icon-s-check"

+ 0 - 1
src/views/qw/user/myIndex.vue

@@ -261,7 +261,6 @@
         <template slot-scope="scope">
 
           <el-button
-            v-if="scope.row.appKey==null"
             size="mini"
             type="text"
             icon="el-icon-s-check"

+ 203 - 203
src/views/qw/user/qwUserList.vue

@@ -1,61 +1,61 @@
 <template>
-  <div class="app-container">
-    <el-alert v-if="type==2 && (sendType==2||sendType==4||sendType==11)"
-      title="注意事项"
-      type="warning"
-      description="只显示含有【允许使用插件】的成员"
-      :closable="false"
-      center
-      show-icon>
-    </el-alert>
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px" @submit.prevent="handleQuery">
-
-      <el-form-item label="后台员工昵称" prop="nickName">
-        <el-input
-          v-model="queryParams.nickName"
-          placeholder="请输入后台员工昵称"
-          clearable
-          size="small"
-          @input="handleQuery"
+    <div class="app-container">
+        <el-alert v-if="type==2 && (sendType==2||sendType==4||sendType==11)"
+                  title="注意事项"
+                  type="warning"
+                  description="只显示含有【允许使用插件】的成员"
+                  :closable="false"
+                  center
+                  show-icon>
+        </el-alert>
+        <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px" @submit.prevent="handleQuery">
+
+            <el-form-item label="后台员工昵称" prop="nickName">
+                <el-input
+                    v-model="queryParams.nickName"
+                    placeholder="请输入后台员工昵称"
+                    clearable
+                    size="small"
+                    @keydown.enter.native="handleQueryEnter"
+                />
+            </el-form-item>
+            <el-form-item label="企微员工部门" prop="deptName">
+                <el-input
+                    v-model="queryParams.deptName"
+                    placeholder="请输入企微员工部门"
+                    clearable
+                    size="small"
+                    @keydown.enter.native="handleQueryEnter"
+                />
+            </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-table v-loading="loading" :data="userList" ref="userList"  @selection-change="handleSelectionChange">
+            <el-table-column type="selection" width="55" align="center" />
+            <el-table-column label="企微员工账号" align="center" prop="qwUserId" />
+            <el-table-column label="企微员工昵称" align="center" prop="qwUserName"/>
+            <el-table-column label="企微员工部门" align="center" prop="departmentName"/>
+            <el-table-column label="后台员工昵称" align="center" prop="nickName"/>
+            <el-table-column label="后台员工用户名" align="center" prop="userName" />
+
+        </el-table>
+        <div style="margin-top: 30px;display: flex;justify-content: center">
+            <el-button type="warning" icon="el-icon-search" @click="confirmSelect">确定选择</el-button>
+        </div>
+
+        <pagination
+            v-show="total>0"
+            :total="total"
+            :page.sync="queryParams.pageNum"
+            :limit.sync="queryParams.pageSize"
+            @pagination="handlePaginationChange"
         />
-      </el-form-item>
-      <el-form-item label="企微员工部门" prop="deptName">
-        <el-input
-          v-model="queryParams.deptName"
-          placeholder="请输入企微员工部门"
-          clearable
-          size="small"
-          @keydown.enter.native="handleQueryEnter"
-        />
-      </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-table v-loading="loading" :data="userList" ref="userList"  @selection-change="handleSelectionChange">
-        <el-table-column type="selection" width="55" align="center" />
-        <el-table-column label="企微员工账号" align="center" prop="qwUserId" />
-        <el-table-column label="企微员工昵称" align="center" prop="qwUserName"/>
-        <el-table-column label="企微员工部门" align="center" prop="departmentName"/>
-        <el-table-column label="后台员工昵称" align="center" prop="nickName"/>
-        <el-table-column label="后台员工用户名" align="center" prop="userName" />
-
-    </el-table>
-    <div style="margin-top: 30px;display: flex;justify-content: center">
-      <el-button type="warning" icon="el-icon-search" @click="confirmSelect">确定选择</el-button>
     </div>
-
-    <pagination
-      v-show="total>0"
-      :total="total"
-      :page.sync="queryParams.pageNum"
-      :limit.sync="queryParams.pageSize"
-      @pagination="handlePaginationChange"
-    />
-  </div>
 </template>
 
 <script>
@@ -63,156 +63,156 @@ import { listUser, getUser, delUser, addUser, updateUser, exportUser } from "@/a
 
 
 export default {
-  name: "qwUserList",
-  data() {
-    return {
-      type:null,
-      sendType:null,
-      // 遮罩层
-      loading: true,
-      // 导出遮罩层
-      exportLoading: false,
-      // 选中数组
-      selectUsers: [],
-      // 非单个禁用
-      single: true,
-      // 非多个禁用
-      multiple: true,
-      // 显示搜索条件
-      showSearch: true,
-      // 总条数
-      total: 0,
-      // 企微用户表格数据
-      userList: [],
-      // 弹出层标题
-      title: "",
-      // 是否显示弹出层
-      open: false,
-      // 查询参数
-      queryParams: {
-        pageNum: 1,
-        pageSize: 10,
-        qwUserId: null,
-        companyId: null,
-        companyUserId: null,
-        deptName: null,
-        corpId: null,
-        nickName: null
-      },
-      // 表单参数
-      form: {},
-
-      // 表单校验
-      rules: {
-      }
-    };
-  },
-  created() {
-
-  },
-  methods: {
-
-    getDetails(corpId,type,sendType,isRemark){
-      this.type=type;
-      this.sendType=sendType;
-      if (type!=null&&sendType!=null){
-        this.queryParams.type=type;
-        this.queryParams.sendType=sendType;
-      }
-      this.queryParams.corpId=corpId;
-      this.queryParams.isRemark=isRemark;
-      this.getList();
-    },
-    /** 查询企微用户列表 */
-    getList() {
-      this.loading = true;
-      listUser(this.queryParams).then(response => {
-        // 如果 companyUserId 为 null,移除列
-
-        this.userList = response.rows;
-        this.total =   response.total;
-        this.loading = false;
-
-      });
-    },
-    handlePaginationChange(row) {
-      this.queryParams.pageNum = row.page;
-      this.queryParams.pageSize = row.limit;
-      this.getList();
-    },
-    // 取消按钮
-    cancel() {
-      this.open = false;
-      this.reset();
-    },
-    // 表单重置
-    reset() {
-      this.form = {
-        id: null,
-        qwUserId: null,
-        companyId: null,
-        companyUserId: null,
-        corpId: null
-      };
-      this.resetForm("form");
-    },
-    //确定选择
-    confirmSelect(){
-      this.$emit("selectUserList",this.selectUsers);
-      this.resetSelect();
+    name: "qwUserList",
+    data() {
+        return {
+            type:null,
+            sendType:null,
+            // 遮罩层
+            loading: true,
+            // 导出遮罩层
+            exportLoading: false,
+            // 选中数组
+            selectUsers: [],
+            // 非单个禁用
+            single: true,
+            // 非多个禁用
+            multiple: true,
+            // 显示搜索条件
+            showSearch: true,
+            // 总条数
+            total: 0,
+            // 企微用户表格数据
+            userList: [],
+            // 弹出层标题
+            title: "",
+            // 是否显示弹出层
+            open: false,
+            // 查询参数
+            queryParams: {
+                pageNum: 1,
+                pageSize: 10,
+                qwUserId: null,
+                companyId: null,
+                companyUserId: null,
+                deptName: null,
+                corpId: null,
+                nickName: null
+            },
+            // 表单参数
+            form: {},
+
+            // 表单校验
+            rules: {
+            }
+        };
     },
-    //重置选择
-    resetSelect(){
-      this.$refs.userList.clearSelection();
-
-      this.selectUsers=[];
-      //重置
-      this.queryParams={
-        pageNum: 1,
-        pageSize: 5,
-        qwUserId: null,
-        companyId: null,
-        companyUserId: null,
-        corpId: null,
-        nickName: null
-      },
-        this.getList();
-    },
-    /** 搜索按钮操作 */
-    handleQuery() {
-      this.queryParams.pageNum = 1;
-      this.getList();
-    },
-    handleQueryEnter(event){
-      // 确保事件对象存在
-      if (event && event.preventDefault) {
-        event.preventDefault(); // 阻止默认提交行为
-      }
-      this.handleQuery();
-    },
-    /** 重置按钮操作 */
-    resetQuery() {
-      this.resetForm("queryForm");
-      this.handleQuery();
-    },
-
-    // 多选框选中数据
-    handleSelectionChange(selection) {
-
-      // 保存当前页的选中项
-      const currentPageSelections = selection.map(item => item.id);
-
-      // 合并选中项
-      this.selectUsers = this.selectUsers.filter(item =>
-        this.userList.some(tag => tag.id === item.id) ? currentPageSelections.includes(item.id) : true
-      ).concat(selection.filter(item => !this.selectUsers.some(selected => selected.id === item.id)));
-
-      // 更新 single 和 multiple
-      this.single = this.selectUsers.length !== 1;
-      this.multiple = !this.selectUsers.length;
+    created() {
 
     },
-
-  }
+    methods: {
+
+        getDetails(corpId,type,sendType,isRemark){
+            this.type=type;
+            this.sendType=sendType;
+            if (type!=null&&sendType!=null){
+                this.queryParams.type=type;
+                this.queryParams.sendType=sendType;
+            }
+            this.queryParams.corpId=corpId;
+            this.queryParams.isRemark=isRemark;
+            this.getList();
+        },
+        /** 查询企微用户列表 */
+        getList() {
+            this.loading = true;
+            listUser(this.queryParams).then(response => {
+                // 如果 companyUserId 为 null,移除列
+
+                this.userList = response.rows;
+                this.total =   response.total;
+                this.loading = false;
+
+            });
+        },
+        handlePaginationChange(row) {
+            this.queryParams.pageNum = row.page;
+            this.queryParams.pageSize = row.limit;
+            this.getList();
+        },
+        // 取消按钮
+        cancel() {
+            this.open = false;
+            this.reset();
+        },
+        // 表单重置
+        reset() {
+            this.form = {
+                id: null,
+                qwUserId: null,
+                companyId: null,
+                companyUserId: null,
+                corpId: null
+            };
+            this.resetForm("form");
+        },
+        //确定选择
+        confirmSelect(){
+            this.$emit("selectUserList",this.selectUsers);
+            this.resetSelect();
+        },
+        //重置选择
+        resetSelect(){
+            this.$refs.userList.clearSelection();
+
+            this.selectUsers=[];
+            //重置
+            this.queryParams={
+                pageNum: 1,
+                pageSize: 5,
+                qwUserId: null,
+                companyId: null,
+                companyUserId: null,
+                corpId: null,
+                nickName: null
+            },
+                this.getList();
+        },
+        /** 搜索按钮操作 */
+        handleQuery() {
+            this.queryParams.pageNum = 1;
+            this.getList();
+        },
+        handleQueryEnter(event){
+            // 确保事件对象存在
+            if (event && event.preventDefault) {
+                event.preventDefault(); // 阻止默认提交行为
+            }
+            this.handleQuery();
+        },
+        /** 重置按钮操作 */
+        resetQuery() {
+            this.resetForm("queryForm");
+            this.handleQuery();
+        },
+
+        // 多选框选中数据
+        handleSelectionChange(selection) {
+
+            // 保存当前页的选中项
+            const currentPageSelections = selection.map(item => item.id);
+
+            // 合并选中项
+            this.selectUsers = this.selectUsers.filter(item =>
+                this.userList.some(tag => tag.id === item.id) ? currentPageSelections.includes(item.id) : true
+            ).concat(selection.filter(item => !this.selectUsers.some(selected => selected.id === item.id)));
+
+            // 更新 single 和 multiple
+            this.single = this.selectUsers.length !== 1;
+            this.multiple = !this.selectUsers.length;
+
+        },
+
+    }
 };
 </script>

+ 248 - 0
src/views/qw/user/qwUserListWay.vue

@@ -0,0 +1,248 @@
+<template>
+  <div class="app-container">
+    <el-alert v-if="type==2 && (sendType==2||sendType==4||sendType==11)"
+      title="注意事项"
+      type="warning"
+      description="只显示含有【允许使用插件】的成员"
+      :closable="false"
+      center
+      show-icon>
+    </el-alert>
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px" @submit.prevent="handleQuery">
+
+      <el-form-item label="后台员工昵称" prop="nickName">
+        <el-input
+          v-model="queryParams.nickName"
+          placeholder="请输入后台员工昵称"
+          clearable
+          size="small"
+          @input="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="企微员工部门" prop="deptName">
+        <el-input
+          v-model="queryParams.deptName"
+          placeholder="请输入企微员工部门"
+          clearable
+          size="small"
+          @keydown.enter.native="handleQueryEnter"
+        />
+      </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-table v-loading="loading" :data="userList" ref="userList"  @selection-change="handleSelectionChange" :row-class-name="tableRowClassName">
+        <el-table-column type="selection" width="55" align="center" />
+        <el-table-column label="企微员工账号" align="center" prop="qwUserId" />
+        <el-table-column label="企微员工昵称" align="center" prop="qwUserName"/>
+        <el-table-column label="企微员工部门" align="center" prop="departmentName"/>
+        <el-table-column label="后台员工昵称" align="center" prop="nickName"/>
+        <el-table-column label="后台员工用户名" align="center" prop="userName" />
+        <el-table-column label="操作" align="center" width="80">
+          <template slot-scope="scope">
+            <el-button v-if="isSelectedUser(scope.row.qwUserId)" type="text" size="mini" @click="handleRemoveUser(scope.row)" style="color: #f56c6c;">移除</el-button>
+          </template>
+        </el-table-column>
+    </el-table>
+    <div style="margin-top: 30px;display: flex;justify-content: center">
+      <el-button type="warning" icon="el-icon-search" @click="confirmSelect">确定选择</el-button>
+    </div>
+
+    <pagination
+      v-show="total>0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="handlePaginationChange"
+    />
+  </div>
+</template>
+
+<script>
+import { listUser, getUser, delUser, addUser, updateUser, exportUser } from "@/api/qw/user";
+
+
+export default {
+  name: "qwUserList",
+  data() {
+    return {
+      type:null,
+      sendType:null,
+      // 遮罩层
+      loading: true,
+      // 导出遮罩层
+      exportLoading: false,
+      // 选中数组
+      selectUsers: [],
+      // 已选中的员工列表(用于标记)
+      selectedUserIds: [],
+      // 非单个禁用
+      single: true,
+      // 非多个禁用
+      multiple: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 企微用户表格数据
+      userList: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 查询参数
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        qwUserId: null,
+        companyId: null,
+        companyUserId: null,
+        deptName: null,
+        corpId: null,
+        nickName: null
+      },
+      // 表单参数
+      form: {},
+
+      // 表单校验
+      rules: {
+      }
+    };
+  },
+  created() {
+
+  },
+  methods: {
+
+    getDetails(corpId,type,sendType,isRemark, selectedUserIds){
+      this.type=type;
+      this.sendType=sendType;
+      this.selectedUserIds = selectedUserIds || [];
+      if (type!=null&&sendType!=null){
+        this.queryParams.type=type;
+        this.queryParams.sendType=sendType;
+      }
+      this.queryParams.corpId=corpId;
+      this.queryParams.isRemark=isRemark;
+      this.getList();
+    },
+    /** 查询企微用户列表 */
+    getList() {
+      this.loading = true;
+      listUser(this.queryParams).then(response => {
+        // 如果 companyUserId 为 null,移除列
+
+        this.userList = response.rows;
+        this.total =   response.total;
+        this.loading = false;
+
+      });
+    },
+    handlePaginationChange(row) {
+      this.queryParams.pageNum = row.page;
+      this.queryParams.pageSize = row.limit;
+      this.getList();
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        id: null,
+        qwUserId: null,
+        companyId: null,
+        companyUserId: null,
+        corpId: null
+      };
+      this.resetForm("form");
+    },
+    //确定选择
+    confirmSelect(){
+      this.$emit("selectUserList",this.selectUsers);
+      this.resetSelect();
+    },
+    //重置选择
+    resetSelect(){
+      this.$refs.userList.clearSelection();
+
+      this.selectUsers=[];
+      //重置
+      this.queryParams={
+        pageNum: 1,
+        pageSize: 5,
+        qwUserId: null,
+        companyId: null,
+        companyUserId: null,
+        corpId: null,
+        nickName: null
+      },
+        this.getList();
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    handleQueryEnter(event){
+      // 确保事件对象存在
+      if (event && event.preventDefault) {
+        event.preventDefault(); // 阻止默认提交行为
+      }
+      this.handleQuery();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+
+    // 判断员工是否已选中
+    isSelectedUser(qwUserId) {
+      return this.selectedUserIds.includes(qwUserId);
+    },
+    // 表格行样式
+    tableRowClassName({row}) {
+      return this.isSelectedUser(row.qwUserId) ? 'user-selected-row' : '';
+    },
+    // 移除已选中员工
+    handleRemoveUser(user) {
+      const index = this.selectedUserIds.indexOf(user.qwUserId);
+      if (index !== -1) {
+        this.selectedUserIds.splice(index, 1);
+        this.$emit("removeUser", user);
+      }
+    },
+    // 多选框选中数据
+    handleSelectionChange(selection) {
+
+      // 保存当前页的选中项
+      const currentPageSelections = selection.map(item => item.id);
+
+      // 合并选中项
+      this.selectUsers = this.selectUsers.filter(item =>
+        this.userList.some(tag => tag.id === item.id) ? currentPageSelections.includes(item.id) : true
+      ).concat(selection.filter(item => !this.selectUsers.some(selected => selected.id === item.id)));
+
+      // 更新 single 和 multiple
+      this.single = this.selectUsers.length !== 1;
+      this.multiple = !this.selectUsers.length;
+
+    },
+
+  }
+};
+</script>
+
+<style scoped>
+::v-deep .user-selected-row {
+  color: #67c23a !important;
+  font-weight: bold !important;
+}
+</style>

+ 211 - 0
src/views/qw/welcome/welComeAddAIVoice.vue

@@ -0,0 +1,211 @@
+<template>
+  <div style="display: flex;justify-content: normal;width: 100%">
+    <div style="width: 1000px">
+      <!-- 添加或修改入群欢迎语管理对话框 -->
+        <el-form ref="form" :model="form" :rules="rules" label-width="160px" size="medium">
+          <el-form-item label="AI外呼客服" prop="aiSopCallRoleId">
+            <el-select
+              v-model="form.aiSopCallRoleId"
+              placeholder="请选择AI外呼客服"
+              clearable
+              size="small"
+              @change="handleWorkflowIdOptionsChange">
+              <el-option
+                v-for="item in aiSopCallRoleOptions"
+                :key="item.id"
+                :label="item.callRoleName"
+                :value="item.id">
+              </el-option>
+            </el-select>
+          </el-form-item>
+          <el-form-item label="是否定时呼叫" prop="isTimerSend">
+            <el-radio-group v-model="form.isTimerSend" size="medium">
+              <el-radio label="1">定时呼叫</el-radio>
+              <el-radio label="2">立即呼叫</el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <el-form-item v-if="form.isTimerSend === '1'" prop="timerSend">
+            <el-date-picker
+              v-model="form.timerSend"
+              type="datetime"
+              placeholder="选择定时呼叫时间"
+              value-format="yyyy-MM-dd HH:mm:ss"
+              default-time="09:00:00">
+            </el-date-picker>
+          </el-form-item>
+
+        </el-form>
+
+    </div>
+
+  </div>
+</template>
+
+<script>
+import ImageUpload from "@/views/qw/material/ImageUpload";
+import {listRole} from "../../../api/aiSopCall/aiSopCallRole";
+
+export default {
+  name: "Welcome",
+  components: {ImageUpload},
+
+  data() {
+    return {
+      //上传地址
+      uploadUrl:process.env.VUE_APP_BASE_API+"/common/uploadOSS",
+      //放大图片
+      dialogImageUrl:null,
+      dialogVisible:false,
+      // 遮罩层
+      loading: true,
+      //群发文本类型(复用)
+      groupMsgTextTypeOptions:[],
+      //是否允许成员在待发送列表中再次选择
+      allowSelectOptions:[],
+      formData:null,
+
+      //呼叫ai客服角色
+      aiSopCallRoleOptions: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+
+      details:{
+        title:"",
+        open:false,
+      },
+
+      // 表单参数
+      form: {
+        aiSopCallRoleId:null,
+        aiSopCallRoleName:null,
+        allowSelect: '1',
+        msgType:'1',
+        fileUrl:null,
+        videoUrl: null,
+        isTimerSend:'2'
+      },
+      // 表单校验
+      rules: {
+        aiSopCallRoleId:[
+          {required:true,message:"请选择AI外呼客服",trigger:"blur"}
+        ],
+      }
+    };
+  },
+  created() {
+
+    //文本类型字典
+    this.getDicts("sys_qw_welcome_type").then(response => {
+      this.groupMsgTextTypeOptions = response.data;
+    });
+
+    // 修改调用方式,添加错误处理
+    listRole().then(response => {
+      this.aiSopCallRoleOptions = response.rows;
+    });
+
+    //是否允许成员在待发送列表中再次选择
+    this.getDicts("sys_qw_allow_select").then(response => {
+      this.allowSelectOptions = response.data;
+    });
+
+  },
+  methods: {
+    // 监听 AI 客服角色选择变化
+    handleWorkflowIdOptionsChange(value) {
+      if (value) {
+        const selectedOption = this.aiSopCallRoleOptions.find(item => item.id === value);
+        if (selectedOption) {
+          this.form.aiSopCallRoleName = selectedOption.callRoleName;
+        }
+      } else {
+        this.form.aiSopCallRoleName = null;
+      }
+    },
+    /** 表单验证 */
+    async validateChildForm() {
+      return new Promise((resolve, reject) => {
+        this.$refs.form.validate(async valid => {
+          if (valid) {
+            resolve(true);
+          } else {
+            console.error("表单验证失败");
+            reject(false);
+          }
+        });
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        // sender: null,
+        // gender:99,
+        allowSelect: '1',
+        textContent: null,
+        msgid: null,
+        // corpId: null,
+        // companyId: null,
+        // tagNames: null,
+        // groupChatNamesList: [],
+        // fromUserCount: null,
+        // toUserCount: null,
+        // fromUserNum: null,
+        // toUserNum: null,
+        // createTime: null,
+        // createTime:null,
+        // updateTime: null,
+        timerSend:'',
+        isTimerSend:'2',
+        msgType:'1',
+        // //选择的成员账号
+        // userIdsSelectList:[],
+        // //选择的群聊
+        // groupChatSelectList:[],
+        // //选择的时间
+        // timeScreenCharge:[],
+
+        // //全部客户还是部分客户
+        // groupMsgSelectType:'1',
+
+        // //选择的标签列表
+        // tagsIdsSelectList:[],
+        //
+        // //排除的标签
+        // outTagsIdsSelectList:[],
+
+        imageMediaId: null,
+        imagePicUrl: null,
+        linkTitle: null,
+        linkPicurl: null,
+        linkDesc: null,
+        linkUrl: null,
+        miniprogramTitle: null,
+        miniprogramPicMediaId: null,
+        miniprogramPicUrl: null,
+        miniprogramAppid: null,
+        miniprogramPage: null,
+        fileMediaId: null,
+        fileUrl: null,
+        videoMediaId: null,
+        videoUrl:null,
+      };
+      this.resetForm("form");
+    },
+  }
+};
+</script>
+<style scoped>
+.flex-container > div {
+  width: 100%; /* 确保每个子div宽度充满容器宽度 */
+}
+
+
+</style>

+ 86 - 2
src/views/store/components/userDetailsTemp.vue

@@ -19,7 +19,15 @@
           </el-image>
         </el-descriptions-item>
         <el-descriptions-item label="手机号码" >
-          <span v-if="item!=null">{{item.phone}}</span>
+          <template v-if="item!=null">
+            <span v-if="item.phone" style="margin-right: 8px;">{{item.phone}}</span>
+            <el-button
+                type="primary"
+                size="mini"
+                v-hasPermi="['his:user:bindPhNum']"
+                @click="openBindPhone"
+            >{{ item.phone ? '修改手机号' : '绑定手机号' }}</el-button>
+          </template>
         </el-descriptions-item>
         <el-descriptions-item label="用户积分" >
           <span v-if="item!=null">{{item.integral}}</span>
@@ -156,12 +164,31 @@
 
 
     </el-dialog>
+
+    <!-- 绑定手机号弹窗 -->
+    <el-dialog
+      :title="item && item.phone ? '修改手机号' : '绑定手机号'"
+      :visible.sync="bindPhoneVisible"
+      width="420px"
+      append-to-body
+      @close="resetBindPhoneForm"
+    >
+      <el-form ref="bindPhoneForm" :model="bindPhoneForm" :rules="bindPhoneRules" label-width="90px">
+        <el-form-item label="手机号码" prop="phNum">
+          <el-input v-model="bindPhoneForm.phNum" placeholder="请输入手机号码" maxlength="11" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="bindPhoneVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="bindPhoneLoading" @click="submitBindPhone">确 定</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { listPatient, getPatient, delPatient, addPatient, updatePatient, exportPatient ,getPatientByUserId} from "@/api/store/patient";
-import { listUser, getUser, delUser, addUser, updateUser, exportUser ,getUserAddr} from "@/api/store/user";
+import { listUser, getUser, delUser, addUser, updateUser, exportUser ,getUserAddr, bindPhNum} from "@/api/store/user";
 import { getListUserCoupon } from "@/api/store/userCoupon";
 import userStorerDetails from "../components/userStorerDetails.vue";
 import userPatietDetails from "../components/userPatietDetails.vue";
@@ -199,6 +226,22 @@ export default {
       // 积分购相关
       integralPurchaseVisible: false,
       currentUserId: null,
+      // 绑定手机号
+      bindPhoneVisible: false,
+      bindPhoneLoading: false,
+      bindPhoneForm: {
+        phNum: ''
+      },
+      bindPhoneRules: {
+        phNum: [
+          { required: true, message: '请输入手机号码', trigger: 'blur' },
+          {
+            pattern: /^1[3-9]\d{9}$/,
+            message: '请输入正确的手机号码',
+            trigger: 'blur'
+          }
+        ]
+      },
       // 会员优惠券表格数据
       userCouponList: [],
       queryParams: {
@@ -251,6 +294,47 @@ export default {
       this.currentUserId = null;
     },
 
+    openBindPhone() {
+      this.resetBindPhoneForm();
+      this.bindPhoneVisible = true;
+    },
+
+    resetBindPhoneForm() {
+      this.bindPhoneForm = { phNum: '' };
+      this.bindPhoneLoading = false;
+      if (this.$refs.bindPhoneForm) {
+        this.$refs.bindPhoneForm.clearValidate();
+      }
+    },
+
+    submitBindPhone() {
+      this.$refs.bindPhoneForm.validate(valid => {
+        if (!valid) return;
+        if (!this.item || !this.item.userId) {
+          this.$message.warning('无法获取会员信息');
+          return;
+        }
+        this.bindPhoneLoading = true;
+        bindPhNum({
+          phNum: Number(this.bindPhoneForm.phNum),
+          fsUserId: this.item.userId
+        }).then(res => {
+          if (res.code === 200) {
+            this.msgSuccess(res.msg || '操作成功');
+            this.bindPhoneVisible = false;
+            this.item.phone = this.bindPhoneForm.phNum;
+            this.getDetails(this.item.userId);
+          } else {
+            this.msgError(res.msg || '操作失败');
+          }
+        }).catch(() => {
+          // 错误信息由请求拦截器处理
+        }).finally(() => {
+          this.bindPhoneLoading = false;
+        });
+      });
+    },
+
     handleClickX(tab, event) {
       if(tab.name=="10"){
         this.queryParams.status=null;