Przeglądaj źródła

feat: 文件上传, 通话, 大屏等节前功能

sunxiao 1 miesiąc temu
rodzic
commit
45273ff52a
34 zmienionych plików z 1333 dodań i 197 usunięć
  1. 2 0
      package.json
  2. 9 0
      src/api/voice/call.js
  3. 12 0
      src/api/voice/dashboard.js
  4. BIN
      src/assets/images/dashboard/bg-bottom-item.png
  5. BIN
      src/assets/images/dashboard/bg-bottom.png
  6. BIN
      src/assets/images/dashboard/bg-count-01.png
  7. BIN
      src/assets/images/dashboard/bg-count-02.png
  8. BIN
      src/assets/images/dashboard/bg-count-03.png
  9. BIN
      src/assets/images/dashboard/bg-count-04.png
  10. BIN
      src/assets/images/dashboard/bg-count-05.png
  11. BIN
      src/assets/images/dashboard/bg-count-06.png
  12. BIN
      src/assets/images/dashboard/bg-echart.png
  13. BIN
      src/assets/images/dashboard/bg-footer.png
  14. BIN
      src/assets/images/dashboard/bg-main.png
  15. BIN
      src/assets/images/dashboard/bg-middle-title-01.png
  16. BIN
      src/assets/images/dashboard/bg-middle-title-02.png
  17. BIN
      src/assets/images/dashboard/bg-middle-title-03.png
  18. BIN
      src/assets/images/dashboard/bg-middle-title-04.png
  19. BIN
      src/assets/images/dashboard/bg-middle-title-05.png
  20. BIN
      src/assets/images/dashboard/bg-middle.png
  21. BIN
      src/assets/images/dashboard/bg-title.png
  22. 5 2
      src/layout/components/HeaderGroup/NoticePopover.vue
  23. 5 2
      src/layout/components/NoticeBar/index.vue
  24. 54 22
      src/layout/components/Sidebar/index.vue
  25. 5 5
      src/layout/components/TelNoticeBar/index.vue
  26. 13 0
      src/layout/index.vue
  27. 8 0
      src/router/index.js
  28. 4 4
      src/store/modules/notice.js
  29. 29 15
      src/store/modules/voice.js
  30. 10 1
      src/views/voice/analyse/index.vue
  31. 31 16
      src/views/voice/call/index.vue
  32. 676 0
      src/views/voice/dashboard/echartConfig.js
  33. 434 0
      src/views/voice/dashboard/index.vue
  34. 36 130
      src/views/voice/general/index.vue

+ 2 - 0
package.json

@@ -19,8 +19,10 @@
     "@element-plus/icons-vue": "2.3.1",
     "@vueup/vue-quill": "1.2.0",
     "@vueuse/core": "10.11.0",
+    "autofit.js": "^3.2.3",
     "axios": "0.28.1",
     "echarts": "5.5.1",
+    "echarts-gl": "^2.0.9",
     "element-plus": "2.8.1",
     "file-saver": "2.0.5",
     "fuse.js": "6.6.2",

+ 9 - 0
src/api/voice/call.js

@@ -7,5 +7,14 @@ export const callApi = {
   getImportFailList: params => request({
     url: `/excel/getImportFailList`,
     params
+  }),
+
+  /**
+   * 通话概况 - 查询
+   */
+  getCallRecordCountPageList: params => request({
+    url: `/front/callRecordCountPageList`,
+    params
   })
+  
 }

+ 12 - 0
src/api/voice/dashboard.js

@@ -0,0 +1,12 @@
+import request from '@/utils/request'
+
+export const dashboardApi = {
+  /**
+   * 驾驶舱 - 查询
+   */
+  getHomeScreenCount: params => request({
+    url: `/front/homeScreenCount`,
+    params
+  })
+  
+}

BIN
src/assets/images/dashboard/bg-bottom-item.png


BIN
src/assets/images/dashboard/bg-bottom.png


BIN
src/assets/images/dashboard/bg-count-01.png


BIN
src/assets/images/dashboard/bg-count-02.png


BIN
src/assets/images/dashboard/bg-count-03.png


BIN
src/assets/images/dashboard/bg-count-04.png


BIN
src/assets/images/dashboard/bg-count-05.png


BIN
src/assets/images/dashboard/bg-count-06.png


BIN
src/assets/images/dashboard/bg-echart.png


BIN
src/assets/images/dashboard/bg-footer.png


BIN
src/assets/images/dashboard/bg-main.png


BIN
src/assets/images/dashboard/bg-middle-title-01.png


BIN
src/assets/images/dashboard/bg-middle-title-02.png


BIN
src/assets/images/dashboard/bg-middle-title-03.png


BIN
src/assets/images/dashboard/bg-middle-title-04.png


BIN
src/assets/images/dashboard/bg-middle-title-05.png


BIN
src/assets/images/dashboard/bg-middle.png


BIN
src/assets/images/dashboard/bg-title.png


+ 5 - 2
src/layout/components/HeaderGroup/NoticePopover.vue

@@ -28,7 +28,7 @@ const tabData = ref([
 
 const queryParams = ref({
   pageNum: 0,
-  pageSize: 2
+  pageSize: 10
 });
 
 const total = ref(0);
@@ -56,6 +56,9 @@ const handleNoticeItem = (item) => {
   if (read == 1) return;
   workbenchApi.putWarningRecord({ read: 1, id }).then(() => {
     item.read = 1;
+    if (read == 0) {
+      warningNumber.value -= 1;
+    }
   })
 }
 
@@ -111,7 +114,7 @@ const onBeforeEnter = () => {
           :infinite-scroll-disabled="disabled"
           :key="timestamp"
         >
-          <div :class="['notice-item', { 'notice-active': item.read == 1 }]" v-for="item, index in warningRecordList" :key="item.id" @click="handleNoticeItem(item)">
+          <div :class="['notice-item', { 'notice-active': item.read == 1 }]" v-for="item in warningRecordList" :key="item.id" @click="handleNoticeItem(item)">
             <h4 class="title" v-show="item.type == 0">有<span class="text-[#FF3636] font-bold">5</span>个连续未接听来电</h4>
             <h4 class="title" v-show="item.type == 1"><span class="text-[#FF3636] font-bold">10</span>分钟空岗报警</h4>
             <h4 class="title" v-show="item.type == 2">有连续<span class="text-[#FF3636] font-bold">3</span>个电话5s内秒挂</h4>

+ 5 - 2
src/layout/components/NoticeBar/index.vue

@@ -26,7 +26,7 @@ const onCloseNoticebar = () => {
 const initWaringCount = () => {
   workbenchApi.getNewestWarningCount().then(({ data }) => {
     warningCount.value = data;
-    if ( data != 0 ) {
+    if ( data != 0 && data > warningNumber.value ) {
       warningNumber.value = data;
       noticeBarStatus.value = true
     }
@@ -35,7 +35,10 @@ const initWaringCount = () => {
 
 onMounted(() => {
   initWaringCount();
-  timer = setInterval(() => initWaringCount, 5 * 60 * 1000);
+  timer = setInterval(() => {
+    initWaringCount();
+  } , 5 * 60 * 1000);
+  // } , 5 * 1000);
 });
 
 onUnmounted(() => {

+ 54 - 22
src/layout/components/Sidebar/index.vue

@@ -4,10 +4,11 @@ import useSettingsStore from '@/store/modules/settings'
 import usePermissionStore from '@/store/modules/permission'
 
 const route = useRoute();
+const router = useRouter();
 const settingsStore = useSettingsStore()
 const permissionStore = usePermissionStore()
 
-const sidebarRouters =  computed(() => permissionStore.sidebarRouters);
+const sidebarRouters = computed(() => permissionStore.sidebarRouters);
 
 const theme = computed(() => settingsStore.theme);
 const activeKey = ref(route.path);
@@ -22,9 +23,9 @@ const activeMenu = computed(() => {
 })
 
 const menuResetTheme = {
-  '--el-menu-bg-color' : '#EFF1F8',
-  '--el-menu-text-color' : '#4E5969',
-  '--el-menu-active-color' : '#165DFF',
+  '--el-menu-bg-color': '#EFF1F8',
+  '--el-menu-text-color': '#4E5969',
+  '--el-menu-active-color': '#165DFF',
   '--el-menu-item-height': '48px'
 }
 
@@ -32,28 +33,59 @@ const handleSelect = (key) => {
   activeKey.value = key;
 }
 
+const onJumpPage = () => {
+  const { href } = router.resolve({
+    path: '/voice/dashboard'
+  });
+  window.open(href, '_blank');
+}
+
 </script>
 <template>
-  <div :style="{ backgroundColor: '#EFF1F8', paddingTop: '20px'}">
+  <div :style="{ backgroundColor: '#EFF1F8', paddingTop: '20px' }" class="vocie-menu-wrapper">
     <el-scrollbar :class="theme" wrap-class="scrollbar-wrapper">
       <div style="padding: 0 20px;">
-        <el-menu
-          :style="menuResetTheme"
-          :default-active="activeMenu"
-          :unique-opened="true"
-          :collapse-transition="false"
-          mode="vertical"
-          @select="handleSelect"
-        >
-          <sidebar-item
-            v-for="(route, index) in sidebarRouters"
-            :key="route.path + index"
-            :item="route"
-            :base-path="route.path"
-            :active-key="activeKey"
-          />
+        <el-menu :style="menuResetTheme" :default-active="activeMenu" :unique-opened="true" :collapse-transition="false"
+          mode="vertical" @select="handleSelect">
+          <sidebar-item v-for="(route, index) in sidebarRouters" :key="route.path + index" :item="route"
+            :base-path="route.path" :active-key="activeKey" />
         </el-menu>
       </div>
-    </el-scrollbar> 
+    </el-scrollbar>
+    <div class="vocie-menu-footer">
+      <div class="dashboard-btn space-x-[10px]" @click="onJumpPage">
+        <el-icon><TrendCharts /></el-icon>
+        <span class="text-[14px]">驾驶舱</span>
+      </div>
+    </div>
   </div>
-</template>
+</template>
+
+<style lang="scss">
+#app .sidebar-container .el-scrollbar {
+  height: calc(100% - 60px);
+}
+
+.vocie-menu-footer {
+  height: 60px;
+  padding: 20px;
+  .dashboard-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 36px;
+    border-radius: 8px;
+    border: 1px solid #e4e4e4;
+    // color: #165DFF;
+    color: #666;
+    font-size: 16px;
+    cursor: pointer;
+    transition: all 0.6s;
+    
+    &:hover {
+      color: #165DFF;
+      background: linear-gradient(90deg, #FFF 0%, rgba(255, 255, 255, 0) 100%);
+    }
+  }
+}
+</style>

+ 5 - 5
src/layout/components/TelNoticeBar/index.vue

@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia';
 import useVoiceStore from "@/store/modules/voice";
 
 const voiceStore = useVoiceStore();
-const { callDialing, callTime, isMakingCall, telephoneNumber } = storeToRefs(voiceStore);
+const { callDialing, callTime, isMakingCall, telephoneNumber, isShowTimer } = storeToRefs(voiceStore);
 
 // 切换展示窗口
 const handleToggle = () => {
@@ -45,10 +45,10 @@ const handleCallAnswered = () => {
         </li>
       </ul>
     </div>
-    
-    <div class="notice-center text-[14px]" style="color: rgba(255, 255, 255, 0.6)" v-show="callDialing">
-      <!-- <span>通话中:</span>
-      <span>{{ callTime }}</span> -->
+   
+    <div class="notice-center text-[14px]" style="color: rgba(255, 255, 255, 1)" v-show="callDialing">
+      <span v-show="!isShowTimer">接通中...</span>
+      <span v-show="isShowTimer">{{ callTime }}</span>
     </div>
 
     <div class="notice-right flex">

+ 13 - 0
src/layout/index.vue

@@ -13,6 +13,7 @@ import Cookies from 'js-cookie'
 
 const useVoice = useVoiceStore();
 const useUser = useUserStore();
+const audioRef = ref(null);
 
 const intervalTimer = ref(null);
 
@@ -48,7 +49,19 @@ const init = () => {
       }, 2000);
     })
 }
+
 onMounted(() => {
+
+  setTimeout(() => {
+
+    audioRef.value.play();
+
+    setTimeout(() => {
+      audioRef.value.pause();
+    }, 2000);
+
+  }, 2000)
+
   if ( useVoice.isAuthPane ) {
     init();
     intervalTimer.value = setInterval(async () => {

+ 8 - 0
src/router/index.js

@@ -88,6 +88,14 @@ export const constantRoutes = [
 
 // 动态路由,基于用户权限动态去加载
 export const dynamicRoutes = [
+  {
+    path: '/voice/dashboard',
+    component: () => import('@/views/voice/dashboard/index'),
+    hidden: false,
+    roles: ['admin'],
+    name: 'VoiceDashboard',
+    
+  },
   {
     path: '/system/user-auth',
     component: Layout,

+ 4 - 4
src/store/modules/notice.js

@@ -12,11 +12,11 @@ const useNoticeStore = defineStore('notice', () => {
 
   const warningNumber = ref(0);
 
-  const getWaringCount = () => {
-    getNewestWarningCount().then(res => {
+  // const getWaringCount = () => {
+  //   getNewestWarningCount().then(res => {
 
-    })
-  }
+  //   })
+  // }
 
   return {
     noticePopupStatus,

+ 29 - 15
src/store/modules/voice.js

@@ -1,5 +1,5 @@
 import { ref } from 'vue';
-// import { Timer } from '@/utils/timer';
+import { Timer } from '@/utils/timer';
 import { ElMessage, ElNotification } from 'element-plus'
 import usePermissionStore from './permission';
 import useUserStore from './user';
@@ -26,6 +26,7 @@ const useVoiceStore = defineStore('voice', () => {
 
   // 刚来电
   const callAnswered = ref(false);
+
   // 接听中
   const callDialing = ref(false);
 
@@ -41,7 +42,10 @@ const useVoiceStore = defineStore('voice', () => {
   
   // 通话时长
   const callTime = ref('00:00:00');
-  // const timer = new Timer(callTime);
+  // 倒计时
+  const timer = new Timer(callTime);
+  // 是否显示倒计时 - 由于存在延迟大约5s
+  const isShowTimer = ref(false);
   
   // 开始时间
   const startTime = '';
@@ -67,7 +71,9 @@ const useVoiceStore = defineStore('voice', () => {
     callAnswered.value = false;
     callDialing.value = false;
 
-    // timer.resetTimer();
+    isShowTimer.value = false;
+
+    timer.resetTimer();
   }
 
   // 拨打电话
@@ -106,8 +112,6 @@ const useVoiceStore = defineStore('voice', () => {
     callDialing.value = true;
     callAnswered.value = true;
     await answer();
-    // timer.start();
-    // TODO 这里需要补充其他逻辑
   }
 
   // 挂断电话
@@ -171,6 +175,13 @@ const useVoiceStore = defineStore('voice', () => {
   // 下面开始事件监听
   const listenScoketEvent = (CTIEvent) => {
     HS_CTI.on(CTIEvent.OnCtiError,(res)=>{
+
+      console.log("------------error-------------")
+      console.log("------------error-------------")
+      console.log( res );
+      console.log("------------error-------------")
+      console.log("------------error-------------")
+
       HSCTIERRORCODE.value = res.code;
       switch (res.code) {
         case '100002':
@@ -186,14 +197,15 @@ const useVoiceStore = defineStore('voice', () => {
       }
     })
 
-    HS_CTI.on(CTIEvent.OnAgentWorkReport, async ({ workStatus, description, callId, phone }) => {
-
-      // console.log( "-----------------------" );
-      // console.log( "-----------------------" );
-      // console.log( "workStatus", workStatus );
-      // console.log( "phone", phone );
-      // console.log( "-----------------------" );
-      // console.log( "-----------------------" );
+    HS_CTI.on(CTIEvent.OnAgentWorkReport, async (val) => {
+      const { workStatus, description, callId, phone } = val;
+      console.log( "----------callback-------------" );
+      console.log( "----------callback-------------" );
+      console.log( "workStatus", workStatus );
+      console.log( "phone", phone );
+      console.log( "callback Value", val );
+      console.log( "----------callback-------------" );
+      console.log( "----------callback-------------" );
 
       // 销毁实例调用签出接口成功后 - 坐席签出
       // if ( workStatus === -1 ) {
@@ -255,7 +267,8 @@ const useVoiceStore = defineStore('voice', () => {
 
       // 座席接通呼入电话
       if ( workStatus === 10 ) {
-        // timer.start();
+        isShowTimer.value = true;
+        timer.start();
       }
 
       // 挂断 任意一方挂断
@@ -334,7 +347,8 @@ const useVoiceStore = defineStore('voice', () => {
 
     callTime,
     isMakingCall,
-    
+    isShowTimer,
+
     onMakingCall,
     onCallDisconnected,
     onCallAnswered,

+ 10 - 1
src/views/voice/analyse/index.vue

@@ -106,9 +106,18 @@ onMounted(() => {
                 <div>
                   <p class="card-sub-title">白名单直呼</p>
                   <ul class="flex items-center justify-center pt-[5px]">
-                    <li class="card-label">
+                    <!-- <li class="card-label">
                       <span class="text">呼入量</span>
                       <span class="num">{{ callRecordCountInfo.whiteListTotal }}</span>
+                    </li> -->
+                    <li class="card-label">
+                      <span class="text">人工坐席</span>
+                      <span class="num">{{ callRecordCountInfo.whiteListTotalHuman }}</span>
+                    </li>
+                    <li class="line"></li>
+                    <li class="card-label">
+                      <span class="text">机器人</span>
+                      <span class="num">{{ callRecordCountInfo.whiteListTotalRobot }}</span>
                     </li>
                     <li class="line"></li>
                     <li class="card-label">

+ 31 - 16
src/views/voice/call/index.vue

@@ -26,7 +26,8 @@ const queryParams = ref({
   status: '',
   category: '',
   phone: '',
-  serviceCategory: ''
+  serviceCategory: '',
+  type: ''
 })
 
 const serviceCategoryOptions = [
@@ -70,11 +71,11 @@ const jumpDetails = (item) => {
 
 // 音频加载完成
 const onAudioLoadDone = ({ durationTime, id }) => {
-  tableData.value.map(item => {
-    if (item.id == id) {
-      item.times = durationTime || ''
-    }
-  })
+  // tableData.value.map(item => {
+  //   if (item.id == id) {
+  //     item.times = durationTime || ''
+  //   }
+  // })
 }
 
 const handleClose = (done) => {
@@ -104,7 +105,27 @@ const handleBatchDownload = () => {
 const handleDownload = ({ id, sessionId }) => {
   proxy.getDownload("/business/record/downloadById", { id }, `${sessionId}.wav`);
 }
+const formatSecondsToTime = (seconds) => {
+  // 计算完整的分钟数和剩余的秒数
+  let minutes = Math.floor(seconds / 60);
+  let remainingSeconds = seconds % 60;
+
+  // 如果有超过60分钟的部分,计算小时
+  let hours = Math.floor(minutes / 60);
+  minutes = minutes % 60;
 
+  // 确保分钟和秒数都是两位数,不足两位前面补零
+  let formattedMinutes = String(minutes).padStart(2, '0');
+  let formattedSeconds = String(remainingSeconds).padStart(2, '0');
+
+  // 如果有小时,则包含在输出中
+  if (hours > 0) {
+    let formattedHours = String(hours).padStart(2, '0');
+    return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+  } else {
+    return `${formattedMinutes}:${formattedSeconds}`;
+  }
+}
 const getList = () => {
   const [timeBegin, timeEnd] = dataPickerValue.value || [];
 
@@ -119,7 +140,8 @@ const getList = () => {
       url: item.url ? item.url + '?timstamp=' + new Date().getTime() : '',
       typeText: item.category == 1 ? '传统服务' : typeEnum[item.type],
       statusText: statusEnum[item.status],
-      serviceCategoryText: serviceCategoryEnum[item.serviceCategory]
+      serviceCategoryText: serviceCategoryEnum[item.serviceCategory],
+      times: formatSecondsToTime(item.times)
     }));
 
     loading.value = false;
@@ -164,13 +186,6 @@ onMounted(() => {
               <el-option label="呼出" :value="1" />
             </el-select>
           </SearchItemWrapper>
-          <SearchItemWrapper label="通话类型">
-            <el-select v-model="queryParams.category" placeholder="全部" size="large" :empty-values="[null, undefined]">
-              <el-option label="全部" value="" />
-              <el-option label="呼入" :value="0" />
-              <el-option label="呼出" :value="1" />
-            </el-select>
-          </SearchItemWrapper>
           <SearchItemWrapper label="服务类型">
             <el-select v-model="queryParams.serviceCategory" placeholder="请选择" size="large" :empty-values="[null, undefined]">
               <el-option label="全部" value="" />
@@ -181,12 +196,12 @@ onMounted(() => {
             <el-date-picker v-model="dataPickerValue" type="daterange" range-separator="-" start-placeholder="起始日期"
               end-placeholder="结束日期" style="width: 100%;" :editable="false" value-format="YYYY-MM-DD"/>
           </SearchItemWrapper>
-          <!-- <SearchItemWrapper label="呼叫类型">
+          <SearchItemWrapper label="呼叫类型">
             <el-select v-model="queryParams.type" placeholder="请选择" size="large" :empty-values="[null, undefined]">
               <el-option label="全部" value="" />
               <el-option v-for="item in callTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
             </el-select>
-          </SearchItemWrapper> -->
+          </SearchItemWrapper>
           <SearchItemWrapper label="通话ID">
             <el-input class="search-input" placeholder="通话ID" v-model="queryParams.sessionId"></el-input>
           </SearchItemWrapper>

+ 676 - 0
src/views/voice/dashboard/echartConfig.js

@@ -0,0 +1,676 @@
+import * as echarts from "echarts";
+import "echarts-gl";
+
+export const getEchartLineOption = ({ xAxisData, seriesData }) => {
+  let colorList = ["#FF7300", "#00F040"];
+  let areaColorList = [
+    { from: "rgba(255,115,0, 0.5)", to: "rgba(255,115,0, 0)" },
+    { from: "rgba(0,240,64, 0.5)", to: "rgba(0,240,64, 0)" },
+  ];
+  return {
+    color: colorList,
+    tooltip: {
+      trigger: "axis",
+    },
+    grid: {
+      bottom: "12%",
+      top: "24%",
+      left: "7%",
+      right: "7%",
+      containLabel: true,
+    },
+    legend: {
+      top: "6%",
+      right: "5%",
+      icon: "rect",
+      itemWidth: 12,
+      itemHeight: 8,
+      itemGap: 12,
+      selectedMode: false,
+      textStyle: {
+        fontSize: 12,
+        color: "#B1C5D6",
+      },
+    },
+    xAxis: [
+      {
+        type: "category",
+        boundaryGap: true,
+        axisTick: {
+          show: false,
+        },
+        axisLabel: {
+          fontSize: "12px",
+          color: "#fff",
+        },
+        axisLine: {
+          lineStyle: {
+            color: "#94A7BD",
+          },
+        },
+        data: xAxisData,
+      },
+    ],
+    yAxis: {
+      type: "value",
+      name: "单位",
+      splitNumber: 2,
+      nameTextStyle: {
+        fontSize: 12,
+        color: "#8FABBF",
+        padding: [0, 0, 20, -30],
+      },
+      axisLabel: {
+        fontSize: "12px",
+        color: "#fff",
+      },
+      axisLine: {
+        lineStyle: {
+          color: "#94A7BD",
+        },
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: "#3D5266",
+          type: [2, 5],
+          dashOffset: 2,
+        },
+      },
+    },
+    series: seriesData.map((item, index) => {
+      return {
+        name: item.name,
+        type: "line",
+        smooth: true,
+        symbol: "none",
+        symbolSize: 8,
+        zlevel: 3,
+        areaStyle: {
+          color: {
+            type: "linear",
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              {
+                offset: 0,
+                color: areaColorList[index].from,
+              },
+              {
+                offset: 1,
+                color: areaColorList[index].to,
+              },
+            ],
+            global: false,
+          },
+        },
+        data: item.data.map((item) => item.count)
+      };
+    }),
+  };
+};
+
+export const getEchart3dOption = (params) => {
+
+  const colorList = [
+    '#F5B84D',
+    '#209FED',
+    '#808EC7',
+    '#EF7F35',
+    '#30A1B3',
+    '#00C3FF',
+    '#1BC982',
+  ]
+
+  const dataList = params.map((item, index) => {
+    const name = index < 3 ? `top${index + 1} ${item.name || '其他'}` : item.name || '其他'
+    return {
+      name,
+      val: item.total || 0,
+      percent: item.percent,
+      itemStyle: {
+        color: colorList[index],
+      }
+    }
+  })
+
+  const heightProportion = 0.2; // 柱状扇形的高度比例
+
+  // 生成扇形的曲面参数方程,用于 series-surface.parametricEquation
+  function getParametricEquation(
+    startRatio,
+    endRatio,
+    isSelected,
+    isHovered,
+    k,
+    height
+  ) {
+    // 计算
+    let midRatio = (startRatio + endRatio) / 3;
+
+    let startRadian = startRatio * Math.PI * 2;
+    let endRadian = endRatio * Math.PI * 2;
+    let midRadian = midRatio * Math.PI * 2;
+
+    // 如果只有一个扇形,则不实现选中效果。
+    if (startRatio === 0 && endRatio === 1) {
+      isSelected = false;
+    }
+
+    // 通过扇形内径/外径的值,换算出辅助参数 k(默认值 1/3)
+    k = typeof k !== "undefined" ? k : 1 / 3;
+
+    // 计算选中效果分别在 x 轴、y 轴方向上的位移(未选中,则位移均为 0)
+    let offsetX = isSelected ? Math.cos(midRadian) * 0.1 : 0;
+    let offsetY = isSelected ? Math.sin(midRadian) * 0.1 : 0;
+
+    // 计算高亮效果的放大比例(未高亮,则比例为 1)
+    let hoverRate = isHovered ? 1.1 : 1;
+
+    // 返回曲面参数方程
+    return {
+      u: {
+        min: -Math.PI,
+        max: Math.PI * 3,
+        step: Math.PI / 32,
+      },
+
+      v: {
+        min: 0,
+        max: Math.PI * 2,
+        step: Math.PI / 20,
+      },
+
+      x: function (u, v) {
+        if (u < startRadian) {
+          return (
+            offsetX + Math.cos(startRadian) * (1 + Math.cos(v) * k) * hoverRate
+          );
+        }
+        if (u > endRadian) {
+          return (
+            offsetX + Math.cos(endRadian) * (1 + Math.cos(v) * k) * hoverRate
+          );
+        }
+        return offsetX + Math.cos(u) * (1 + Math.cos(v) * k) * hoverRate;
+      },
+
+      y: function (u, v) {
+        if (u < startRadian) {
+          return (
+            offsetY + Math.sin(startRadian) * (1 + Math.cos(v) * k) * hoverRate
+          );
+        }
+        if (u > endRadian) {
+          return (
+            offsetY + Math.sin(endRadian) * (1 + Math.cos(v) * k) * hoverRate
+          );
+        }
+        return offsetY + Math.sin(u) * (1 + Math.cos(v) * k) * hoverRate;
+      },
+
+      z: function (u, v) {
+        if (u < -Math.PI * 0.5) {
+          return Math.sin(u);
+        }
+        if (u > Math.PI * 2.5) {
+          return Math.sin(u);
+        }
+        return Math.sin(v) > 0 ? heightProportion * height : -1;
+      },
+    };
+  }
+
+  function getHeight3D (series, height) {
+    series.sort((a, b) => {
+      return b.pieData.value - a.pieData.value;
+    });
+    return (height * 20) / series[0].pieData.value;
+  }
+
+  // 生成模拟 3D 饼图的配置项
+  function getPie3D(pieData, internalDiameterRatio) {
+    let series = [];
+    let sumValue = 0;
+    let startValue = 0;
+    let endValue = 0;
+    let legendData = [];
+    let linesSeries = []; // line3D模拟label指示线
+    let k = 1
+
+    // 为每一个饼图数据,生成一个 series-surface 配置
+    for (let i = 0; i < pieData.length; i++) {
+      sumValue += pieData[i].value;
+
+      let seriesItem = {
+        name:
+          typeof pieData[i].name === "undefined"
+            ? `series${i}`
+            : pieData[i].name,
+        type: "surface",
+        parametric: true,
+        wireframe: {
+          show: false,
+        },
+        pieData: pieData[i],
+        pieStatus: {
+          selected: false,
+          hovered: false,
+          k: k,
+        },
+      };
+
+      if (typeof pieData[i].itemStyle != "undefined") {
+        let itemStyle = {};
+
+        typeof pieData[i].itemStyle.color != "undefined"
+          ? (itemStyle.color = pieData[i].itemStyle.color)
+          : null;
+        typeof pieData[i].itemStyle.opacity != "undefined"
+          ? (itemStyle.opacity = pieData[i].itemStyle.opacity)
+          : null;
+
+        seriesItem.itemStyle = itemStyle;
+      }
+
+      series.push(seriesItem);
+    }
+
+    // 使用上一次遍历时,计算出的数据和 sumValue,调用 getParametricEquation 函数,
+    // 向每个 series-surface 传入不同的参数方程 series-surface.parametricEquation,也就是实现每一个扇形。
+    for (let i = 0; i < series.length; i++) {
+      endValue = startValue + series[i].pieData.value;
+
+      series[i].pieData.startRatio = startValue / sumValue;
+      series[i].pieData.endRatio = endValue / sumValue;
+      series[i].parametricEquation = getParametricEquation(
+        series[i].pieData.startRatio,
+        series[i].pieData.endRatio,
+        false,
+        false,
+        k,
+        series[i].pieData.value
+      );
+
+      startValue = endValue;
+
+      legendData.push(series[i].name);
+    }
+    series = series.concat(linesSeries);
+
+    return series;
+  }
+
+  let total = 0;
+
+  dataList.forEach((item) => {
+    total += item.val;
+  });
+
+  const series = getPie3D(
+    dataList.map((item) => {
+      item.value = Number(((item.val / total) * 100).toFixed(2));
+      return item;
+    }),
+    1
+  );
+
+  const boxHeight = getHeight3D(series, 15);
+  
+  // 准备待返回的配置项,把准备好的 legendData、series 传入。
+  const option = {
+    // graphic: {
+    //   elements: [
+    //     {
+    //       type: "image",
+    //       style: {
+    //         // image: image,
+    //         image: "https://static.fuxicarbon.com/bigModel/pc/bg-3d-echart.png",
+    //         width: 352,
+    //         height: 146,
+    //         cursor: 'none'
+    //       },
+    //       bottom: "8%",
+    //     },
+    //   ],
+    // },
+
+    legend: {
+      selectedMode: false,
+      show: true,
+      data: dataList.map((item) => item.name),
+      orient: "vertical",
+      icon: "circle",
+      top: "center",
+      itemGap: 20,
+      itemHeight: 10,
+      itemWidth: 10,
+      right: "4%",
+      textStyle: {
+        rich: {
+          name: {
+            width: 140,
+            padding: [0, 10, 0, 0],
+            fontSize: 14,
+            color: "#fff",
+          },
+          value: {
+            fontSize: 14,
+            color: "rgba(157, 219, 255, 1)",
+            fontFamily: "D-DIN-PRO-700-Bold",
+          },
+          unit: {
+            fontSize: 14,
+            color: "rgba(157, 219, 255, 1)",
+          },
+        },
+        color: "red",
+        fontSize: 14,
+      },
+      formatter: function (name) {
+        const value = dataList.find((item) => item.name === name).percent;
+        return `{name|${name}} {value|${value}} {unit|%}`;
+      },
+    },
+
+    tooltip: {
+      formatter: (params) => {
+        if (
+          params.seriesName !== "mouseoutSeries" &&
+          params.seriesName !== "pie2d"
+        ) {
+          return `${
+            params.seriesName
+          }<br/><span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${
+            params.color
+          };"></span>${option.series[params.seriesIndex].pieData.val}`;
+        }
+      },
+    },
+
+    animation: false,
+
+    label: {
+      show: false,
+    },
+    xAxis3D: {
+      min: -1.5,
+      max: 1.5,
+    },
+    yAxis3D: {
+      min: -1.5,
+      max: 1.5,
+    },
+    zAxis3D: {
+      min: -1,
+      max: 1,
+    },
+    grid3D: {
+      show: false,
+      boxHeight: boxHeight,
+      top: "0%",
+      left: "-20%",
+      width: '100%',
+      // width: '352px',
+      viewControl: {
+        distance: 220,
+        alpha: 25,
+        minAlpha: 25,
+        maxAlpha: 25,
+        beta: 60,
+        autoRotate: true, // 自动旋转
+        zoomSensitivity: 0,
+        rotate: false,
+        // 禁用缩放
+        zoom: false,
+        // 禁用平移
+        pan: false,
+        // 如果你还想禁用其他交互行为,可以继续添加相关配置
+        dragToPan: false, // 确保拖拽不会改变中心点
+        pinchToZoom: false, // 确保捏合手势不会触发缩放
+      },
+    },
+    series: series,
+  };
+  return option;
+};
+
+export const getEchartBarOption = ({ xAxisData, seriesData, seriesData1 }) => {
+  let legendData = ["近七月", "同比"];
+  return {
+    tooltip: {
+      trigger: "axis",
+      axisPointer: {
+        type: "shadow",
+      },
+    },
+    legend: {
+      selectedMode: false,
+      data: [
+        {
+          name: legendData[0],
+          itemStyle: {
+              color: '#C99E0A'
+          }
+        },
+        {
+          name: legendData[1],
+          itemStyle: {
+              color: '#0264B5'
+          }
+        },
+      ],
+      top: "6%",
+      right: "5%",
+      icon: "rect",
+      itemWidth: 7,
+      itemHeight: 7,
+      itemGap: 15,
+      textStyle: {
+        color: "#89BFE5",
+        fontSize: 12,
+      },
+    },
+    grid: {
+      bottom: "12%",
+      top: "24%",
+      left: '7%',
+      right: "7%",
+      containLabel: true,
+    },
+    xAxis: {
+      type: "category",
+      data: xAxisData,
+      axisLine: {
+        lineStyle: {
+          color: "#94A7BD",
+        },
+      },
+      axisLabel: {
+        fontSize: "12px",
+        color: "#fff",
+      },
+      axisTick: {
+        show: false,
+      },
+    },
+    yAxis: [
+      {
+        type: "value",
+        inverse: false,
+        splitLine: {
+          show: true,
+          lineStyle: {
+            color: "rgba(117, 168, 202, 0.3)",
+            type: "dashed",
+          },
+        },
+        axisLine: {
+          show: false,
+          lineStyle: {
+            color: "##89BFE5",
+          },
+        },
+        axisLabel: {
+          fontSize: "12px",
+          color: "#fff",
+        },
+        axisTick: {
+          show: false,
+        },
+      },
+    ],
+    series: [
+      // 左
+      {
+        name: legendData[0],
+        type: "bar",
+        barWidth: 8,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(255,214,0, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(88, 55, 15, 0.6)",
+              },
+            ]),
+          },
+        },
+        data: seriesData,
+      },
+      // 右
+      {
+        name: legendData[0],
+        type: "bar",
+        barWidth: 10,
+        barGap: 0,
+        data: seriesData,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(178, 149, 0, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(255,138,0, 0.1)",
+              },
+            ]),
+          },
+        },
+        tooltip: {
+          show: false,
+        },
+      },
+      // 上部
+      {
+        name: legendData[0],
+        type: "pictorialBar",
+        symbolSize: [19.6, 5.5],
+        symbolOffset: [-9, -3],
+        symbolPosition: "end",
+        symbol: "diamond",
+        z: 12,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(178, 138, 0, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(252, 255, 108, 0.8)",
+              },
+            ]),
+          },
+        },
+        tooltip: {
+          show: false,
+        },
+        data: seriesData,
+      },
+      // 左
+      {
+        name: legendData[1],
+        type: "bar",
+        barWidth: 8,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(0, 147, 221, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(0, 88, 255, 0.2)",
+              },
+            ]),
+          },
+        },
+        data: seriesData1,
+      },
+      // 右
+      {
+        name: legendData[1],
+        type: "bar",
+        barWidth: 10,
+        barGap: 0,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(0, 67, 123, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(0, 67, 123, 0)",
+              },
+            ]),
+          },
+        },
+        data: seriesData1,
+        tooltip: {
+          show: false,
+        },
+      },
+      // 上部
+      {
+        name: legendData[1],
+        type: "pictorialBar",
+        symbolSize: [18, 5],
+        symbolOffset: [8, -3],
+        symbolPosition: "end",
+        symbol: "diamond",
+        z: 12,
+        itemStyle: {
+          normal: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              {
+                offset: 0,
+                color: "rgba(0, 114, 221, 1)",
+              },
+              {
+                offset: 1,
+                color: "rgba(129, 228, 255, 1)",
+              },
+            ]),
+          },
+        },
+        tooltip: {
+          show: false,
+        },
+        data: seriesData1,
+      },
+    ],
+  };
+};

+ 434 - 0
src/views/voice/dashboard/index.vue

@@ -0,0 +1,434 @@
+<script setup>
+import dayjs from 'dayjs';
+import autofit from 'autofit.js'
+import * as echarts from 'echarts';
+import useUserStore from '@/store/modules/user';
+import { dashboardApi } from '@/api/voice/dashboard';
+import { getEchartLineOption, getEchart3dOption, getEchartBarOption } from './echartConfig';
+
+const userStore = useUserStore();
+
+const screenData = ref({});
+
+const dateInfo = ref({
+  weekDay: '',
+  today: '',
+  time: ''
+})
+
+let timer = null;
+let myLineChart = null;
+let my3dChart = null;
+let myBarChart = null;
+
+const echartLineRef = ref(null);
+const echart3dRef = ref(null);
+const echartBarRef = ref(null);
+
+const updateTime = () => {
+  const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
+  const todayWeekDay = dayjs().day();
+  dateInfo.value = {
+    weekDay: '星期' + weekDays[todayWeekDay],
+    today: dayjs().format('YYYY-MM-DD'),
+    time: dayjs().format('HH:mm:ss')
+  }
+}
+
+const formatSeconds = (seconds, num) => {
+  // 将秒转换为分钟
+  let minutes = seconds / num;
+
+  // 使用 toLocaleString 方法格式化数字,保留两位小数
+  // 并添加千位分隔符
+  let formattedMinutes = minutes.toLocaleString('en-US', {
+      minimumFractionDigits: 2,
+      maximumFractionDigits: 2
+  });
+
+  return formattedMinutes;
+}
+
+const formatSecondsToTime = (seconds) => {
+  // 计算完整的分钟数和剩余的秒数
+  let minutes = Math.floor(seconds / 60);
+  let remainingSeconds = seconds % 60;
+
+  // 如果有超过60分钟的部分,计算小时
+  let hours = Math.floor(minutes / 60);
+  minutes = minutes % 60;
+
+  // 确保分钟和秒数都是两位数,不足两位前面补零
+  let formattedMinutes = String(minutes).padStart(2, '0');
+  let formattedSeconds = String(remainingSeconds).padStart(2, '0');
+
+  // 如果有小时,则包含在输出中
+  if (hours > 0) {
+    let formattedHours = String(hours).padStart(2, '0');
+    return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
+  } else {
+    return `${formattedMinutes}:${formattedSeconds}`;
+  }
+}
+
+const initEchart = async() => {
+  const { data } = await dashboardApi.getHomeScreenCount()
+  const { 
+    recent7DayAndCounts, recent7DayAndCountsLastYear,
+    recent7MonthAndCounts, recent7MonthAndCountsLastYear,
+    businessTop7
+   } = data;
+
+  screenData.value = {
+    ...data,
+    inTimes: formatSeconds(data.inTimes, 60),
+    inTimesAvg: formatSecondsToTime(data.inTimesAvg),
+    totalTimes: formatSeconds(data.totalTimes, 3600),
+    robotInTimes: formatSeconds(data.robotInTimes, 60),
+    robotTimesAvg: formatSecondsToTime(data.robotTimesAvg),
+    humanTimesAvg: formatSecondsToTime(data.humanTimesAvg),
+    humanInTimes: formatSeconds(data.humanInTimes, 60)
+  };
+
+  // 近7日电话呼入量 - 折线图
+  const lineSeriesData = [
+    { name: '近七日', data:recent7DayAndCounts },
+    { name: '同比', data:recent7DayAndCountsLastYear }
+  ];
+  const lineXAxisData = recent7DayAndCounts.map(item => dayjs(item.date).format('MM.DD'))
+  
+  // 近七日呼入电话量趋势
+  const barXAxisData = recent7MonthAndCounts.map(item => dayjs(item.date).format('MM-DD'))
+  const seriesData = recent7MonthAndCounts.map(item => item.count)
+  const seriesData1 = recent7MonthAndCountsLastYear.map(item => item.count)
+
+  myLineChart = echarts.init(echartLineRef.value);
+  my3dChart = echarts.init(echart3dRef.value);
+  myBarChart = echarts.init(echartBarRef.value);
+
+  myLineChart.setOption(getEchartLineOption({
+    xAxisData: lineXAxisData,
+    seriesData: lineSeriesData
+  }));
+
+  my3dChart.setOption(getEchart3dOption(businessTop7));
+
+  myBarChart.setOption(getEchartBarOption({
+    barXAxisData,
+    seriesData,
+    seriesData1
+  }));
+}
+
+const windowResize = () => {
+  setTimeout(() => {
+    myLineChart.resize();
+    // my3dChart.resize();
+    // myBarChart.resize();
+  }, 300)
+}
+
+onMounted(() => {
+  autofit.init({
+    dw: 1920,
+    dh: 1080,
+    el:"#screen-view",
+    resize: true,
+  })
+
+  timer = setInterval(updateTime, 1000);
+
+  updateTime();
+
+  initEchart();
+
+  window.addEventListener('resize', windowResize, false);
+
+});
+
+onUnmounted(() => {
+  clearInterval(timer);
+})
+</script>
+
+<template>
+  <div class="dashboard-viewport">
+    <div id="screen-view">
+    <div class="header">
+      <div class="date space-x-[24px]">
+        <div class="time">{{ dateInfo.time }}</div>
+        <ul class="week">
+          <li>{{dateInfo.weekDay}}</li>
+          <li class="num">{{ dateInfo.today }}</li>
+        </ul>
+      </div>
+      <div class="line"></div>
+      <div class="user space-x-[10px]">
+        <img :src="userStore.avatar" alt="" class="avatar">
+        <span>{{ userStore.nickName }}</span>
+      </div>
+    </div>
+    <div class="main space-y-[20px]">
+      <div class="top">
+        <div class="count-list">
+          <div class="count-item"><p class="num">{{ screenData.inTotal }}</p><p>近7天呼入总量</p></div>
+          <div class="count-item"><p class="num">{{ screenData.successRate }}%</p><p>近7天接通率</p></div>
+          <div class="count-item"><p class="num">{{ screenData.inTimes }}<span class="text-[14px] text-[#fff] opacity-70">分钟</span></p><p>近7天呼入时长</p></div>
+          <div class="count-item"><p class="num">{{ screenData.inTimesAvg }}</p><p>近7天平均呼入时长</p></div>
+          <div class="count-item"><p class="num">{{ screenData.totalTimes }}<span class="text-[14px] text-[#fff] opacity-70">小时</span></p><p>累计呼入时长</p></div>
+          <div class="count-item"><p class="num">{{ screenData.totalCounts }}</p><p>累计呼入总量</p></div>
+        </div>
+      </div>
+      <div class="middle">
+        <div class="echart-list">
+          <div class="echart-item">
+            <div class="content" ref="echartLineRef"></div>
+          </div>
+          <div class="echart-item">
+            <div class="content echart-content" ref="echart3dRef"></div>
+          </div>
+          <div class="echart-item">
+            <div class="content" ref="echartBarRef"></div>
+          </div>
+        </div>
+      </div>
+      <div class="bottom">
+        <div class="left">
+          <ul class="statistic-list">
+            <li class="statistic-item"><p class="title">今日呼入总量</p><p class="num">{{ screenData.robotInTotal }}</p></li>
+            <li class="statistic-item"><p class="title">通话时长</p><p class="num space-x-[4px]"><span>{{ screenData.robotInTimes }}</span><span class="unit">分钟</span></p></li>
+            <li class="statistic-item"><p class="title">平均通话时长</p><p class="num">{{ screenData.robotTimesAvg }}</p></li>
+            <li class="statistic-item"><p class="title">转人工总量</p><p class="num">{{ screenData.transfer2Human }}</p></li>
+            <li class="statistic-item"><p class="title">当前排队总数</p><p class="num">300</p></li>
+            <li class="statistic-item"><p class="title">分流比例</p><p class="num">{{ screenData.robotStreamRate }}%</p></li>
+          </ul>
+        </div>
+        <div class="right">
+          <ul class="statistic-list">
+            <li class="statistic-item"><p class="title">今日呼入总量</p><p class="num">{{ screenData.humanInTotal }}</p></li>
+            <li class="statistic-item"><p class="title">通话时长</p><p class="num space-x-[4px]"><span>{{ screenData.humanInTimes }}</span><span class="unit">分钟</span></p></li>
+            <li class="statistic-item"><p class="title">平均通话时长</p><p class="num">{{ screenData.humanTimesAvg }}</p></li>
+            <li class="statistic-item"><p class="title">置闲人数</p><p class="num">{{ screenData.onlineTotal }}</p></li>
+            <li class="statistic-item"><p class="title">当前排队总数</p><p class="num"></p></li>
+            <li class="statistic-item"><p class="title">分流比例</p><p class="num">{{ screenData.humanStreamRate }}%</p></li>
+          </ul>
+        </div>
+      </div>
+    </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.dashboard-viewport {
+  width: 100vw;
+  height: 100vh;
+  overflow: hidden;
+  background: url('@/assets/images/dashboard/bg-main.png') no-repeat;
+  background-size: 100% 100%;
+
+  #screen-view  {
+    height: 100%;
+    display: flex;
+    flex-flow: column;
+    padding-bottom: 30px;
+  }
+}
+
+.header {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  height: 110px;
+  padding: 26px 26px 46px 26px;
+  background: url('@/assets/images/dashboard/bg-title.png') no-repeat;
+  background-size: cover;
+  color: #fff;
+
+  .date {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    color: #BFDFFF;
+    .time {
+      width: 120px;
+      text-shadow: 0px 1px 3px rgba(5, 12, 25, 0.54);
+      font-family: D-DIN-PRO;
+      font-size: 36px;
+      font-weight: bold;
+    }
+    .week {
+      text-align: center;
+      font-size: 16px;
+      .num {
+        font-size: 12px;
+        font-family: D-DIN-PRO;
+      }
+    }
+  }
+
+  .line {
+    width: 2px;
+    height: 30px;
+    margin: 0 38px;
+    flex-shrink: 0;
+    background: rgba(205, 233, 251, 0.32);
+  }
+
+  .user {
+    display: flex;
+    align-items: center;
+    color: #FFF;
+    font-size: 16px;
+    .avatar {
+      width: 32px;
+      height: 32px;
+      border-radius: 50%;
+      background: #fff;
+    }
+  }
+}
+
+.main {
+  justify-content: space-between;
+  padding: 30px 50px 18px 50px;
+  .top {
+    height: 120px;
+    flex-shrink: 0;
+    .count-list {
+      display: grid;
+      gap: 12px;
+      grid-template-columns: repeat(6, minmax(260px, 1fr));
+      height: 100%;
+      .count-item:nth-child(1) {
+        background: url("@/assets/images/dashboard/bg-count-01.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item:nth-child(2) {
+        background: url("@/assets/images/dashboard/bg-count-02.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item:nth-child(3) {
+        background: url("@/assets/images/dashboard/bg-count-03.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item:nth-child(4) {
+        background: url("@/assets/images/dashboard/bg-count-04.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item:nth-child(5) {
+        background: url("@/assets/images/dashboard/bg-count-05.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item:nth-child(6) {
+        background: url("@/assets/images/dashboard/bg-count-06.png") no-repeat;
+        background-size: contain;
+      }
+      .count-item {
+        padding: 20px 0 0 22px;
+        color: #FFF;
+        .num {
+          font-family: D-DIN-PRO;
+          font-weight: bold;
+          font-size: 34px;
+        }
+        .text {
+          color: #E6FFF5;
+          font-size: 16px;
+          font-weight: 400px;
+        }
+      }
+    }
+  }
+  .middle {
+    height: 346px;
+    .echart-list {
+      display: flex;
+      align-items: center;
+      // display: grid;
+      // grid-template-columns: repeat(3, 1fr);
+      // gap: 12px;
+      height: 100%; 
+      .echart-item:nth-child(1) {
+        margin-right: 12px;
+        background: url('@/assets/images/dashboard/bg-middle-title-01.png') center top no-repeat, url('@/assets/images/dashboard/bg-middle.png') center center no-repeat;
+        background-size: contain, 100% 100%;
+      }
+      .echart-item:nth-child(2) {
+        margin-right: 12px;
+        background: url('@/assets/images/dashboard/bg-middle-title-02.png') center top no-repeat, url('@/assets/images/dashboard/bg-middle.png') center center no-repeat;
+        background-size: contain, 100% 100%;
+      }
+      .echart-item:nth-child(3) {
+        background: url('@/assets/images/dashboard/bg-middle-title-03.png') center top no-repeat, url('@/assets/images/dashboard/bg-middle.png') center center no-repeat;
+        background-size: contain, 100% 100%;
+      }
+      .echart-item {
+        width: 33%;
+        height: 100%;
+        padding-top: 44px;
+      }
+      .content {
+        width: 100%;
+        height: 100%;
+      }
+      .echart-content {
+        background: url('https://static.fuxicarbon.com/bigModel/pc/bg-3d-echart.png') left center no-repeat;
+        background-size: 60% 50%;
+        background-position: 0px 80%;
+      }
+    }
+  }
+  .bottom {
+    flex-shrink: 0;
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 12px;
+    height: 350px;
+    .left {
+      padding-top: 44px;
+      background: url('@/assets/images/dashboard/bg-middle-title-04.png') left top no-repeat, url('@/assets/images/dashboard/bg-bottom.png') left center no-repeat;
+      background-size: 598px 44px, 100% 100%;
+    }
+    .right {
+      padding-top: 44px;
+      background: url('@/assets/images/dashboard/bg-middle-title-05.png') left top no-repeat, url('@/assets/images/dashboard/bg-bottom.png') left center no-repeat;
+      background-size: 598px 44px, 100% 100%;
+    }
+    .statistic-list {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      grid-template-rows: repeat(2, 1fr);
+      gap: 15px;
+      height: 100%; 
+      padding: 30px 42px;
+      .statistic-item {
+        height: 100%;
+        padding: 20px 0 0 36px;
+        background: url('@/assets/images/dashboard/bg-bottom-item.png') center center no-repeat;
+        background-size: 100% 100%;
+        .title {
+          color: #E6FFF5;
+          font-size: 18px;
+          line-height: 26px;
+          letter-spacing: 2.25px;
+        }
+        .num {
+          color: #FFF;
+          text-shadow: 0px 0px 3.75px rgba(58, 206, 237, 0.50);
+          font-family: D-DIN-PRO;
+          font-size: 36px;
+          font-weight: bold;
+        }
+        .unit {
+          color:  #9c9c9c;
+          font-size: 18px;
+          font-weight: 400;
+          letter-spacing: 2.25px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 36 - 130
src/views/voice/general/index.vue

@@ -1,10 +1,7 @@
 <script setup>
-import { callApi, workbenchApi } from '@/api/voice/call';
+import { callApi } from '@/api/voice/call';
 import SearchItemWrapper from '@/components/SearchItemWrapper';
-import AudioPlayer from '@/components/AudioPlayer';
 import useTableHeight from '@/composables/useTableHeight';
-import CallView from '@/components/CallView';
-import dayjs from 'dayjs';
 
 const { proxy } = getCurrentInstance();
 const { tableContainer, tableMaxHeight } = useTableHeight();
@@ -13,113 +10,45 @@ const dataPickerValue = ref([]);
 const loading = ref(false);
 const tableData = ref([]);
 const total = ref(0);
-const agentList = ref([]);
-const drawer = ref(false);
-const callDetails = ref({});
-
-const isTransitionVoiceStatus = ref(false);
 
 const queryParams = ref({
   pageNum: 1,
-  pageSize: 10,
-  userId: '',
-  status: '',
-  category: '',
-  phone: '',
-  serviceCategory: ''
+  pageSize: 10
 })
 
-const serviceCategoryOptions = [
-  { value: 0, label: '人工坐席' },
-  { value: 1, label: '机器人坐席' },
-  { value: 2, label: '机器人转人工' }
-]
-
-const callTypeOptions = [
-  { value: 0, label: '白名单' },
-  { value: 1, label: 'AI客服' },
-  { value: 2, label: '传统服务' }
-]
-
+const tableListData = computed(() => {
+  const { pageNum, pageSize } = queryParams.value;
+  return tableData.value.slice((pageNum-1)*pageSize,pageNum * pageSize) || []
+})
 
 // 清除检索条件
 const handleCleanOptions = () => {
   queryParams.value = {
     pageNum: 1,
-    pageSize: 10,
-    userId: '',
-    status: '',
-    category: '',
-    phone: '',
-    serviceCategory: ''
+    pageSize: 10
   };
+  total.value = 0;
   dataPickerValue.value = [];
-  getList();
-}
-
-// 语音转化完成
-const handleVoiceParsed = (item) => {
-  const { parsedVoiceContent } = item;
-  callDetails.value.parsedVoiceContent = parsedVoiceContent;
+  tableData.value = [];
 }
 
-const jumpDetails = (item) => {
-  callDetails.value = item;
-  drawer.value = true;
-}
-
-// 音频加载完成
-const onAudioLoadDone = ({ durationTime, id }) => {
-  tableData.value.map(item => {
-    if (item.id == id) {
-      item.times = durationTime || ''
-    }
-  })
-}
-
-const handleClose = (done) => {
-  if ( !isTransitionVoiceStatus.value ) {
-    done();
-  } else {
-    proxy.$modal.msgWarning("当前语音正在转换中,请稍后");
+const handleSearch = () => {
+  if (dataPickerValue.value.length === 0) {
+    return proxy.$modal.msgWarning("请先选择统计日期");
   }
-}
-
-// 批量下载
-const handleBatchDownload = () => {
-  const [timeBegin, timeEnd] = dataPickerValue.value;
-  if ( !timeBegin ) {
-    return proxy.$modal.msgError("请选择通话发起时间");
-  }
-  const now = dayjs();
-  const formattedDateTime = now.format('YYYYMMDDHHmmss');
-  const milliseconds = now.millisecond();
-  const fullFormattedString = `jmsyy${formattedDateTime}${milliseconds}`;
-  proxy.getDownload("/business/record/downloadBatchByCondition", {
-    ...queryParams.value, timeBeginReq: timeBegin, timeEndReq: timeEnd
-  }, `${fullFormattedString}.zip`);
-}
-
-// 单独下载
-const handleDownload = ({ id, sessionId }) => {
-  proxy.getDownload("/business/record/downloadById", { id }, `${sessionId}.wav`);
+  getList();
 }
 
 const getList = () => {
+
   const [timeBegin, timeEnd] = dataPickerValue.value || [];
 
   loading.value = true;
 
-  workbenchApi.getCallRecordList({...queryParams.value, timeBeginReq:timeBegin, timeEndReq: timeEnd}).then(({ rows, total:t }) => {
-    const typeEnum = { 0: '白名单', 1: 'AI客服', 2: '传统服务' };
-    const statusEnum = { 0: '未接听', 1: '已接通' };
-    const serviceCategoryEnum = { 0: '人工坐席', 1: '机器人坐席', 2: '机器人转人工' };
+  callApi.getCallRecordCountPageList({...queryParams.value, timeBeginReq:timeBegin, timeEndReq: timeEnd}).then(({ rows, total:t }) => {
+
     tableData.value = rows.map(item => ({
       ...item,
-      url: item.url ? item.url + '?timstamp=' + new Date().getTime() : '',
-      typeText: item.category == 1 ? '传统服务' : typeEnum[item.type],
-      statusText: statusEnum[item.status],
-      serviceCategoryText: serviceCategoryEnum[item.serviceCategory]
     }));
 
     loading.value = false;
@@ -127,12 +56,7 @@ const getList = () => {
   })
 };
 
-onMounted(() => {
-  workbenchApi.getAgentList().then(({ data }) => {
-    agentList.value = data;
-  })
-  getList();
-})
+onMounted(() => {})
 
 </script>
 
@@ -140,48 +64,43 @@ onMounted(() => {
   <div class="call-viewprot">
     <div class="search-card">
       <div class="grid grid-cols-4 gap-[24px]">
-        <SearchItemWrapper label="通话发起时间">
+        <SearchItemWrapper label="统计日期">
           <el-date-picker v-model="dataPickerValue" type="daterange" range-separator="-" start-placeholder="起始日期"
             end-placeholder="结束日期" style="width: 100%;" :editable="false" value-format="YYYY-MM-DD"/>
         </SearchItemWrapper>
         <div class="flex items-center justify-start space-x-[20px]">
-          <div class="custom-btn custom-btn_primary" @click="getList">搜索</div>
-          <div class="custom-btn custom-btn_default" @click="handleBatchDownload">重置</div>
+          <div class="custom-btn custom-btn_primary" @click="handleSearch">搜索</div>
+          <div class="custom-btn custom-btn_default" @click="handleCleanOptions">重置</div>
         </div>
       </div>
     </div>
     <div class="table-card">
       <div style="height: 100%;" ref="tableContainer">
-        <el-table :data="tableData" style="width: 100%" :max-height="tableMaxHeight" v-loading="loading">
+        <el-table :data="tableListData" style="width: 100%" :max-height="tableMaxHeight" v-loading="loading">
           <el-table-column prop="date" label="统计日期" align="center" width="130" fixed />
-          <el-table-column prop="inTotal" label="呼入总量" align="center" width="100">
+          <el-table-column prop="inTotal" label="呼入总量" align="center" min-width="120" />
+          <el-table-column prop="successTotal" label="接通总量" align="center" min-width="120" />
+          <el-table-column prop="failTotal" label="未接通总量" align="center" min-width="120" />
+          <el-table-column prop="robotHearTotal" label="机器人接听" align="center" min-width="120" />
+          <el-table-column prop="transferTotal" label="机器人转人工" align="center" min-width="120" />
+          <el-table-column prop="humanTotal" label="人工接听" align="center" min-width="120" />
+          <el-table-column prop="robotHandleTotal" label="机器人处理量" align="center" min-width="120" />
+          <el-table-column prop="humanHandleTotal" label="人工处理量" align="center" min-width="120"/>
+          <el-table-column prop="robotRate" label="机器人处理率" align="center" min-width="120">
             <template #default="scope">
-              <span>{{ !scope.row.category && scope.row.category != 0 ? '' : scope.row.category == 0 ? '呼入' : '呼出' }}</span>
+              {{ scope.row.robotRate }}%
             </template>
           </el-table-column>
-          <el-table-column prop="successTotal" label="接通总量" align="center" width="100" />
-          <el-table-column prop="failTotal" label="未接通总量" align="center" width="100" />
-          <el-table-column prop="robotHearTotal" label="机器人接听" align="center" width="100">
+          <el-table-column prop="humanRate" label="人工处理率" align="center" min-width="">
             <template #default="scope">
-              <div class="flex items-center justify-center space-x-[6px]">
-                <span class="w-[6px] h-[6px] rounded-full" :class="[scope.row.status === 1 ? 'bg-[#65C734]': 'bg-[#c75134]']" ></span>
-                <span>{{ scope.row.statusText }}</span>
-              </div>
+              {{ scope.row.humanRate }}%
             </template>
           </el-table-column>
-          <el-table-column prop="transferTotal" label="机器人转人工" align="center" width="200" />
-          <el-table-column prop="humanTotal" label="人工接听" align="center" width="180" />
-          <el-table-column prop="robotHandleTotal" label="机器人处理量" align="center" width="180" />
-          <el-table-column prop="humanHandleTotal" label="人工处理量" align="center" width="90"/>
-          <el-table-column prop="robotRate" label="机器人处理率" align="center" width="350">
+          <el-table-column prop="failRate" label="未接通率" align="center" width="140">
             <template #default="scope">
-              <div class="flex justify-center" v-show="scope.row.url">
-                <AudioPlayer :audioUrl="scope.row.url" @loadDone="onAudioLoadDone" :id="scope.row.id"></AudioPlayer>
-              </div>
+              {{ scope.row.failRate }}%
             </template>
           </el-table-column>
-          <el-table-column prop="humanRate" label="人工处理率" align="center" width="140"/>
-          <el-table-column prop="failRate" label="未接通率" align="center" width="140"/>
           <!-- <el-table-column prop="handle" label="操作" align="center" fixed="right" width="150">
             <template #default="scope">
               <div class="flex justify-center space-x-[20px]">
@@ -192,22 +111,9 @@ onMounted(() => {
           </el-table-column> -->
         </el-table>
         <pagination v-show="total >= 0" :total="total" v-model:page="queryParams.pageNum"
-          v-model:limit="queryParams.pageSize" @pagination="getList" />
+          v-model:limit="queryParams.pageSize" />
       </div>
     </div>
-
-    <el-drawer
-      v-model="drawer"
-      title="通话详情"
-      direction="rtl"
-      :before-close="handleClose"
-      class="voice-drawer"
-      size="900"
-    >
-      <div>
-        <CallView :data="callDetails" noInit @on-end="handleVoiceParsed" v-model="isTransitionVoiceStatus"></CallView>
-      </div>
-    </el-drawer>
   </div>
 </template>