lmx пре 1 дан
родитељ
комит
9c084b52d5

+ 28 - 0
src/api/crm/manualOutboundCallDashboard.js

@@ -0,0 +1,28 @@
+import request from '@/utils/request'
+
+// 获取手动外呼统计数据
+export function getCallStatistics(query) {
+  return request({
+    url: '/crm/manualOutboundCallDashboard/statistics',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取客户跟进阶段统计数据
+export function getVisitStatusStatistics(query) {
+  return request({
+    url: '/crm/manualOutboundCallDashboard/visitStatusStatistics',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取每日外呼趋势数据
+export function getDailyCallTrend(query) {
+  return request({
+    url: '/crm/manualOutboundCallDashboard/dailyTrend',
+    method: 'get',
+    params: query
+  })
+}

+ 631 - 0
src/views/crm/customer/manualOutboundCallDashboard.vue

@@ -0,0 +1,631 @@
+<template>
+  <div class="app-container">
+    <div class="app-content">
+      <div class="title">手动外呼看板</div>
+      <!-- 筛选区域 -->
+      <el-form class="search-form" :model="queryParams" ref="queryForm" :inline="true">
+        <el-form-item label="日期范围" prop="dateRange">
+          <el-date-picker
+            v-model="dateRange"
+            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>
+          <el-button type="cyan" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 汇总卡片区域 -->
+      <el-row :gutter="20" class="summary-cards">
+        <el-col :span="6">
+          <el-card class="summary-card card-total" shadow="hover">
+            <div class="card-content">
+              <div class="card-icon"><i class="el-icon-phone-outline"></i></div>
+              <div class="card-info">
+                <div class="card-label">总外呼数</div>
+                <div class="card-value">{{ summary.totalCalls }}</div>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+        <el-col :span="6">
+          <el-card class="summary-card card-connected" shadow="hover">
+            <div class="card-content">
+              <div class="card-icon"><i class="el-icon-phone"></i></div>
+              <div class="card-info">
+                <div class="card-label">接通数</div>
+                <div class="card-value">{{ summary.connectedCalls }}</div>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+        <el-col :span="6">
+          <el-card class="summary-card card-rate" shadow="hover">
+            <div class="card-content">
+              <div class="card-icon"><i class="el-icon-data-analysis"></i></div>
+              <div class="card-info">
+                <div class="card-label">接通率(%)</div>
+                <div class="card-value">{{ summary.connectRate }}%</div>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+        <el-col :span="6">
+          <el-card class="summary-card card-duration" shadow="hover">
+            <div class="card-content">
+              <div class="card-icon"><i class="el-icon-timer"></i></div>
+              <div class="card-info">
+                <div class="card-label">平均通话时长(秒)</div>
+                <div class="card-value">{{ summary.avgDuration }}</div>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <!-- 每日外呼趋势 -->
+      <el-card class="box-card" style="margin-bottom: 20px;" v-if="isMultiDay">
+        <div slot="header">每日外呼趋势</div>
+        <div id="trendChart" class="trend-chart"></div>
+      </el-card>
+
+      <!-- 图表区域 -->
+      <el-row :gutter="20" class="chart-row">
+        <el-col :span="14">
+          <el-card shadow="never">
+            <div slot="header"><span>销售外呼统计</span></div>
+            <div id="barChart" class="chart-container"></div>
+          </el-card>
+        </el-col>
+        <el-col :span="10">
+          <el-card shadow="never">
+            <div slot="header"><span>通话状态分布</span></div>
+            <div id="pieChart" class="chart-container"></div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <!-- 客户跟进阶段分布 -->
+      <el-card shadow="never" class="chart-row">
+        <div slot="header"><span>客户跟进阶段分布</span></div>
+        <div v-if="visitStatusList && visitStatusList.length > 0" id="visitStatusChart" class="visit-status-chart"></div>
+        <div v-else class="no-data">暂无数据</div>
+      </el-card>
+
+      <!-- 明细表格区域 -->
+      <el-card shadow="never" class="table-card">
+        <div slot="header"><span>销售明细数据</span></div>
+        <el-table v-loading="loading" :data="list" border stripe style="width: 100%">
+          <el-table-column label="销售姓名" align="center" prop="companyUserName" />
+          <el-table-column label="总外呼数" align="center" prop="totalCalls" />
+          <el-table-column label="接通数" align="center" prop="connectedCalls" />
+          <el-table-column label="未接通数" align="center" prop="unconnectedCalls" />
+          <el-table-column label="接通率(%)" align="center" prop="connectRate">
+            <template slot-scope="scope">
+              <span :style="{ color: scope.row.connectRate >= 50 ? '#67C23A' : '#F56C6C', fontWeight: 'bold' }">
+                {{ scope.row.connectRate.toFixed(1) }}%
+              </span>
+            </template>
+          </el-table-column>
+          <el-table-column label="总通话时长" align="center" prop="totalDuration">
+            <template slot-scope="scope">
+              {{ formatDuration(scope.row.totalDuration) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="平均通话时长" align="center" prop="avgDuration">
+            <template slot-scope="scope">
+              {{ formatDuration(scope.row.avgDuration) }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getCallStatistics, getVisitStatusStatistics, getDailyCallTrend } from "@/api/crm/manualOutboundCallDashboard";
+import { getTradeDicts } from "@/api/crm/customer";
+import echarts from 'echarts'
+import resize from '../../dashboard/mixins/resize'
+
+export default {
+  name: "ManualOutboundCallDashboard",
+  mixins: [resize],
+  data() {
+    return {
+      loading: false,
+      // 日期范围
+      dateRange: [],
+      // 查询参数
+      queryParams: {},
+      // 列表数据
+      list: [],
+      // 汇总数据
+      summary: {
+        totalCalls: 0,
+        connectedCalls: 0,
+        connectRate: '0.0',
+        avgDuration: 0
+      },
+      // 跟进阶段统计数据
+      visitStatusList: [],
+      // 跟进阶段字典选项
+      statusOptions: [],
+      // 每日趋势数据
+      dailyTrendList: [],
+      // 是否为多天查询
+      isMultiDay: false,
+      // 图表实例
+      chart: null,
+      chart1: null,
+      visitStatusChart: null,
+      trendChart: null
+    }
+  },
+  created() {
+    this.initDefaultDate()
+    this.loadDictData().then(() => {
+      this.getList()
+    })
+  },
+  methods: {
+    /** 初始化默认日期为今天 */
+    initDefaultDate() {
+      const today = new Date()
+      const year = today.getFullYear()
+      const month = String(today.getMonth() + 1).padStart(2, '0')
+      const day = String(today.getDate()).padStart(2, '0')
+      const dateStr = `${year}-${month}-${day}`
+      this.dateRange = [dateStr, dateStr]
+    },
+    /** 获取统计数据 */
+    getList() {
+      this.loading = true
+      const params = {}
+      if (this.dateRange && this.dateRange.length === 2) {
+        params.startTime = this.dateRange[0]
+        params.endTime = this.dateRange[1]
+      }
+      // 判断是否为多天查询
+      const startTime = params.startTime || ''
+      const endTime = params.endTime || ''
+      this.isMultiDay = (startTime !== endTime)
+
+      getCallStatistics(params).then(response => {
+        this.list = response.data || []
+        this.calcSummary()
+        this.$nextTick(() => {
+          this.initBarChart()
+          this.initPieChart()
+        })
+        this.loading = false
+      }).catch(() => {
+        this.loading = false
+      })
+      // 获取跟进阶段统计
+      getVisitStatusStatistics(params).then(response => {
+        this.visitStatusList = response.data || []
+        this.$nextTick(() => {
+          this.initVisitStatusChart()
+        })
+      })
+      // 获取每日外呼趋势
+      if (this.isMultiDay) {
+        getDailyCallTrend(params).then(response => {
+          this.dailyTrendList = response.data || []
+          this.$nextTick(() => {
+            this.initTrendChart()
+          })
+        })
+      } else {
+        this.dailyTrendList = []
+        if (this.trendChart) {
+          this.trendChart.dispose()
+          this.trendChart = null
+        }
+      }
+    },
+    /** 计算汇总数据 */
+    calcSummary() {
+      let totalCalls = 0
+      let connectedCalls = 0
+      let totalDuration = 0
+      this.list.forEach(item => {
+        totalCalls += item.totalCalls || 0
+        connectedCalls += item.connectedCalls || 0
+        totalDuration += item.totalDuration || 0
+      })
+      const connectRate = totalCalls > 0 ? (connectedCalls / totalCalls * 100).toFixed(1) : '0.0'
+      const avgDuration = connectedCalls > 0 ? Math.round(totalDuration / connectedCalls) : 0
+      this.summary = {
+        totalCalls,
+        connectedCalls,
+        connectRate,
+        avgDuration
+      }
+    },
+    /** 初始化柱状图 */
+    initBarChart() {
+      if (this.chart) {
+        this.chart.dispose()
+      }
+      const chartDom = document.getElementById('barChart')
+      if (!chartDom) return
+      this.chart = echarts.init(chartDom)
+      const names = this.list.map(item => item.companyUserName)
+      const totalData = this.list.map(item => item.totalCalls)
+      const connectedData = this.list.map(item => item.connectedCalls)
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' }
+        },
+        legend: {
+          data: ['总外呼数', '接通数']
+        },
+        grid: {
+          left: '3%',
+          right: '4%',
+          bottom: '3%',
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: {
+            rotate: names.length > 6 ? 30 : 0
+          }
+        },
+        yAxis: {
+          type: 'value'
+        },
+        series: [
+          {
+            name: '总外呼数',
+            type: 'bar',
+            barWidth: '30%',
+            itemStyle: { color: '#409EFF' },
+            data: totalData
+          },
+          {
+            name: '接通数',
+            type: 'bar',
+            barWidth: '30%',
+            itemStyle: { color: '#67C23A' },
+            data: connectedData
+          }
+        ]
+      }
+      this.chart.setOption(option, true)
+    },
+    /** 初始化饼图 */
+    initPieChart() {
+      if (this.chart1) {
+        this.chart1.dispose()
+      }
+      const chartDom = document.getElementById('pieChart')
+      if (!chartDom) return
+      this.chart1 = echarts.init(chartDom)
+      let connectedTotal = 0
+      let unconnectedTotal = 0
+      this.list.forEach(item => {
+        connectedTotal += item.connectedCalls || 0
+        unconnectedTotal += item.unconnectedCalls || 0
+      })
+      const option = {
+        tooltip: {
+          trigger: 'item',
+          formatter: '{a} <br/>{b}: {c} ({d}%)'
+        },
+        legend: {
+          bottom: '5%',
+          data: ['接通', '未接通']
+        },
+        series: [
+          {
+            name: '通话状态',
+            type: 'pie',
+            radius: ['40%', '65%'],
+            center: ['50%', '45%'],
+            label: {
+              formatter: '{b}: {c}\n({d}%)'
+            },
+            data: [
+              { value: connectedTotal, name: '接通', itemStyle: { color: '#67C23A' } },
+              { value: unconnectedTotal, name: '未接通', itemStyle: { color: '#F56C6C' } }
+            ]
+          }
+        ]
+      }
+      this.chart1.setOption(option, true)
+    },
+    /** 格式化通话时长为 X分Y秒 */
+    formatDuration(seconds) {
+      if (!seconds || seconds <= 0) return '0秒'
+      const min = Math.floor(seconds / 60)
+      const sec = seconds % 60
+      if (min > 0) {
+        return sec > 0 ? `${min}分${sec}秒` : `${min}分`
+      }
+      return `${sec}秒`
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.getList()
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.visitStatusList = []
+      this.initDefaultDate()
+      this.handleQuery()
+    },
+    /** 加载字典数据 */
+    loadDictData() {
+      this.dictPromise = getTradeDicts().then(response => {
+        const data = response.data || {}
+        this.statusOptions = data["crm_customer_user_status"] || []
+      })
+      return this.dictPromise
+    },
+    /** 根据 visitStatus 值获取字典标签 */
+    getVisitStatusLabel(value) {
+      if (!value && value !== 0) return '未设置'
+      const strValue = String(value)
+      const item = this.statusOptions.find(dict => String(dict.dictValue) === strValue)
+      return item ? item.dictLabel : ('阶段' + value)
+    },
+    /** 初始化跟进阶段图表 */
+    initVisitStatusChart() {
+      if (this.visitStatusChart) {
+        this.visitStatusChart.dispose()
+      }
+      const chartDom = document.getElementById('visitStatusChart')
+      if (!chartDom) return
+      if (!this.visitStatusList || this.visitStatusList.length === 0) return
+      this.visitStatusChart = echarts.init(chartDom)
+      const colors = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#9B59B6', '#1ABC9C', '#F39C12']
+      const names = this.visitStatusList.map(item => this.getVisitStatusLabel(item.visitStatus))
+      const values = this.visitStatusList.map(item => item.customerCount)
+      const seriesData = values.map((val, idx) => ({
+        value: val,
+        itemStyle: { color: colors[idx % colors.length] }
+      }))
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' }
+        },
+        grid: {
+          left: '3%',
+          right: '6%',
+          bottom: '3%',
+          top: '40px',
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: {
+            rotate: names.length > 6 ? 30 : 0
+          }
+        },
+        yAxis: {
+          type: 'value',
+          name: '客户数量'
+        },
+        series: [
+          {
+            name: '客户数量',
+            type: 'bar',
+            barWidth: '40%',
+            data: seriesData
+          }
+        ]
+      }
+      this.visitStatusChart.setOption(option, true)
+    },
+    /** 初始化每日外呼趋势图 */
+    initTrendChart() {
+      if (this.trendChart) {
+        this.trendChart.dispose()
+      }
+      const chartDom = document.getElementById('trendChart')
+      if (!chartDom) return
+      this.trendChart = echarts.init(chartDom)
+      const dates = this.dailyTrendList.map(item => item.callDate ? item.callDate.substring(5) : '')
+      const totalData = this.dailyTrendList.map(item => item.totalCalls || 0)
+      const connectedData = this.dailyTrendList.map(item => item.connectedCalls || 0)
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          formatter: function(params) {
+            let tip = params[0].axisValue + '<br/>'
+            params.forEach(item => {
+              tip += item.marker + item.seriesName + ': ' + item.value + '<br/>'
+            })
+            return tip
+          }
+        },
+        legend: {
+          data: ['外呼总数', '接通数']
+        },
+        grid: {
+          left: '3%',
+          right: '4%',
+          bottom: '3%',
+          top: '40px',
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          boundaryGap: true,
+          data: dates
+        },
+        yAxis: {
+          type: 'value'
+        },
+        series: [
+          {
+            name: '外呼总数',
+            type: 'bar',
+            barWidth: '30%',
+            itemStyle: { color: '#409EFF' },
+            data: totalData
+          },
+          {
+            name: '接通数',
+            type: 'line',
+            smooth: false,
+            itemStyle: { color: '#67C23A' },
+            lineStyle: { width: 2 },
+            data: connectedData
+          }
+        ]
+      }
+      this.trendChart.setOption(option, true)
+    },
+    /** 图表响应式 - 覆盖 mixin 的 resize */
+    resize() {
+      const { chart, chart1, visitStatusChart, trendChart } = this
+      chart && chart.resize()
+      chart1 && chart1.resize()
+      visitStatusChart && visitStatusChart.resize()
+      trendChart && trendChart.resize()
+    }
+  },
+  beforeDestroy() {
+    if (this.chart) {
+      this.chart.dispose()
+      this.chart = null
+    }
+    if (this.chart1) {
+      this.chart1.dispose()
+      this.chart1 = null
+    }
+    if (this.visitStatusChart) {
+      this.visitStatusChart.dispose()
+      this.visitStatusChart = null
+    }
+    if (this.trendChart) {
+      this.trendChart.dispose()
+      this.trendChart = null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-container {
+  padding: 12px;
+
+  .app-content {
+    background-color: #fff;
+    padding: 20px;
+    border-radius: 4px;
+
+    .title {
+      font-size: 18px;
+      font-weight: bold;
+      color: #303133;
+      margin-bottom: 20px;
+    }
+
+    .search-form {
+      margin-bottom: 20px;
+    }
+  }
+}
+
+.summary-cards {
+  margin-bottom: 20px;
+
+  .summary-card {
+    border-radius: 4px;
+
+    .card-content {
+      display: flex;
+      align-items: center;
+    }
+
+    .card-icon {
+      font-size: 36px;
+      margin-right: 16px;
+      width: 50px;
+      height: 50px;
+      border-radius: 50%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .card-info {
+      .card-label {
+        font-size: 13px;
+        color: #909399;
+        margin-bottom: 6px;
+      }
+      .card-value {
+        font-size: 24px;
+        font-weight: bold;
+        color: #303133;
+      }
+    }
+  }
+
+  .card-total .card-icon {
+    background-color: #ecf5ff;
+    color: #409EFF;
+  }
+  .card-connected .card-icon {
+    background-color: #f0f9eb;
+    color: #67C23A;
+  }
+  .card-rate .card-icon {
+    background-color: #fdf6ec;
+    color: #E6A23C;
+  }
+  .card-duration .card-icon {
+    background-color: #f4ecff;
+    color: #909399;
+  }
+}
+
+.chart-row {
+  margin-bottom: 20px;
+}
+
+.chart-container {
+  width: 100%;
+  height: 350px;
+}
+
+.visit-status-chart {
+  width: 100%;
+  height: 340px;
+}
+
+.trend-chart {
+  width: 100%;
+  height: 300px;
+}
+
+.no-data {
+  height: 300px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 14px;
+}
+
+.table-card {
+  margin-top: 0;
+}
+</style>