Просмотр исходного кода

cid新增统计ai外呼&美的需求

lmx 1 месяц назад
Родитель
Сommit
ec421eced6

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

@@ -97,6 +97,24 @@ 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 getCallphone(logId) {
 //   return request({

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

@@ -24,6 +24,15 @@ 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/crm/customerAll.js

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

+ 9 - 0
src/router/index.js

@@ -381,6 +381,15 @@ export const constantRoutes = [
                     title: '外呼统计',
                     activeMenu: '/callLogStatistics/callLog'
                 }
+            },
+            {
+                path: 'detail',
+                component: () => import('@/views/taskStatistics/callLog/callLogDetail'),
+                name: 'callLogDetail',
+                meta: {
+                    title: 'AI外呼记录明细',
+                    activeMenu: '/callLogStatistics/callLog'
+                }
             }
         ]
     },

+ 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: {
@@ -323,6 +336,13 @@ export default {
       }
       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 + '"?', "警告", {
         confirmButtonText: "确定",
@@ -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(() => {});
     }
   }
 };

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

@@ -30,6 +30,17 @@
             <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
             v-model="dateRange"
@@ -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,10 @@ 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 +287,7 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.dateRange = [];
+      this.callDateRange = [];
       this.resetForm("queryForm");
       this.handleQuery();
     }

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

@@ -30,6 +30,17 @@
             <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
             v-model="dateRange"
@@ -155,6 +166,7 @@ export default {
       list: [],
       // 日期范围
       dateRange: [],
+      callDateRange: [],
       // 计费分钟合计
       sumBillingMinute: null,
       // 对话记录弹窗
@@ -236,6 +248,10 @@ 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 +271,7 @@ export default {
     /** 重置按钮操作 */
     resetQuery() {
       this.dateRange = [];
+      this.callDateRange = [];
       this.resetForm("queryForm");
       this.handleQuery();
     }

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

@@ -0,0 +1,839 @@
+<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: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
+} 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,
+      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(() => {})
+    },
+    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>