Преглед изворни кода

Merge remote-tracking branch 'origin/saas-ui' into saas-ui

吴树波 пре 1 недеља
родитељ
комит
3d3b26863c

+ 26 - 0
src/api/company/callphone.js

@@ -97,6 +97,32 @@ export function exportDetailPhone(query) {
     })
 }
 
+// AI外呼记录明细列表
+export function listCallphoneDetail(query) {
+    return request({
+        url: '/company/callphoneLog/detailList',
+        method: 'get',
+        params: query
+    })
+}
+
+// AI外呼记录明细统计
+export function getCallphoneDetailSummary(query) {
+    return request({
+        url: '/company/callphoneLog/detailSummary',
+        method: 'get',
+        params: query
+    })
+}
+// 同步AI外呼数据(异步补偿)
+export function syncAiCallData() {
+    return request({
+        url: '/company/callphoneLog/syncAiCallData',
+        method: 'post'
+    })
+}
+
+
 // // 查询调用日志_ai打电话详细
 // export function getCallphone(logId) {
 //   return request({

+ 8 - 0
src/api/company/companyVoiceRobotic.js

@@ -24,6 +24,14 @@ export function listAll(query) {
     params: query
   })
 }
+// 外呼任务下拉(分页 + 名称搜索)
+export function listRoboticSelectOptions(query) {
+  return request({
+    url: '/company/companyVoiceRobotic/selectOptions',
+    method: 'get',
+    params: query
+  })
+}
 // 查询机器人外呼任务列表
 export function calleesList(query) {
   return request({

+ 8 - 0
src/api/company/tagBinding.js

@@ -132,4 +132,12 @@ export const tagBindingApi = {
       data
     })
   },
+    // 批量取消龙虾标签
+  batchUnbindLobsterTag: (data) => {
+    return request({
+      url: '/workflow/tagBinding/tag-binding/batch-unbind-lobster-tag',
+      method: 'post',
+      data
+    })
+  },
 }

+ 9 - 0
src/api/crm/customerAll.js

@@ -15,3 +15,12 @@ export function recoverAll(data) {
     data: data
   })
 }
+
+export function batchRecoverAll(data) {
+  return request({
+    url: '/crm/customerAll/batchRecover',
+    method: 'post',
+    data: data
+  })
+}
+

+ 16 - 0
src/api/workflow/lobster.js

@@ -83,6 +83,22 @@ export function getAvailableChannels() {
   return request({ url: '/workflow/lobster/engine/channels', method: 'get' })
 }
 
+export function getNodeCapabilities() {
+  return request({ url: '/workflow/lobster/engine/node-capabilities', method: 'get' })
+}
+
+export function runIntegrationTests() {
+  return request({ url: '/workflow/lobster/engine/integration-test/run', method: 'post' })
+}
+
+export function testNodeTypes() {
+  return request({ url: '/workflow/lobster/engine/integration-test/node-types', method: 'get' })
+}
+
+export function testDynamicExecutor() {
+  return request({ url: '/workflow/lobster/engine/integration-test/dynamic-executor', method: 'get' })
+}
+
 // ======== 提示词管理 ========
 export function listPrompts(params) {
   return request({ url: '/workflow/lobster/prompt/list', method: 'get', params })

+ 75 - 0
src/router/index.js

@@ -367,6 +367,15 @@ export const constantRoutes = [
                     title: '外呼统计',
                     activeMenu: '/callLogStatistics/callLog'
                 }
+            },
+            {
+                path: 'detail',
+                component: () => import('@/views/taskStatistics/callLog/callLogDetail'),
+                name: 'callLogDetail',
+                meta: {
+                    title: 'AI外呼记录明细',
+                    activeMenu: '/callLogStatistics/callLog'
+                }
             }
         ]
     },
@@ -641,6 +650,72 @@ export const constantRoutes = [
             meta: { title: '引擎看板', activeMenu: '/lobster/dashboard' }
         }]
     },
+    {
+        path: '/lobster/evolution',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/evolution/index'),
+            name: 'LobsterEvolution',
+            meta: { title: 'AI进化引擎', activeMenu: '/lobster/evolution' }
+        }]
+    },
+    {
+        path: '/lobster/node-capabilities',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/node-capabilities/index'),
+            name: 'LobsterNodeCapabilities',
+            meta: { title: '节点能力矩阵', activeMenu: '/lobster/node-capabilities' }
+        }]
+    },
+    {
+        path: '/lobster/ab-test',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/ab-test/index'),
+            name: 'LobsterAbTest',
+            meta: { title: 'A/B测试', activeMenu: '/lobster/ab-test' }
+        }]
+    },
+    {
+        path: '/lobster/pay-manage',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/pay-manage/index'),
+            name: 'LobsterPayManage',
+            meta: { title: '支付管理', activeMenu: '/lobster/pay-manage' }
+        }]
+    },
+    {
+        path: '/lobster/channel-config',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/channel-config/index'),
+            name: 'LobsterChannelConfig',
+            meta: { title: '渠道配置', activeMenu: '/lobster/channel-config' }
+        }]
+    },
+    {
+        path: '/lobster/integration-test',
+        component: Layout,
+        hidden: true,
+        children: [{
+            path: '',
+            component: () => import('@/views/lobster/integration-test/index'),
+            name: 'LobsterIntegrationTest',
+            meta: { title: '集成测试', activeMenu: '/lobster/integration-test' }
+        }]
+    },
 
 ]
 

+ 15 - 6
src/views/company/companyWorkflow/design.vue

@@ -790,7 +790,8 @@ export default {
       voiceSourceOptions: [
         { label: '阿里云TTS', value: 'aliyun_tts' },
         { label: '豆包语音', value: 'doubao_vcl_tts' },
-        { label: '电信语音', value: 'chinatelecom_tts' }
+        { label: '电信语音', value: 'chinatelecom_tts' },
+        { label: '科大讯飞', value: 'xf_tts' }
       ],
     }
   },
@@ -869,13 +870,21 @@ export default {
       }
       this.handleConfigChange()
     },
+    /** 根据音色来源过滤音色列表,未选择来源时展示全部 */
+    applyVoiceSourceFilter() {
+      const voiceSource = this.selectedNode && this.selectedNode.nodeConfig
+        ? this.selectedNode.nodeConfig.voiceSource
+        : null
+      if (voiceSource) {
+        this.easyCallVoiceCodeList = this.allEasyCallVoiceCodeList.filter(e => e.voiceSource == voiceSource)
+      } else {
+        this.easyCallVoiceCodeList = this.allEasyCallVoiceCodeList
+      }
+    },
     /** 处理配置变化,强制更新视图 */
     handleConfigChange(v) {
       if( !!v && v === "handleVoiceSource" ){
-        let voiceSource = this.selectedNode.nodeConfig.voiceSource;
-
-        this.easyCallVoiceCodeList = this.allEasyCallVoiceCodeList.filter(e => e.voiceSource == voiceSource);
-
+        this.applyVoiceSourceFilter()
         this.selectedNode.nodeConfig.voiceCode = null;
         this.selectedNode.nodeConfig.ttsModels = null;
       }
@@ -971,7 +980,7 @@ export default {
         })
         getVoiceCodeList().then(res => {
           this.allEasyCallVoiceCodeList = res.data || []
-          this.easyCallVoiceCodeList = res.data || []
+          this.applyVoiceSourceFilter()
         })
         getBusiGroupList().then(res => {
           this.easyCallBusiGroupList = res.data || []

+ 10 - 0
src/views/crm/components/CustomerSelect.vue

@@ -92,6 +92,12 @@
             size="small"
             @keyup.enter.native="handleQuery"
           />
+        </el-form-item>
+         <el-form-item label="AI接通筛选" prop="aiConnectFilter">
+          <el-select v-model="aiConnectFilter" placeholder="请选择AI接通筛选" clearable size="small">
+            <el-option label="今日AI未接通" value="todayNotConnected" />
+            <el-option label="累计AI未接通" value="totalNotConnected" />
+          </el-select>
         </el-form-item>
         <el-form-item>
           <el-button type="cyan" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -250,6 +256,7 @@ export default {
       tags:[],
       tagIds:[],
       customTag: null,
+      aiConnectFilter: null,
       inputVisible: false,
       inputValue: '',
       statusOptions:[],
@@ -417,6 +424,8 @@ export default {
         params.tags = null;
       }
       params.customTag = this.customTag || null;
+      
+      params.aiConnectFilter = this.aiConnectFilter || null;
       return params;
     },
     getRowKeys(item){
@@ -508,6 +517,7 @@ export default {
       this.sourceArr = [];
       this.tagIds = [];
       this.customTag = null;
+      this.aiConnectFilter = null;
       if (!this.designatedCompany) {
         this.queryParams.companyId = null;
       }

+ 40 - 3
src/views/crm/customer/customerAll.vue

@@ -115,10 +115,21 @@
       </el-form>
 
       <el-row :gutter="10" class="mb8">
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+      <el-col :span="1.5">
+          <el-button
+            type="warning"
+            icon="el-icon-s-release"
+            size="mini"
+            :disabled="multiple"
+            @click="handleBatchRecover"
+            v-hasPermi="['crm:customer:recover']"
+          >批量回收公海</el-button>
+        </el-col>
+        <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
       </el-row>
 
-      <el-table height="500" border v-loading="loading" :data="customerList">
+      <el-table height="500" border v-loading="loading" :data="customerList"  @selection-change="handleSelectionChange">
+        <el-table-column type="selection" width="55" align="center" :selectable="canSelect" />
         <el-table-column label="客户编码" align="center" prop="customerCode" width="110" />
         <el-table-column label="客户名称" align="center" prop="customerName" :show-overflow-tooltip="true" min-width="100">
           <template slot-scope="scope">
@@ -186,7 +197,7 @@
 </template>
 
 <script>
-import { getCustomerAllList,recoverAll } from "@/api/crm/customerAll";
+import { getCustomerAllList,recoverAll, batchRecoverAll } from "@/api/crm/customerAll";
 import { getMyCustomerPhone,getTradeDicts } from "@/api/crm/customer";
 import customerDetails from '../components/customerDetails.vue';
 import customerCallLogList from '../components/customerCallLogList.vue';
@@ -216,6 +227,8 @@ export default {
       },
       loading: true,
       showSearch: true,
+      multiple: true,
+      customerUserIds: [],
       total: 0,
       customerList: [],
       queryParams: {
@@ -322,6 +335,13 @@ export default {
         return "";
       }
       return customerName;
+    },
+     canSelect(row) {
+      return !!row.customerUserId;
+    },
+    handleSelectionChange(selection) {
+      this.customerUserIds = selection.map(item => item.customerUserId);
+      this.multiple = !selection.length;
     },
     handleRecover(row) {
       this.$confirm('是否确认回收客户"' + row.customerName + '"?', "警告", {
@@ -335,6 +355,23 @@ export default {
         this.getList();
         this.msgSuccess("操作成功");
       }).catch(function() {});
+    },
+    handleBatchRecover() {
+      const count = this.customerUserIds.length;
+      if (count === 0) {
+        this.$message.warning("请选择要回收的客户");
+        return;
+      }
+      this.$confirm('是否确认批量回收选中的' + count + '个客户到公海?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(() => {
+        return batchRecoverAll({ customerUserIds: this.customerUserIds });
+      }).then((response) => {
+        this.getList();
+        this.msgSuccess(response.msg || "操作成功");
+      }).catch(() => {});
     }
   }
 };

+ 36 - 1
src/views/crm/customer/manualOutboundCallLog.vue

@@ -29,6 +29,17 @@
             <el-option label="成功" :value="2" />
             <el-option label="失败" :value="3" />
           </el-select>
+        </el-form-item>
+         <el-form-item label="呼叫时间" prop="callDateRange">
+          <el-date-picker
+            v-model="callDateRange"
+            type="daterange"
+            value-format="yyyy-MM-dd"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            size="small"
+            style="width: 240px"
+          ></el-date-picker>
         </el-form-item>
         <el-form-item label="创建时间" prop="dateRange">
           <el-date-picker
@@ -54,9 +65,17 @@
 
       <!-- 表格区域 -->
       <el-table border v-loading="loading" :data="list">
-        <el-table-column label="客户号码" align="center" prop="callerNum" width="140">
+        <el-table-column label="客户号码" align="center" prop="callerNum" width="180">
           <template slot-scope="scope">
             <span>{{ desensitizePhone(scope.row.callerNum) }}</span>
+             <el-button
+              v-if="scope.row.customerId"
+              type="text"
+              v-hasPermi="['crm:customer:queryPhone']"
+              size="mini"
+              icon="el-icon-view"
+              @click="handleQueryPhone(scope.row)"
+            >查看</el-button>
           </template>
         </el-table-column>
         <el-table-column label="坐席号码" align="center" prop="calleeNum" width="140" />
@@ -140,6 +159,7 @@
 
 <script>
 import { listManualOutboundCallLog, sumBillingMinute } from "@/api/crm/manualOutboundCallLog";
+import { queryPhone } from "@/api/crm/customer";
 import { parseTime } from "@/utils/common";
 import { checkPermi } from "@/utils/permission";
 
@@ -155,6 +175,7 @@ export default {
       list: [],
       // 日期范围
       dateRange: [],
+      callDateRange: [],
       // 计费分钟合计
       sumBillingMinute: null,
       // 对话记录弹窗
@@ -211,6 +232,13 @@ export default {
       this.contentDialog.content = row.contentList || '';
       this.contentDialog.visible = true;
     },
+    handleQueryPhone(row) {
+      queryPhone(row.customerId).then(response => {
+        this.$alert(response.mobile, '手机号', {
+          confirmButtonText: '确定'
+        });
+      });
+    },
     parseContentList(content) {
       if (!content) return [];
       try {
@@ -236,6 +264,11 @@ export default {
         params.beginTime = this.dateRange[0];
         params.endTime = this.dateRange[1];
       }
+      
+      if (this.callDateRange && this.callDateRange.length === 2) {
+        params.callBeginTime = this.callDateRange[0];
+        params.callEndTime = this.callDateRange[1];
+      }
       // 前端输入秒,直接传秒给后端,由SQL用CEILING(call_time/1000)匹配
       listManualOutboundCallLog(params).then(response => {
         this.list = response.rows;
@@ -255,6 +288,8 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.dateRange = [];
+      
+      this.callDateRange = [];
       this.resetForm("queryForm");
       this.handleQuery();
     }

+ 19 - 0
src/views/crm/customer/myManualOutboundCallLog.vue

@@ -29,6 +29,17 @@
             <el-option label="成功" :value="2" />
             <el-option label="失败" :value="3" />
           </el-select>
+        </el-form-item>
+         <el-form-item label="呼叫时间" prop="callDateRange">
+          <el-date-picker
+            v-model="callDateRange"
+            type="daterange"
+            value-format="yyyy-MM-dd"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            size="small"
+            style="width: 240px"
+          ></el-date-picker>
         </el-form-item>
         <el-form-item label="创建时间" prop="dateRange">
           <el-date-picker
@@ -155,6 +166,8 @@ export default {
       list: [],
       // 日期范围
       dateRange: [],
+      
+      callDateRange: [],
       // 计费分钟合计
       sumBillingMinute: null,
       // 对话记录弹窗
@@ -236,6 +249,11 @@ export default {
         params.beginTime = this.dateRange[0];
         params.endTime = this.dateRange[1];
       }
+      
+      if (this.callDateRange && this.callDateRange.length === 2) {
+        params.callBeginTime = this.callDateRange[0];
+        params.callEndTime = this.callDateRange[1];
+      }
       // 前端输入秒,直接传秒给后端,由SQL用CEILING(call_time/1000)匹配
       listMyManualOutboundCallLog(params).then(response => {
         this.list = response.rows;
@@ -255,6 +273,7 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.dateRange = [];
+      this.callDateRange = [];
       this.resetForm("queryForm");
       this.handleQuery();
     }

+ 30 - 22
src/views/lobster/channel-config/index.vue

@@ -1,35 +1,43 @@
 <template>
   <div class="app-container">
     <el-table border :data="channels" v-loading="loading">
-      <el-table-column prop="channelType" label="渠道编码" width="120"/>
+      <el-table-column prop="channelType" label="渠道编码" width="140"/>
       <el-table-column prop="displayName" label="渠道名称"/>
-      <el-table-column prop="enabled" label="状态" width="80"><template slot-scope="s"><el-tag :type="s.row.enabled?'success':'danger'" size="small">{{ s.row.enabled?'已启用':'已禁用' }}</el-tag></template></el-table-column>
-      <el-table-column label="消息通道" width="100"><template slot-scope="s"><el-tag :type="s.row.hasChannel?'success':'info'" size="small">{{ s.row.hasChannel?'已对接':'待对接' }}</el-tag></template></el-table-column>
-      <el-table-column label="联系人适配" width="100"><template slot-scope="s"><el-tag :type="s.row.hasAdapter?'success':'info'" size="small">{{ s.row.hasAdapter?'已对接':'待对接' }}</el-tag></template></el-table-column>
-      <el-table-column label="操作" width="120"><template slot-scope="s"><el-switch v-model="s.row.enabled" @change="toggle(s.row)" active-text="开" inactive-text="关"/></template></el-table-column>
+      <el-table-column prop="available" label="可用" width="80">
+        <template slot-scope="s"><el-tag :type="s.row.available?'success':'info'" size="small">{{ s.row.available?'可用':'不可用' }}</el-tag></template>
+      </el-table-column>
+      <el-table-column prop="enabled" label="启用" width="80">
+        <template slot-scope="s"><el-tag :type="s.row.enabled?'success':'danger'" size="small">{{ s.row.enabled?'已启用':'已禁用' }}</el-tag></template>
+      </el-table-column>
     </el-table>
   </div>
 </template>
 <script>
-const CHANNELS = [
-  { channelType:'WECHAT', displayName:'微信客服', enabled:true, hasChannel:true, hasAdapter:true },
-  { channelType:'QW', displayName:'企微助手', enabled:true, hasChannel:true, hasAdapter:true },
-  { channelType:'IM', displayName:'APP内IM', enabled:true, hasChannel:true, hasAdapter:true },
-  { channelType:'TMALL', displayName:'天猫', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'JD', displayName:'京东', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'DOUYIN_DM', displayName:'抖音私信', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'DOUYIN_EC', displayName:'抖音电商', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'KUAISHOU_DM', displayName:'快手私信', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'XIAOHONGSHU_DM', displayName:'小红书', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'LINE', displayName:'Line', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'TELEGRAM', displayName:'Telegram', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'APP_IM', displayName:'APP私信', enabled:false, hasChannel:true, hasAdapter:true },
-  { channelType:'WHATSAPP', displayName:'WhatsApp', enabled:false, hasChannel:true, hasAdapter:true }
-]
+import { getAvailableChannels } from '@/api/workflow/lobster'
+const DISPLAY_NAMES = {
+  WX: '微信客服', QW: '企微助手', IM: 'APP内IM', SMS: '短信', EMAIL: '邮件',
+  TMALL: '天猫', JD: '京东', DOUYIN_DM: '抖音私信', DOUYIN_EC: '抖音电商',
+  KUAISHOU_DM: '快手私信', XIAOHONGSHU_DM: '小红书', LINE: 'Line',
+  TELEGRAM: 'Telegram', APP_IM: 'APP私信', WHATSAPP: 'WhatsApp'
+}
 export default {
-  data() { return { channels: CHANNELS, loading:false } },
+  data() { return { channels: [], loading: false } },
+  created() { this.load() },
   methods: {
-    toggle(row) { this.$message.success(row.channelType + ' ' + (row.enabled?'已启用':'已禁用')) }
+    async load() {
+      this.loading = true
+      try {
+        const res = await getAvailableChannels()
+        const data = res.data || {}
+        this.channels = Object.keys(data).map(k => ({
+          channelType: k,
+          displayName: DISPLAY_NAMES[k] || k,
+          available: data[k].available,
+          enabled: data[k].available
+        }))
+      } catch (e) { /* ignore */ }
+      this.loading = false
+    }
   }
 }
 </script>

+ 69 - 0
src/views/lobster/evolution/index.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="app-container">
+    <el-row :gutter="16" class="mb8">
+      <el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-value">{{ metrics.totalInteractions || 0 }}</div><div class="stat-label">总交互次数</div></div></el-card></el-col>
+      <el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-value" style="color:#67C23A">{{ metrics.appliedSuggestions || 0 }}</div><div class="stat-label">已应用优化</div></div></el-card></el-col>
+      <el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-value" style="color:#E6A23C">{{ metrics.pendingSuggestions || 0 }}</div><div class="stat-label">待审核建议</div></div></el-card></el-col>
+      <el-col :span="6"><el-card shadow="hover"><div class="stat-card"><div class="stat-value">{{ metrics.avgQualityScore || '-' }}</div><div class="stat-label">平均质量分</div></div></el-card></el-col>
+    </el-row>
+
+    <el-form :inline="true" class="mb8">
+      <el-form-item label="工作流ID">
+        <el-input v-model="workflowId" placeholder="输入工作流ID" size="small" style="width:160px" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-data-analysis" size="mini" @click="handleAnalyze">触发进化分析</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="loadMetrics">刷新指标</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-card shadow="hover" v-if="suggestion">
+      <div slot="header"><span>最新进化建议</span></div>
+      <el-descriptions :column="2" border size="small">
+        <el-descriptions-item label="建议ID">{{ suggestion.id || suggestion.suggestionId }}</el-descriptions-item>
+        <el-descriptions-item label="置信度">{{ suggestion.confidence }}</el-descriptions-item>
+        <el-descriptions-item label="节点" :span="2">{{ suggestion.nodeCode }}</el-descriptions-item>
+        <el-descriptions-item label="原因" :span="2">{{ suggestion.reason }}</el-descriptions-item>
+        <el-descriptions-item label="优化内容" :span="2">{{ suggestion.suggestedContent }}</el-descriptions-item>
+      </el-descriptions>
+      <div style="margin-top:12px">
+        <el-button type="success" size="mini" @click="handleApply" :disabled="!(suggestion.id || suggestion.suggestionId)">应用建议</el-button>
+      </div>
+    </el-card>
+  </div>
+</template>
+<script>
+import { getEvolutionMetrics, analyzeEvolution, applyEvolution } from '@/api/workflow/lobster'
+export default {
+  name: 'LobsterEvolution',
+  data() {
+    return { metrics: {}, workflowId: '', suggestion: null }
+  },
+  created() { this.loadMetrics() },
+  methods: {
+    loadMetrics() {
+      getEvolutionMetrics().then(res => { this.metrics = res.data || {} }).catch(() => {})
+    },
+    handleAnalyze() {
+      if (!this.workflowId) { this.$message.warning('请输入工作流ID'); return }
+      analyzeEvolution(this.workflowId).then(res => {
+        this.suggestion = res.data || null
+        this.$message.success('分析完成')
+        this.loadMetrics()
+      })
+    },
+    handleApply() {
+      if (!this.suggestion || !(this.suggestion.id || this.suggestion.suggestionId)) return
+      applyEvolution(this.suggestion.id || this.suggestion.suggestionId).then(() => {
+        this.$message.success('建议已应用')
+        this.loadMetrics()
+      })
+    }
+  }
+}
+</script>
+<style scoped>
+.stat-card { text-align:center; padding:10px 0 }
+.stat-value { font-size:32px; font-weight:bold; color:#409EFF }
+.stat-label { font-size:13px; color:#909399; margin-top:4px }
+</style>

+ 42 - 0
src/views/lobster/integration-test/index.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="app-container">
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5"><el-button type="primary" icon="el-icon-video-play" size="mini" :loading="running" @click="runAll">运行全部测试</el-button></el-col>
+      <el-col :span="1.5"><el-button plain icon="el-icon-cpu" size="mini" @click="runModule('node-types')">节点类型</el-button></el-col>
+      <el-col :span="1.5"><el-button plain icon="el-icon-connection" size="mini" @click="runModule('dynamic-executor')">动态执行器</el-button></el-col>
+    </el-row>
+
+    <el-alert v-if="summary.message" :title="summary.message" :type="summary.passed ? 'success' : 'error'" show-icon class="mb8" />
+
+    <el-table border :data="details" size="small">
+      <el-table-column prop="line" label="测试结果" show-overflow-tooltip />
+    </el-table>
+  </div>
+</template>
+<script>
+import { runIntegrationTests, testNodeTypes, testDynamicExecutor } from '@/api/workflow/lobster'
+export default {
+  name: 'LobsterIntegrationTest',
+  data() { return { running: false, summary: {}, details: [] } },
+  methods: {
+    showResult(data) {
+      this.summary = data || {}
+      const lines = (data && data.details) || []
+      this.details = lines.map(line => ({ line }))
+    },
+    runAll() {
+      this.running = true
+      runIntegrationTests().then(res => { this.showResult(res.data); this.$message.success('测试完成') })
+        .finally(() => { this.running = false })
+    },
+    runModule(type) {
+      const fn = type === 'node-types' ? testNodeTypes : testDynamicExecutor
+      fn().then(res => {
+        const r = res.data || {}
+        this.summary = { passed: r.passed, message: r.message }
+        this.details = (r.details || []).map(line => ({ line }))
+      })
+    }
+  }
+}
+</script>

+ 1 - 0
src/views/lobster/learning-results/index.vue

@@ -3,6 +3,7 @@
     <el-row :gutter="10" class="mb8"><el-col :span="1.5"><el-button type="primary" icon="el-icon-video-play" size="mini" @click="trigger">触发学习周期</el-button></el-col></el-row>
     <el-table border v-loading="loading" :data="list">
       <el-table-column prop="title" label="发现模式" show-overflow-tooltip/>
+      <el-table-column prop="learningType" label="类型" width="90"/>
       <el-table-column prop="description" label="证据" show-overflow-tooltip/>
       <el-table-column prop="confidence" label="置信度" width="80"/>
       <el-table-column prop="status" label="状态" width="80"><template slot-scope="s"><el-tag :type="s.row.status==='discovered'?'warning':'success'" size="small">{{ s.row.status }}</el-tag></template></el-table-column>

+ 77 - 0
src/views/lobster/node-capabilities/index.vue

@@ -0,0 +1,77 @@
+<template>
+  <div class="app-container">
+    <el-row :gutter="16" class="mb8">
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value">{{ summary.totalTypes || 0 }}</div><div class="stat-label">节点类型总数</div></div></el-card></el-col>
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value" style="color:#67C23A">{{ summary.fullCount || 0 }}</div><div class="stat-label">FULL 完整</div></div></el-card></el-col>
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value" style="color:#E6A23C">{{ summary.partialCount || 0 }}</div><div class="stat-label">PARTIAL</div></div></el-card></el-col>
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value">{{ summary.avgMaturityStars || '-' }}</div><div class="stat-label">平均成熟度</div></div></el-card></el-col>
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value">{{ registeredCount }}</div><div class="stat-label">已注册Handler</div></div></el-card></el-col>
+      <el-col :span="4"><el-card shadow="hover"><div class="stat-card"><div class="stat-value" style="color:#F56C6C">{{ gapCount }}</div><div class="stat-label">未注册缺口</div></div></el-card></el-col>
+    </el-row>
+
+    <el-form :inline="true" class="mb8">
+      <el-form-item label="状态">
+        <el-select v-model="filterStatus" placeholder="全部" clearable size="small" @change="applyFilter">
+          <el-option label="FULL" value="FULL" /><el-option label="PARTIAL" value="PARTIAL" />
+          <el-option label="STUB" value="STUB" /><el-option label="NONE" value="NONE" />
+        </el-select>
+      </el-form-item>
+      <el-form-item><el-button icon="el-icon-refresh" size="mini" @click="load">刷新</el-button></el-form-item>
+    </el-form>
+
+    <el-table border v-loading="loading" :data="filteredList" size="small">
+      <el-table-column prop="code" label="编码" width="70" />
+      <el-table-column prop="codeName" label="标识" width="140" />
+      <el-table-column prop="name" label="名称" width="120" />
+      <el-table-column prop="maturityStars" label="成熟度" width="100">
+        <template slot-scope="s"><el-rate :value="s.row.maturityStars" disabled /></template>
+      </el-table-column>
+      <el-table-column prop="status" label="状态" width="90">
+        <template slot-scope="s"><el-tag :type="statusType(s.row.status)" size="mini">{{ s.row.status }}</el-tag></template>
+      </el-table-column>
+      <el-table-column prop="handlerRegistered" label="Handler" width="90">
+        <template slot-scope="s"><el-tag :type="s.row.handlerRegistered?'success':'danger'" size="mini">{{ s.row.handlerRegistered?'已注册':'缺失' }}</el-tag></template>
+      </el-table-column>
+      <el-table-column prop="handler" label="处理器" show-overflow-tooltip />
+      <el-table-column prop="gapNote" label="备注" show-overflow-tooltip />
+    </el-table>
+  </div>
+</template>
+<script>
+import { getNodeCapabilities } from '@/api/workflow/lobster'
+export default {
+  name: 'LobsterNodeCapabilities',
+  data() {
+    return { loading: false, list: [], summary: {}, registeredCount: 0, gapCount: 0, filterStatus: '' }
+  },
+  computed: {
+    filteredList() {
+      if (!this.filterStatus) return this.list
+      return this.list.filter(r => r.status === this.filterStatus)
+    }
+  },
+  created() { this.load() },
+  methods: {
+    load() {
+      this.loading = true
+      getNodeCapabilities().then(res => {
+        const d = res.data || {}
+        this.summary = d.summary || {}
+        this.list = d.capabilities || []
+        this.registeredCount = d.registeredHandlerCount || 0
+        this.gapCount = (d.gaps || []).length
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    applyFilter() {},
+    statusType(s) {
+      return s === 'FULL' ? 'success' : s === 'PARTIAL' ? 'warning' : 'info'
+    }
+  }
+}
+</script>
+<style scoped>
+.stat-card { text-align:center; padding:10px 0 }
+.stat-value { font-size:28px; font-weight:bold; color:#409EFF }
+.stat-label { font-size:12px; color:#909399; margin-top:4px }
+</style>

+ 8 - 2
src/views/lobster/optimization/index.vue

@@ -51,7 +51,7 @@
   </div>
 </template>
 <script>
-import { listPendingAudit, batchAuditOptimization, auditSingleOptimization, getOptimizationStats } from '@/api/workflow/lobster'
+import { listPendingAudit, batchAuditOptimization, auditSingleOptimization, getOptimizationStats, analyzeEvolution } from '@/api/workflow/lobster'
 export default {
   name: 'LobsterOptimization',
   data() {
@@ -72,7 +72,13 @@ export default {
       let items = this.ids.map(id => ({ optimizationId: id, approved, remark: '' }))
       batchAuditOptimization({ auditItems: items, auditRemark: approved?'批量通过':'批量驳回' }).then(() => { this.$message.success('批量审核完成'); this.getList(); this.getStats() })
     },
-    handleAnalyze() { this.$message.info('请输入用户ID触发分析') }
+    handleAnalyze() {
+      if (!this.queryParams.workflowId) { this.$message.warning('请输入工作流ID'); return }
+      analyzeEvolution(this.queryParams.workflowId).then(() => {
+        this.$message.success('进化分析已触发')
+        this.getList(); this.getStats()
+      })
+    }
   }
 }
 </script>

+ 29 - 0
src/views/qw/externalContact/index.vue

@@ -346,6 +346,15 @@
         v-hasPermi="['qw:externalContact:addTag']"
       >批量增加龙虾标签</el-button>
     </el-col>
+    <el-col :span="1.5">
+      <el-button
+        type="warning"
+        plain
+        size="mini"
+        @click="removeLobsterTag"
+        v-hasPermi="['qw:externalContact:addTag']"
+      >批量取消龙虾标签</el-button>
+    </el-col>
 <!--       <el-col :span="1.5">-->
 <!--        <el-button-->
 <!--          type="primary"-->
@@ -2515,6 +2524,26 @@ export default {
         });
       }).catch(() => {});
     },
+    removeLobsterTag() {
+      if (this.ids == null || this.ids.length === 0) {
+        return this.$message('请勾选需要取消龙虾标签的客户');
+      }
+      this.$confirm(
+        '确认取消 ' + this.ids.length + ' 个客户的龙虾标签吗?',
+        '提示',
+        { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
+      ).then(() => {
+        tagBindingApi.batchUnbindLobsterTag({
+          userIds: this.ids,
+          qwCorpId: this.queryParams.corpId
+        }).then(res => {
+          this.$message.success('龙虾标签取消任务已提交');
+          this.getList();
+        }).catch(() => {
+          this.$message.error('龙虾标签取消失败');
+        });
+      }).catch(() => {});
+    },
     /** 导出按钮操作 */
     handleExport() {
       const { qwUserName, ...queryParams } = this.queryParams;

+ 856 - 0
src/views/taskStatistics/callLog/callLogDetail.vue

@@ -0,0 +1,856 @@
+<template>
+  <div class="app-container">
+    <!-- 统计概览 -->
+    <el-row :gutter="24" class="baseInfo">
+      <el-col :xs="12" :sm="12" :lg="6" class="ivu-mb">
+        <el-card shadow="hover" class="stat-card">
+          <div slot="header" class="card-header">
+            <span>记录总数</span>
+          </div>
+          <div class="content" v-loading="loading">
+            <span class="card-number total">
+              <count-to v-if="!loading" :start-val="0" :end-val="summary.totalCount || 0" :duration="2000" />
+            </span>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="12" :lg="6" class="ivu-mb">
+        <el-card shadow="hover" class="stat-card">
+          <div slot="header" class="card-header">
+            <span>接通量</span>
+          </div>
+          <div class="content" v-loading="loading">
+            <span class="card-number success">
+              <count-to v-if="!loading" :start-val="0" :end-val="summary.connectedCount || 0" :duration="2000" />
+            </span>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="12" :lg="6" class="ivu-mb">
+        <el-card shadow="hover" class="stat-card">
+          <div slot="header" class="card-header">
+            <span>计费分钟合计</span>
+          </div>
+          <div class="content" v-loading="loading">
+            <span class="card-number billing">
+              <count-to v-if="!loading" :start-val="0" :end-val="summary.totalBillingMinute || 0" :duration="2000" />
+            </span>
+            <span v-if="!loading" class="billing-unit">分钟</span>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :xs="12" :sm="12" :lg="6" class="ivu-mb">
+        <el-card shadow="hover" class="stat-card">
+          <div slot="header" class="card-header">
+            <span>接通率</span>
+          </div>
+          <div class="content" v-loading="loading">
+            <span class="card-number rate">
+              <count-to v-if="!loading" :start-val="0" :end-val="summary.connectRate || 0" :duration="2000" />%
+            </span>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 筛选区域 -->
+    <el-form
+      ref="queryForm"
+      :model="queryParams"
+      :inline="true"
+      size="small"
+      label-width="100px"
+      class="detail-query-form"
+    >
+      <el-form-item label="任务" prop="roboticId">
+        <el-select
+          v-model="queryParams.roboticId"
+          filterable
+          remote
+          reserve-keyword
+          clearable
+          placeholder="请选择任务"
+          style="width: 280px"
+          :remote-method="searchRoboticTask"
+          :loading="roboticLoading"
+          @visible-change="handleRoboticDropdownVisible"
+          @change="handleTaskChange"
+        >
+          <el-option
+            v-for="item in roboticList"
+            :key="item.id"
+            :label="formatRoboticLabel(item)"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="手机号" prop="phone">
+        <el-input
+          v-model="queryParams.phone"
+          placeholder="请输入手机号"
+          clearable
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
+          <el-option label="执行中" :value="1" />
+          <el-option label="成功" :value="2" />
+          <el-option label="失败" :value="3" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="是否接通" prop="isConnected">
+        <el-select v-model="queryParams.isConnected" placeholder="请选择" clearable>
+          <el-option label="是" :value="1" />
+          <el-option label="否" :value="0" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="客户类型" prop="intention">
+        <el-select v-model="queryParams.intention" placeholder="请选择" clearable>
+          <el-option
+            v-for="dict in intentionOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="运行时间" prop="runDateRange">
+        <el-date-picker
+          v-model="runDateRange"
+          type="daterange"
+          value-format="yyyy-MM-dd"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          style="width: 240px"
+        />
+      </el-form-item>
+      <el-form-item label="通话时长(秒)">
+        <div class="call-time-range">
+          <el-input
+            v-model="queryParams.minCallTime"
+            placeholder="最小时长"
+            clearable
+            @keyup.enter.native="handleQuery"
+          />
+          <span class="range-separator">至</span>
+          <el-input
+            v-model="queryParams.maxCallTime"
+            placeholder="最大时长"
+            clearable
+            @keyup.enter.native="handleQuery"
+          />
+        </div>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
+        <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
+        <el-button
+          v-hasPermi="['company:callphoneDetail:list']"
+          type="primary"
+          icon="el-icon-refresh"
+          :loading="syncLoading"
+          @click="handleSyncAiCallData"
+        >同步AI外呼数据</el-button>
+        <el-button
+          v-hasPermi="['company:callphonelog:exportPhone']"
+          type="warning"
+          icon="el-icon-download"
+          :loading="exportLoading"
+          @click="handleExport"
+        >导出</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 表格 -->
+    <div v-hasPermi="['company:callphoneDetail:list']">
+      <el-table
+        border
+        v-loading="loading"
+        :data="list"
+        height="520"
+      >
+        <el-table-column label="ID" align="center" prop="logId" width="80" fixed="left" />
+        <el-table-column label="任务名称" align="center" prop="roboticName" min-width="150" show-overflow-tooltip />
+        <el-table-column label="运行时间" align="center" prop="runTime" width="160">
+          <template slot-scope="scope">
+            <span>{{ formatDateTime(scope.row.runTime) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" align="center" prop="status" width="80">
+          <template slot-scope="scope">
+            <el-tag v-if="scope.row.status === 1" type="warning" size="mini">执行中</el-tag>
+            <el-tag v-else-if="scope.row.status === 2" type="success" size="mini">成功</el-tag>
+            <el-tag v-else-if="scope.row.status === 3" type="danger" size="mini">失败</el-tag>
+            <span v-else>--</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="客户号码" align="center" prop="callerNum" width="155">
+          <template slot-scope="scope">
+            <span>{{ formatCallerNum(scope.row) }}</span>
+            <el-button
+              v-if="scope.row.callerNum || scope.row.customerId || scope.row.callerId"
+              v-hasPermi="['crm:customer:queryPhone']"
+              type="text"
+              size="mini"
+              icon="el-icon-view"
+              @click="handleQueryPhone(scope.row)"
+            >查看</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column label="客户类型" align="center" prop="intention" width="90">
+          <template slot-scope="scope">
+            <template v-for="dict in intentionOptions">
+              <el-tag
+                v-if="normalizeIntention(scope.row.intention) == dict.dictValue"
+                :key="dict.dictValue"
+                size="mini"
+              >{{ dict.dictLabel }}</el-tag>
+            </template>
+            <span v-if="!matchIntentionDict(scope.row.intention)">--</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="通话时长(秒)" align="center" prop="callTime" width="105">
+          <template slot-scope="scope">
+            <span>{{ scope.row.callTime != null ? scope.row.callTime : '--' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="计费分钟" align="center" prop="billingMinute" width="95">
+          <template slot-scope="scope">
+            <span v-if="scope.row.billingMinute != null">{{ scope.row.billingMinute }}分钟</span>
+            <span v-else>--</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="销售名称" align="center" prop="companyUserName" width="100" show-overflow-tooltip />
+        <el-table-column
+          v-if="checkPermi(['crm:customer:showCallInfo'])"
+          label="录音"
+          align="center"
+          prop="recordPath"
+          min-width="260"
+        >
+          <template slot-scope="scope">
+            <audio v-if="scope.row.recordPath" controls :src="handleRecordPath(scope.row.recordPath)" />
+            <span v-else>--</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" width="90" fixed="right">
+          <template slot-scope="scope">
+            <el-button
+              v-if="scope.row.contentList && checkPermi(['crm:customer:showCallInfo'])"
+              size="mini"
+              type="text"
+              @click="handleViewChat(scope.row)"
+            >查看对话</el-button>
+            <span v-else>--</span>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination
+        v-show="total > 0"
+        :total="total"
+        :page.sync="queryParams.pageNum"
+        :limit.sync="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </div>
+
+    <!-- 对话记录弹窗 -->
+    <el-dialog
+      title="通话详情"
+      :visible.sync="chatDialogVisible"
+      width="720px"
+      append-to-body
+      custom-class="chat-dialog"
+      @close="handleChatClose"
+    >
+      <div v-if="chatList.length > 0" class="chat-summary">
+        <div class="chat-summary-item">
+          <span class="chat-summary-label">总条数:</span>
+          <span class="chat-summary-value">{{ chatList.length }}</span>
+        </div>
+        <div class="chat-summary-item">
+          <span class="chat-summary-label">机器人:</span>
+          <span class="chat-summary-value blue">{{ chatStats.agentCount }}</span>
+        </div>
+        <div class="chat-summary-item">
+          <span class="chat-summary-label">用户:</span>
+          <span class="chat-summary-value green">{{ chatStats.userCount }}</span>
+        </div>
+        <div class="chat-summary-item">
+          <span class="chat-summary-label">敏感词命中:</span>
+          <span class="chat-summary-value red">{{ chatStats.sensitiveCount }}</span>
+        </div>
+      </div>
+      <div v-if="chatList.length > 0" class="chat-container">
+        <div
+          v-for="(item, index) in chatList"
+          :key="index"
+          class="chat-item"
+          :class="item.role === 'agent' ? 'chat-item-right' : 'chat-item-left'"
+        >
+          <div class="chat-avatar">
+            <div
+              class="chat-avatar-circle"
+              :class="item.role === 'agent' ? 'avatar-agent' : 'avatar-user'"
+            >
+              <i :class="item.role === 'agent' ? 'el-icon-headset' : 'el-icon-user-solid'" />
+            </div>
+          </div>
+          <div class="chat-content-wrap">
+            <div class="chat-role-line">
+              <span class="chat-role">{{ item.role === 'agent' ? '机器人' : '用户' }}</span>
+              <span class="chat-index">#{{ index + 1 }}</span>
+            </div>
+            <div class="chat-bubble" v-html="item.content" />
+          </div>
+        </div>
+      </div>
+      <el-empty v-else description="暂无通话内容" />
+      <div slot="footer" class="dialog-footer chat-dialog-footer">
+        <span class="chat-footer-tip">标红内容为命中的敏感词</span>
+        <el-button @click="chatDialogVisible = false">关 闭</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { parseTime } from '@/utils/common'
+import { checkPermi } from '@/utils/permission'
+import {
+  listCallphoneDetail,
+  getCallphoneDetailSummary,
+  exportDetailPhone,
+  queryCallphoneLogPhone,
+  syncAiCallData
+} from '@/api/company/callphone'
+import { listRoboticSelectOptions, getRobotic } from '@/api/company/companyVoiceRobotic'
+import { queryPhone } from '@/api/crm/customer'
+import CountTo from 'vue-count-to'
+
+export default {
+  name: 'CallLogDetail',
+  components: { CountTo },
+  data() {
+    return {
+      loading: false,
+      exportLoading: false,
+      syncLoading: false,
+      total: 0,
+      list: [],
+      roboticList: [],
+      roboticLoading: false,
+      roboticQueryParams: {
+        pageNum: 1,
+        pageSize: 20,
+        name: null
+      },
+      runDateRange: [],
+      intentionOptions: [],
+      chatDialogVisible: false,
+      chatList: [],
+      taskInfo: {
+        roboticId: null,
+        roboticName: ''
+      },
+      summary: {
+        totalCount: 0,
+        connectedCount: 0,
+        connectRate: 0,
+        totalBillingMinute: 0
+      },
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        roboticId: null,
+        phone: null,
+        status: null,
+        isConnected: null,
+        intention: null,
+        minCallTime: null,
+        maxCallTime: null,
+        beginRunTime: null,
+        endRunTime: null
+      }
+    }
+  },
+  computed: {
+    chatStats() {
+      const stats = { agentCount: 0, userCount: 0, sensitiveCount: 0 }
+      this.chatList.forEach(item => {
+        if (item.role === 'agent') stats.agentCount++
+        else stats.userCount++
+        const matches = (item.content || '').match(/<span[^>]*class="sensitive-word"[^>]*>/g)
+        if (matches) stats.sensitiveCount += matches.length
+      })
+      return stats
+    }
+  },
+  created() {
+    this.initFromRoute()
+    this.loadIntentionDict()
+    this.ensureSelectedRoboticInOptions().finally(() => {
+      this.getList()
+    })
+  },
+  methods: {
+    checkPermi,
+    formatRoboticLabel(item) {
+      if (!item) return ''
+      return item.name ? `${item.name}(${item.id})` : String(item.id)
+    },
+    normalizeIntention(intention) {
+      if (intention === null || intention === undefined || intention === '') {
+        return '0'
+      }
+      return String(intention)
+    },
+    matchIntentionDict(intention) {
+      if (!Array.isArray(this.intentionOptions) || this.intentionOptions.length === 0) {
+        return false
+      }
+      const value = this.normalizeIntention(intention)
+      return this.intentionOptions.some(dict => String(dict.dictValue) === value)
+    },
+    initFromRoute() {
+      const query = this.$route.query || {}
+      this.taskInfo.roboticId = query.roboticId ? Number(query.roboticId) : null
+      this.taskInfo.roboticName = query.roboticName || ''
+      this.queryParams.roboticId = this.taskInfo.roboticId
+    },
+    ensureSelectedRoboticInOptions() {
+      const roboticId = this.queryParams.roboticId
+      if (!roboticId) {
+        return Promise.resolve()
+      }
+      if (this.roboticList.some(item => item.id === roboticId)) {
+        return Promise.resolve()
+      }
+      if (this.taskInfo.roboticName) {
+        this.roboticList = [{ id: roboticId, name: this.taskInfo.roboticName }]
+        return Promise.resolve()
+      }
+      return getRobotic(roboticId).then(res => {
+        const data = res.data
+        if (data && data.id) {
+          this.roboticList = [{ id: data.id, name: data.name }]
+          this.taskInfo.roboticName = data.name
+        }
+      }).catch(() => {})
+    },
+    loadRoboticOptions(reset = false) {
+      if (reset) {
+        this.roboticQueryParams.pageNum = 1
+      }
+      this.roboticLoading = true
+      return listRoboticSelectOptions(this.roboticQueryParams).then(res => {
+        const rows = res.rows || []
+        if (reset || this.roboticQueryParams.pageNum === 1) {
+          const selectedId = this.queryParams.roboticId
+          const selected = selectedId
+            ? this.roboticList.find(item => item.id === selectedId) || rows.find(item => item.id === selectedId)
+            : null
+          this.roboticList = selected && !rows.some(item => item.id === selected.id)
+            ? [selected, ...rows]
+            : rows
+        } else {
+          const existIds = new Set(this.roboticList.map(item => item.id))
+          rows.forEach(item => {
+            if (!existIds.has(item.id)) {
+              this.roboticList.push(item)
+            }
+          })
+        }
+      }).catch(() => {
+        if (reset || this.roboticQueryParams.pageNum === 1) {
+          this.roboticList = []
+        }
+      }).finally(() => {
+        this.roboticLoading = false
+      })
+    },
+    searchRoboticTask(query) {
+      this.roboticQueryParams.name = query || null
+      this.loadRoboticOptions(true)
+    },
+    handleRoboticDropdownVisible(visible) {
+      if (visible && this.roboticList.length === 0) {
+        this.roboticQueryParams.name = null
+        this.loadRoboticOptions(true)
+      }
+    },
+    handleTaskChange(roboticId) {
+      const task = this.roboticList.find(item => item.id === roboticId)
+      this.taskInfo.roboticId = roboticId || null
+      this.taskInfo.roboticName = task ? task.name : ''
+      this.handleQuery()
+    },
+    loadIntentionDict() {
+      this.getDicts('customer_intention_level').then(response => {
+        if (response.data && response.data.length) {
+          this.intentionOptions = response.data
+        }
+      }).catch(() => {})
+    },
+    desensitizePhone(phone) {
+      if (!phone) return '--'
+      if (String(phone).includes('****')) return phone
+      if (phone.length < 7) return phone
+      return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4)
+    },
+    formatCallerNum(row) {
+      if (row.callerNum) {
+        return this.desensitizePhone(row.callerNum)
+      }
+      if (row.customerId || row.callerId) {
+        return '******'
+      }
+      return '--'
+    },
+    formatDateTime(time) {
+      if (!time) return '--'
+      if (typeof time === 'string') return time.replace('T', ' ').substring(0, 16)
+      return parseTime(time, '{y}-{m}-{d} {h}:{i}') || '--'
+    },
+    handleRecordPath(url) {
+      if (!url) return ''
+      if (url.startsWith('http')) {
+        return process.env.VUE_APP_BASE_API + '/common/proxy/recording?url=' + encodeURIComponent(url)
+      }
+      const fullUrl = 'http://129.28.164.235:8899/recordings/files?filename=' + url
+      return process.env.VUE_APP_BASE_API + '/common/proxy/recording?url=' + encodeURIComponent(fullUrl)
+    },
+    handleQueryPhone(row) {
+      const customerId = row.customerId
+      if (customerId) {
+        queryPhone(customerId).then(response => {
+          this.$alert(response.mobile, '手机号', { confirmButtonText: '确定' })
+        })
+        return
+      }
+      if (row.logId) {
+        queryCallphoneLogPhone(row.logId).then(response => {
+          this.$alert(response.mobile, '手机号', { confirmButtonText: '确定' })
+        })
+        return
+      }
+      this.$message.warning('无法查看手机号')
+    },
+    handleExport() {
+      this.$confirm('是否确认导出当前筛选条件下的外呼记录(含解密手机号)?', '警告', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.exportLoading = true
+        return exportDetailPhone(this.getExportParams())
+      }).then(response => {
+        this.download(response.msg)
+      }).finally(() => {
+        this.exportLoading = false
+      }).catch(() => {})
+    },
+    handleSyncAiCallData() {
+      this.syncLoading = true
+      syncAiCallData().then(res => {
+        this.$message.success(res.msg || '正在同步中')
+      }).finally(() => {
+        this.syncLoading = false
+      })
+    },
+    parseCallTime(val) {
+      if (val === null || val === undefined || val === '') return null
+      const num = Number(val)
+      return isNaN(num) || num < 0 ? null : num
+    },
+    buildQueryParams() {
+      const params = { ...this.queryParams }
+      params.minCallTime = this.parseCallTime(params.minCallTime)
+      params.maxCallTime = this.parseCallTime(params.maxCallTime)
+      if (this.runDateRange && this.runDateRange.length === 2) {
+        params.beginRunTime = this.runDateRange[0]
+        params.endRunTime = this.runDateRange[1]
+      } else {
+        params.beginRunTime = null
+        params.endRunTime = null
+      }
+      return params
+    },
+    getExportParams() {
+      const params = this.buildQueryParams()
+      delete params.pageNum
+      delete params.pageSize
+      return params
+    },
+    getSummary() {
+      return getCallphoneDetailSummary(this.buildQueryParams()).then(res => {
+        const data = res.data || {}
+        this.summary = {
+          totalCount: data.totalCount || 0,
+          connectedCount: data.connectedCount || 0,
+          connectRate: data.connectRate != null ? data.connectRate : 0,
+          totalBillingMinute: data.totalBillingMinute || 0
+        }
+      }).catch(() => {
+        this.summary = { totalCount: 0, connectedCount: 0, connectRate: 0, totalBillingMinute: 0 }
+      })
+    },
+    getList() {
+      this.loading = true
+      const params = this.buildQueryParams()
+      listCallphoneDetail(params).then(listRes => {
+        this.list = listRes.rows || []
+        this.total = listRes.total || 0
+        if (this.list.length === 0 && this.total > 0 && this.queryParams.pageNum > 1) {
+          this.queryParams.pageNum = 1
+          this.getList()
+          return
+        }
+      }).catch(() => {
+        this.list = []
+        this.total = 0
+      }).finally(() => {
+        this.loading = false
+      })
+      this.getSummary()
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.runDateRange = []
+      this.resetForm('queryForm')
+      this.queryParams.minCallTime = null
+      this.queryParams.maxCallTime = null
+      this.queryParams.beginRunTime = null
+      this.queryParams.endRunTime = null
+      const query = this.$route.query || {}
+      this.queryParams.roboticId = query.roboticId ? Number(query.roboticId) : null
+      this.taskInfo.roboticId = this.queryParams.roboticId
+      this.taskInfo.roboticName = query.roboticName || ''
+      this.ensureSelectedRoboticInOptions()
+      this.handleQuery()
+    },
+    isSystemChatRole(role) {
+      return String(role || '').toLowerCase() === 'system'
+    },
+    normalizeChatRole(role) {
+      const r = String(role || '').toLowerCase()
+      if (['agent', 'robot', 'ai', 'bot', 'assistant'].includes(r)) return 'agent'
+      return 'user'
+    },
+    handleViewChat(row) {
+      let list = []
+      try {
+        const raw = row.contentList
+        list = typeof raw === 'string' ? JSON.parse(raw) : raw
+      } catch (e) {
+        list = []
+      }
+      this.chatList = (Array.isArray(list) ? list : [])
+        .filter(item => item && !this.isSystemChatRole(item.role) && String(item.content || '').trim())
+        .map(item => ({
+          ...item,
+          role: this.normalizeChatRole(item.role || item.speaker || item.from)
+        }))
+      this.chatDialogVisible = true
+    },
+    handleChatClose() {
+      this.chatList = []
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.baseInfo {
+  margin-bottom: 20px;
+}
+
+.stat-card {
+  border-radius: 10px;
+  transition: all 0.3s;
+}
+
+.stat-card:hover {
+  transform: translateY(-3px);
+}
+
+.card-header {
+  font-size: 14px;
+  color: #666;
+}
+
+.content {
+  height: 60px;
+  display: flex;
+  align-items: center;
+}
+
+.card-number {
+  font-size: 28px;
+  font-weight: bold;
+
+  &.total { color: #409eff; }
+  &.success { color: #67c23a; }
+  &.rate { color: #e6a23c; }
+  &.billing { color: #9b59b6; }
+}
+
+.billing-unit {
+  margin-left: 4px;
+  font-size: 14px;
+  color: #909399;
+  font-weight: normal;
+}
+
+.detail-query-form {
+  margin-bottom: 16px;
+  padding: 16px 16px 4px;
+  background: #fff;
+  border-radius: 4px;
+}
+
+.call-time-range {
+  display: inline-flex;
+  align-items: center;
+}
+
+.call-time-range .el-input {
+  width: 100px;
+}
+
+.range-separator {
+  margin: 0 6px;
+  color: #909399;
+}
+</style>
+
+<style lang="scss">
+.chat-dialog {
+  margin-top: 5vh !important;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+
+  .el-dialog__body {
+    padding: 0;
+    background-color: #f5f7fa;
+    max-height: calc(90vh - 120px);
+    overflow-y: auto;
+  }
+
+  .chat-summary {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 20px;
+    padding: 12px 20px;
+    background: #fff;
+    border-bottom: 1px solid #ebeef5;
+    position: sticky;
+    top: 0;
+    z-index: 2;
+
+    .chat-summary-label { color: #909399; font-size: 13px; }
+    .chat-summary-value {
+      font-weight: 600;
+      &.blue { color: #409eff; }
+      &.green { color: #67c23a; }
+      &.red { color: #f56c6c; }
+    }
+  }
+
+  .chat-container {
+    padding: 16px 20px;
+  }
+
+  .chat-item {
+    display: flex;
+    margin-bottom: 14px;
+    align-items: flex-start;
+  }
+
+  .chat-item-left {
+    .chat-avatar { margin-right: 10px; }
+    .chat-bubble {
+      background: #fff;
+      border: 1px solid #ebeef5;
+      border-radius: 2px 8px 8px 8px;
+    }
+    .chat-role { color: #67c23a; }
+  }
+
+  .chat-item-right {
+    flex-direction: row-reverse;
+    .chat-avatar { margin-left: 10px; }
+    .chat-content-wrap { align-items: flex-end; }
+    .chat-role-line { flex-direction: row-reverse; }
+    .chat-bubble {
+      background: #ecf5ff;
+      border: 1px solid #d9ecff;
+      border-radius: 8px 2px 8px 8px;
+    }
+    .chat-role { color: #409eff; }
+  }
+
+  .chat-avatar-circle {
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    font-size: 18px;
+
+    &.avatar-agent { background: #409eff; }
+    &.avatar-user { background: #67c23a; }
+  }
+
+  .chat-content-wrap {
+    display: flex;
+    flex-direction: column;
+    max-width: 70%;
+  }
+
+  .chat-role-line {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 4px;
+    font-size: 12px;
+  }
+
+  .chat-index { color: #c0c4cc; }
+
+  .chat-bubble {
+    padding: 8px 12px;
+    font-size: 14px;
+    line-height: 1.6;
+    word-break: break-word;
+
+    .sensitive-word {
+      color: #f56c6c !important;
+      font-weight: 600;
+      background: rgba(245, 108, 108, 0.12);
+      padding: 0 3px;
+      border-radius: 2px;
+    }
+  }
+
+  .chat-dialog-footer {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+
+    .chat-footer-tip {
+      font-size: 12px;
+      color: #909399;
+    }
+  }
+}
+</style>