Kaynağa Gözat

reactor:新增兴唐呼叫中心大屏监控;

zhangchong 7 ay önce
ebeveyn
işleme
e30e907d63

BIN
src/assets/img/seats/callIn.png


BIN
src/assets/img/seats/callOut.png


+ 8 - 0
src/router/index.ts

@@ -30,6 +30,14 @@ const routes: Array<RouteRecordRaw> = [
       title: `12345坐席监控中心`,
     },
   },
+  {
+    path: "/seatMonitor",
+    name: "seatMonitor",
+    component: () => import("@/views/seatMonitor/index.vue"),
+    meta: {
+      title: `12345坐席监控中心`,
+    },
+  },
   {
     path: "/judicial",
     name: "judicial",

+ 14 - 13
src/utils/mitt.ts

@@ -1,20 +1,21 @@
 // https://www.npmjs.com/package/mitt 全局事件总线
-import mitt, {Emitter} from 'mitt';
+import mitt, { Emitter } from "mitt";
 
 // mitt 事件类型定义
 declare type MittType = {
-    Send?: string; // 加入分组消息
-    BsDataShowArea1?: string; // 工单办理
-    BsDataShowArea3?: string; // 来电实况
-    BsDataShowArea8?: string; // 工单概览
-    SeatState?: string; // 坐席监控
-    BsSeatStateDataShowArea1?: string; // 24小时话务量
-    BsSeatStateDataShowArea2?: string; // 24小时坐席话务top10
-    BsSeatStateDataShowArea3?: string; // 24小时坐席话务量数据
-    BsSeatStateDataShowArea4?: string; // 数据展示
-    ordercountstatistics: string; // 大屏展示 中上工单数量
-    orderHandlingDetail: string; // 大屏展示 办理中工单概览
-    EnforcementOrderHandlingDetail: string; // 大屏展示 催办中工单概览(司法行政)
+  Send?: string; // 加入分组消息
+  BsDataShowArea1?: string; // 工单办理
+  BsDataShowArea3?: string; // 来电实况
+  BsDataShowArea8?: string; // 工单概览
+  SeatState?: string; // 坐席监控
+  BsSeatStateDataShowArea1?: string; // 24小时话务量
+  BsSeatStateDataShowArea2?: string; // 24小时坐席话务top10
+  BsSeatStateDataShowArea3?: string; // 24小时坐席话务量数据
+  BsSeatStateDataShowArea4?: string; // 数据展示
+  ordercountstatistics: string; // 大屏展示 中上工单数量
+  orderHandlingDetail: string; // 大屏展示 办理中工单概览
+  EnforcementOrderHandlingDetail: string; // 大屏展示 催办中工单概览(司法行政)
+  monitorInfo: string; // 监听分机消息
 };
 // 类型
 const emitter: Emitter<MittType> = mitt<MittType>();

+ 0 - 1
src/utils/signalR.ts

@@ -1,6 +1,5 @@
 // 官方文档:https://docs.microsoft.com/zh-cn/aspnet/core/signalr/javascript-client?view=aspnetcore-6.0&viewFallbackFrom=aspnetcore-2.2&tabs=visual-studio
 import * as signalR from "@microsoft/signalr";
-import mittBus from "@/utils/mitt";
 import { getNowDateTime } from "@/utils/constants";
 
 export default {

+ 97 - 0
src/views/seatMonitor/container.vue

@@ -0,0 +1,97 @@
+<template>
+  <div class="index-box">
+    <div class="center_left">
+      <border-box class="center_left-left center_lr-item" title="24小时话务量">
+        <LeftTop :content="state.data1" />
+      </border-box>
+      <border-box
+        class="center_left-center center_lr-item-center"
+        title="24小时坐席话务TOP10"
+        style="margin-top: 10px"
+      >
+        <LeftCenter :content="state.data2" />
+      </border-box>
+      <border-box
+        class="center_left-bottom center_lr-item"
+        title="数据展示"
+        style="margin-top: 10px"
+      >
+        <LeftBottom :data="props.data" :content="state.data3" />
+      </border-box>
+    </div>
+    <div class="center_right">
+      <Right :data="props.data" />
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { onMounted, reactive } from "vue";
+import { agentPaged } from "api/seats";
+import BorderBox from "@/views/seats/border-box.vue";
+import LeftTop from "@/views/seatMonitor/left-top.vue";
+import LeftCenter from "@/views/seatMonitor/left-center.vue";
+import LeftBottom from "@/views/seatMonitor/left-bottom.vue";
+import Right from "@/views/seatMonitor/right.vue";
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+});
+const state = reactive<any>({
+  data1: [],
+  data2: [],
+  data3: {},
+});
+onMounted(async () => {
+  try {
+    const { result } = await agentPaged();
+    state.data1 = result[0] ?? [];
+    state.data2 = result[1] ?? [];
+    state.data3 = {
+      count: result[2] ?? [],
+      list: result[3] ?? [],
+    };
+  } catch (e) {
+    console.log(e);
+  }
+});
+</script>
+<style scoped lang="scss">
+.index-box {
+  width: 100%;
+  display: flex;
+  height: calc(100% - 150px);
+  font-size: var(--el-font-size-base);
+  color: var(--el-color-white);
+}
+
+.center_left {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  position: relative;
+  box-sizing: border-box;
+  flex-shrink: 0;
+  width: 570px;
+  background-color: rgba(0, 0, 0, 0.18);
+  padding: 0 16px;
+  margin-left: 10px;
+  border-radius: var(--el-border-radius-base);
+}
+
+.center_right {
+  box-sizing: border-box;
+  flex: 1;
+  margin-left: 20px;
+}
+
+.center_lr-item {
+  height: 242px;
+}
+
+.center_lr-item-center {
+  height: 360px;
+}
+</style>

+ 437 - 0
src/views/seatMonitor/header.vue

@@ -0,0 +1,437 @@
+<template>
+  <div class="title_wrap">
+    <el-button
+      round
+      class="title_wrap_btn"
+      @click="signIn"
+      v-if="!globalState.callCenterIsSignIn"
+      :icon="Phone"
+      >签入
+    </el-button>
+    <el-button
+      round
+      class="title_wrap_btn"
+      @click="signOut"
+      v-else
+      :icon="PhoneFilled"
+      >签出</el-button
+    >
+    <div class="title">
+      <span class="title-text">{{ title }}</span>
+    </div>
+    <div class="timers">
+      <span class="timers-text">
+        {{ formatDate(now, "YYYY年mm月dd日 HH:MM:SS WWW") }}</span
+      >
+    </div>
+    <div class="guang"></div>
+    <div class="left_icons">
+      <el-badge :value="readyCount" :max="99" class="left_icons_item">
+        <img src="@/assets/img/seats/ready.png" alt="" />空闲
+      </el-badge>
+      <el-badge :value="unreadyCount" :max="99" class="left_icons_item">
+        <img src="@/assets/img/seats/unready.png" alt="" />小休
+      </el-badge>
+      <el-badge :value="threeWayCount" :max="99" class="left_icons_item">
+        <img src="@/assets/img/seats/threeWay.png" alt="" />三方会议
+      </el-badge>
+      <el-badge :value="logoutCount" :max="99" class="left_icons_item">
+        <img src="@/assets/img/seats/logout.png" alt="" />签出
+      </el-badge>
+      <el-badge :value="ringCount" :max="99" class="left_icons_item">
+        <img src="@/assets/img/seats/callIn.png" alt="" />呼入
+      </el-badge>
+    </div>
+    <div class="right_icons">
+      <el-badge :value="ringCount" :max="99" class="right_icons_item">
+        <img src="@/assets/img/seats/callOut.png" alt="" />呼出
+      </el-badge>
+      <el-badge :value="ringCount" :max="99" class="right_icons_item">
+        <img src="@/assets/img/seats/ring.png" alt="" />咨询
+      </el-badge>
+      <el-badge :value="acwCount" :max="99" class="right_icons_item">
+        <img src="@/assets/img/seats/acw.png" alt="" />其他
+      </el-badge>
+      <el-badge :value="busyCount" :max="99" class="right_icons_item">
+        <img src="@/assets/img/seats/busy.png" alt="" />通话
+      </el-badge>
+    </div>
+    <!-- 签入弹窗 -->
+    <el-dialog
+      v-model="state.dutyDialogVisible"
+      draggable
+      title="签入"
+      width="500px"
+      :show-close="false"
+      append-to-body
+    >
+      <el-form :model="state.dutyForm" label-width="80px" ref="dutyFormRef">
+        <el-form-item
+          label="分机"
+          prop="currentTel"
+          :rules="[
+            {
+              required: true,
+              message: '请选择需要签入的分机',
+              trigger: 'change',
+            },
+          ]"
+        >
+          <el-select-v2
+            v-model="state.dutyForm.currentTel"
+            :options="state.telsList"
+            placeholder="选择要签入的分机"
+            filterable
+            class="w-full"
+            :height="500"
+            :props="{
+              label: 'telNo',
+              value: 'telNo',
+            }"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button
+            @click="state.dutyDialogVisible = false"
+            :loading="state.loading"
+            >取 消</el-button
+          >
+          <el-button @click="clickOnDuty(dutyFormRef)" :loading="state.loading"
+            >确 定</el-button
+          >
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
+import signalR from "@/utils/signalR";
+import { useNow, useTitle } from "@vueuse/core";
+import { formatDate } from "@/utils/formatTime";
+import { olaFn } from "@/utils/olaFn";
+import { getNowDateTime } from "@/utils/constants";
+import { callCenterLogout, useGlobalState } from "@/utils/callCenter";
+import { ElMessageBox, FormInstance } from "element-plus";
+import { Phone, PhoneFilled } from "@element-plus/icons-vue";
+import { useThemeConfig } from "@/stores/themeConfig";
+import { storeToRefs } from "pinia";
+import { getListenExtension } from "api/seats";
+
+const storesThemeConfig = useThemeConfig();
+const { themeConfig } = storeToRefs(storesThemeConfig);
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+});
+const now = useNow(); // 获取当前时间
+const state = reactive({
+  dutyDialogVisible: false,
+  dutyForm: {
+    currentTel: null,
+  },
+  loading: false,
+  telsList: [],
+});
+// 签入
+const signIn = () => {
+  if (!globalState.callCenterIsSignIn) {
+    state.dutyDialogVisible = true;
+  } else {
+    globalState.callCenterIsSignIn = false;
+  }
+};
+const dutyFormRef = ref();
+// 确认签入
+const clickOnDuty = (formEl: FormInstance | undefined) => {
+  if (!formEl) return;
+  formEl.validate((valid: boolean) => {
+    if (!valid) return;
+    const currentPhone: any = state.telsList.find(
+      (item: any) => item.telNo === state.dutyForm.currentTel
+    );
+    globalState.currentTel = {
+      telNo: currentPhone.telNo,
+      telGroup: currentPhone.queue,
+      jobNum: currentPhone.TelNo,
+    };
+    websocket_connect(currentPhone.telNo, currentPhone.telPwd);
+    state.dutyDialogVisible = false;
+  });
+};
+// 签出
+const signOut = () => {
+  ElMessageBox.confirm(`签出后无法监听和插话,确定签出?`, "提示", {
+    confirmButtonText: "确认",
+    cancelButtonText: "取消",
+    type: "warning",
+    draggable: true,
+    cancelButtonClass: "default-button",
+    autofocus: false,
+  })
+    .then(async () => {
+      callCenterLogout();
+    })
+    .catch(() => {});
+};
+const globalState = useGlobalState();
+const olaRef = ref();
+const websocket_connect = (telNo: string, telPwd: string) => {
+  olaRef.value = olaFn(themeConfig.value.callCenterSocketUrl, {
+    username: telNo,
+    password: telPwd,
+    onConnected: onConnected, // 连接成功
+    onDisconnected: onDisconnected, // 断开链接
+    onMessage: onMessage, // 接收消息
+    onError: onError, // 错误
+    autoReconnect: {
+      delay: 2000,
+      // retries: 10,
+    }, // 自动重连
+    heartbeat: {
+      message: JSON.stringify({ cmd: "ping" }),
+      interval: 5000,
+      // pongTimeout: 1000,
+    },
+  });
+  console.log(`${getNowDateTime()}:开始链接呼叫中心`);
+};
+// 呼叫中心链接成功
+const onConnected = () => {
+  olaRef.value.logout(globalState.currentTel.telNo); //连接之后,先登出一次,防止其他地方已经登陆
+  let array_ola_queue = []; // 队列
+  // 普通模式
+  let array = globalState.currentTel.telGroup.split(",");
+  for (let i = 0; i < array.length; i++) {
+    array_ola_queue[i] = array[i];
+  }
+  olaRef.value.login(array_ola_queue, globalState.currentTel.telNo, {
+    type: "onhook",
+  });
+  globalState.callCenterWs = olaRef.value;
+  console.log(`${getNowDateTime()}:呼叫中心链接成功`);
+};
+// 呼叫中心链接关闭
+const onDisconnected = (event: any) => {
+  globalState.callCenterWs = null;
+  console.log(`${getNowDateTime()}:呼叫中心断开链接`, event);
+};
+// 呼叫中心链接错误
+const onError = (ws: any, e: any) => {
+  globalState.callCenterIsSignIn = false; // 签出状态
+  console.log(`${getNowDateTime()}:呼叫中心链接错误`, e);
+};
+// 呼叫中心消息
+const onMessage = async (event: any) => {
+  const data = JSON.parse(event);
+  // console.log(`${getNowDateTime()}:接收呼叫中心消息`, event);
+  if (data.event_type == "agent_state") {
+    switch (data.state) {
+      case "login": // 签入消息回调
+        globalState.callCenterIsSignIn = true; // 签入状态
+        setTimeout(() => {
+          // 设置示闲状态
+          olaRef.value.go_ready();
+        }, 300);
+        console.log(
+          `${getNowDateTime()}:接收消息:呼叫中心:已签入,当前分机:${
+            globalState.currentTel.telNo
+          }`
+        );
+        break;
+      case "logout": // 签出消息回调
+        globalState.callCenterIsSignIn = false; // 签出状态
+        console.log(`${getNowDateTime()}:接收消息:呼叫中心:已签出`);
+        break;
+      case "acw": // 话后整理回到
+        // 调用示闲
+        olaRef.value.go_ready(); // 示闲
+        console.log(`${getNowDateTime()}:接收消息:呼叫中心:已示闲`);
+        break;
+      case "unready": // 示忙消息回调
+        // 调用示闲
+        olaRef.value.go_ready(); // 示闲
+        console.log(`${getNowDateTime()}:接收消息:呼叫中心:已示闲`);
+        break;
+    }
+  }
+};
+// 通话数量
+const busyCount = computed(() => {
+  console.log(seatsList.value);
+  return seatsList.value.filter((item: any) => item.state === "busy").length;
+});
+// 振铃
+const ringCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "ring").length;
+});
+// 小休数量、
+const unreadyCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "unready").length;
+});
+// 保持数量
+const heldCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "held").length;
+});
+// 空闲数量
+const readyCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "ready").length;
+});
+// 三方会议数量
+const threeWayCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "threeWay")
+    .length;
+});
+// 话后整理数量
+const acwCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "acw").length;
+});
+// 签出数量
+const logoutCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state === "logout").length;
+});
+const seatsList = ref<any>([]);
+watch(
+  () => props.data,
+  (newData: any) => {
+    seatsList.value = newData;
+  },
+  { immediate: true }
+);
+// 获取可以监听的分机列表
+const getSeatsList = async () => {
+  try {
+    const { result } = await getListenExtension();
+    state.telsList = result;
+  } catch (e) {
+    console.log(e);
+  }
+};
+const title = ref("12345坐席监控中心");
+onMounted(() => {
+  getSeatsList();
+  // 接收消息
+  signalR.SR.on("SeatState", (res: any) => {
+    const item = seatsList.value.find((item: any) => item.telNo === res.telNo);
+    item.loading = true;
+    if (item) {
+      setTimeout(() => {
+        item.state = res.state;
+        item.loading = false;
+      }, 500);
+    }
+  });
+  title.value = `${themeConfig.value.cityName}12345坐席监控中心`;
+  useTitle(title.value);
+});
+onUnmounted(() => {
+  signalR.SR.off("SeatState");
+});
+</script>
+
+<style scoped lang="scss">
+:deep(.el-badge__content) {
+  border: none;
+  top: -3px;
+  right: calc(-3px + var(--el-badge-size) / 2);
+}
+
+.title_wrap {
+  height: 140px;
+  position: relative;
+  &_btn {
+    position: absolute;
+    left: 40px;
+    top: 12px;
+    z-index: 2;
+    border-color: var(--el-color-white);
+  }
+  &::after {
+    content: "";
+    position: absolute;
+    bottom: 13px;
+    background-image: radial-gradient(circle, #1b86d1 50%, #176bb5 50%);
+    width: 100%;
+    height: 2px;
+  }
+
+  .guang {
+    position: absolute;
+    bottom: -13px;
+    background-image: url("../../assets/img/seats/guang.png");
+    background-position: 80px center;
+    width: 100%;
+    height: 56px;
+  }
+
+  .timers {
+    position: absolute;
+    right: 16px;
+    top: 20px;
+    display: flex;
+    align-items: center;
+    color: var(--el-color-white);
+    font-size: var(--el-font-size-extra-large);
+  }
+}
+
+.title {
+  position: relative;
+  text-align: center;
+  background-size: cover;
+  color: transparent;
+  height: 140px;
+  line-height: 90px;
+  &-text {
+    font-size: 38px;
+    font-weight: 900;
+    letter-spacing: 6px;
+    background: linear-gradient(
+      92deg,
+      #0072ff 0%,
+      #00eaff 48.8525390625%,
+      #01aaff 100%
+    );
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+  }
+}
+
+.left_icons {
+  position: absolute;
+  left: 40px;
+  bottom: 25px;
+  display: flex;
+  align-items: center;
+
+  .left_icons_item {
+    display: flex;
+    align-items: center;
+    margin-right: 50px;
+
+    img {
+      margin-right: 5px;
+    }
+  }
+}
+
+.right_icons {
+  position: absolute;
+  right: 16px;
+  bottom: 25px;
+  display: flex;
+  align-items: center;
+  .right_icons_item {
+    display: flex;
+    align-items: center;
+    margin-right: 50px;
+    img {
+      margin-right: 5px;
+    }
+  }
+}
+</style>

+ 613 - 0
src/views/seatMonitor/index.vue

@@ -0,0 +1,613 @@
+<template>
+  <scale-screen
+    width="1920"
+    height="1080"
+    :delay="500"
+    :fullScreen="true"
+    :boxStyle="{
+      overflow: themeConfig.isScale ? 'hidden' : 'auto',
+    }"
+    :autoScale="themeConfig.isScale"
+  >
+    <div class="content_wrap">
+      <Headers :data="seatsList" />
+      <Container :data="seatsList" />
+    </div>
+  </scale-screen>
+</template>
+<script setup lang="ts" name="seats">
+import { defineAsyncComponent, onMounted, ref } from "vue";
+import { storeToRefs } from "pinia";
+import { useThemeConfig } from "@/stores/themeConfig";
+import { useTimeoutFn, useWebSocket } from "@vueuse/core";
+import { useGlobalState } from "@/utils/callCenter";
+import { getNowDateTime } from "@/utils/constants";
+import { ElMessage } from "element-plus";
+import mittBus from "@/utils/mitt";
+
+const ScaleScreen = defineAsyncComponent(
+  () => import("@/components/Scale-screen/index.vue")
+);
+const Headers = defineAsyncComponent(
+  () => import("@/views/seatMonitor/header.vue")
+);
+const Container = defineAsyncComponent(
+  () => import("@/views/seatMonitor/container.vue")
+);
+
+const m_strUserNo = ref("8001"); // 分机号码
+const m_strUserName = ref(""); // 用户名称
+const m_strJobNum = ref("1"); // 坐席工号
+const m_strSkillId = ref("1"); // 技能组
+const m_strLevel = ref("1"); // 优先级别
+const m_strGroup = ref("1"); // 分组ID
+const m_strCompanyId = ref(""); // 企业编码
+const m_bLogin = ref(false); // 登录状态
+const m_bTelBusy = ref(false); // 是否示忙中
+const m_strIsMonitor = ref("1"); // 是否监控分机 1-是监控分机
+const callId = ref(""); // 通话ID
+const m_IsCallOut = ref(false); // 是否呼出
+const m_IsCallIn = ref(false); // 是否是呼入
+const m_strOpenFlag = ref("2"); // 来电弹屏方式 1-接通弹屏;2-振铃弹屏
+const m_CallOutOpen = ref(false); // 呼出是否弹屏(用于未接统计“回拨”业务处理)
+const m_bIsOpen = ref(false); // 是否已经弹屏
+const m_bCallConnect = ref(false); // 是否在通话状态
+const m_IsConsult = ref(false); // 是否咨询
+const m_strConsultType = ref("-1"); // 咨询类型
+const m_IsHangup = ref(false); // 是否挂机
+const m_IsHold = ref(false); // 是否保持
+const m_IsTalkingDeal = ref(false); // 是否通话整理
+const m_IsMonListen = ref("0"); // 监控状态 0-未监听;1-监控成功;2-监控失败;
+const m_strTelState = ref("0"); // 当前状态
+
+const globalState = useGlobalState(); // 全局变量
+const userAlreadyLogin = ref(false);
+// 发送消息
+const e_TelSendMsg = (strObj: Object) => {
+  // 客户端当前时间
+  const strMsg = JSON.stringify(strObj);
+  console.log(`${getNowDateTime()} 发送消息:`, strMsg, wsRef.value.status);
+  if (wsRef.value.ws?.readyState === 1) {
+    // 已经链接并且可以通讯,则发放文本消息
+    wsRef.value.send(strMsg);
+  } else {
+    ElMessage.error("请先签入");
+  }
+};
+// ws实例对象
+const wsRef = ref();
+const storesThemeConfig = useThemeConfig();
+const { themeConfig } = storeToRefs(storesThemeConfig);
+const initWs = () => {
+  // themeConfig.value.callCenterSocketUrl
+  wsRef.value = useWebSocket("ws://123.56.10.71:7681", {
+    heartbeat: {
+      message: JSON.stringify({
+        Action: "ReqHealthCheck",
+        Param: { Extension: m_strUserNo.value },
+      }),
+      interval: 5000,
+      pongTimeout: 5000,
+    },
+    autoReconnect: {
+      delay: 2000,
+    }, // 自动重连
+    immediate: true, // 是否立即链接
+    onMessage: e_TelMsgReceive, // 消息接收
+    onError: e_websocketError, // 错误
+    onDisconnected: e_websocketClose, // 断开
+    onConnected: e_websocketOpen, // 链接成功
+  });
+  // wsRef.value.open();
+};
+// 消息接收
+const e_TelMsgReceive = (ws: any, restMsg: any) => {
+  console.log(`${getNowDateTime()} 接收消息:${restMsg.data}`);
+  if (restMsg.data) {
+    const data = eval("(" + restMsg.data + ")");
+    if (data) {
+      // 方法
+      const strAction = data.Action;
+      switch (strAction) {
+        // 登录返回值
+        case "ResAgentLogin":
+          retSignIn(data);
+          break;
+        // 示闲
+        case "ResAgentIdle":
+          break;
+        // 示忙
+        case "ResAgentBusy":
+          break;
+        // 外呼状态
+        case "ResMakeCall":
+          break;
+        // 保持
+        case "ResHoldCall":
+          break;
+        // 取消保持
+        case "ResRetrieve":
+          break;
+        // 咨询内线
+        case "ResConsultInline":
+          break;
+        // 咨询外线
+        case "ResConsultOutline":
+          break;
+        // 咨询群组
+        case "ResConsultSkillGroup":
+          break;
+        // 咨询转移
+        case "ResTransfer":
+          break;
+        // 三方会议
+        case "ResConference":
+          break;
+        // 三方会议
+        case "ResMonConf":
+          break;
+        // 坐席实时状态
+        case "ResAgentMinitor":
+          ResAgentMonitor(data);
+          break;
+        // 监听
+        case "ResMonListen":
+          retResMonListen(data);
+          break;
+        // 取消监听返回
+        case "ResStopListen":
+          retResStopListen(data);
+          break;
+      }
+      // 事件
+      const strEvent = data.Event;
+      switch (strEvent) {
+        // 签出事件
+        case "EvtLogout":
+          retSignOut();
+          break;
+        // 呼入振铃事件
+        case "EvtCallAlerting":
+          break;
+        // 应答事件
+        case "EvtCallAnswer":
+          break;
+        // 挂机事件
+        case "EvtHangup":
+          break;
+        // 状态
+        case "EvtSeatState":
+          evtSeatState(data);
+          break;
+        // 队列等待
+        case "EvtAcdInfo":
+          break;
+        // 语音识别结果通知事件
+        case "EvtRecognize":
+          break;
+
+        // 转三方接通状态
+        case "EvtDispatchState":
+          break;
+
+        // 签出事件
+        case "ResStopMonitor":
+          retSignOut();
+          break;
+
+        case "ResError": // 异常
+          // 异常处理
+          retResError(data);
+          break;
+        // 呼出接通事件
+        case "EvtOutCalling":
+          break;
+        // 签出事件
+        case "EvtQuit":
+          retSignOut();
+          break;
+      }
+    }
+  }
+};
+const evtSeatState = (data: any) => {
+  if (m_strIsMonitor.value == "1") {
+    // 推送分机信息
+    const pushExt = data.Param.Extension || "";
+    if (pushExt !== m_strUserNo.value) {
+      // 如果分机实时推送的分机信息和当前登录分机不一致,则表明当前登录分机为监控分机,开启了状态监控功能
+      // 1.不属于当前分机的状态,则不改变当前分机电话条状态
+      // 2.调整其他分机大屏监控状态
+      const pushState = GetTelState(data.Param.State);
+      if (pushState) {
+        // 修改后台数据
+        console.log(pushExt, pushState);
+        // SetMonitorState(pushExt, pushState);
+      }
+      return;
+    }
+  }
+  if (m_IsHold.value) {
+    // 正在保持通话状态下
+    return;
+  }
+  // 状态 0:闲 1:忙 2:会议 3:登出 4:呼入 5:呼出 6:咨询 7:其他 8:通话
+  const strState = data.Param.State;
+  switch (strState) {
+    // 空闲
+    case "0":
+      m_bIsOpen.value = false;
+      m_IsHangup.value = false;
+      m_strTelState.value = "200";
+      m_bTelBusy.value = false;
+      e_TopStateChange(m_strTelState.value);
+      m_IsTalkingDeal.value = false;
+      break;
+    // 示忙
+    case "1":
+      m_strTelState.value = "201";
+      m_bTelBusy.value = true;
+      e_TopStateChange(m_strTelState.value);
+      break;
+    case "2":
+      break;
+    // 登出
+    case "3":
+      // 附加签出方法(用户分机分离调用业务系统签出)
+      m_strTelState.value = "0";
+      e_TopStateChange(m_strTelState.value);
+      break;
+    // 通话振铃
+    case "4":
+      if (!m_IsHangup.value) {
+        if (m_IsCallOut.value) {
+          // 呼出振铃
+          m_strTelState.value = "302";
+        } else {
+          // 呼入振铃
+          m_strTelState.value = "300";
+        }
+        e_TopStateChange(m_strTelState.value);
+      }
+      break;
+    // 通话振铃
+    case "5":
+      if (!m_IsHangup.value) {
+        m_strTelState.value = "302";
+        e_TopStateChange(m_strTelState.value);
+      }
+      break;
+    // 咨询
+    case "6":
+      break;
+    // 其他
+    case "7":
+      break;
+    // 接通
+    case "8":
+      if (!m_IsHangup.value) {
+        if (m_IsCallOut.value) {
+          // 呼出接通
+          m_strTelState.value = "303";
+        } else {
+          // 呼入接通
+          m_strTelState.value = "301";
+          // 是否是保持通话
+        }
+        e_TopStateChange(m_strTelState.value);
+      }
+      break;
+    case "9": // 工单整理
+      m_strTelState.value = "900";
+      e_TopStateChange(m_strTelState.value);
+      m_IsTalkingDeal.value = true;
+      break;
+  }
+};
+// 获取分机状态
+const GetTelState = (strState: any) => {
+  let strResult = "";
+  switch (strState) {
+    // 空闲
+    case "0":
+      strResult = "200";
+      break;
+    // 示忙
+    case "1":
+      strResult = "201";
+      break;
+    case "2":
+      break;
+    // 登出
+    case "3":
+      strResult = "0";
+      break;
+    // 通话振铃
+    case "4":
+      strResult = "302"; // 默认呼入振铃
+      break;
+    // 通话振铃
+    case "5":
+      strResult = "302"; // 默认呼出振铃
+      break;
+    // 咨询
+    case "6":
+      break;
+    // 其他
+    case "7":
+      break;
+    // 接通
+    case "8":
+      strResult = "301";
+      break;
+    case "9": // 工单整理
+      strResult = "900";
+      break;
+  }
+  return strResult;
+};
+// ws链接开启成功
+const e_websocketOpen = () => {
+  if (userAlreadyLogin.value) {
+    // 检查到用户已经登录需要先签出 再签入
+    sendSignOut();
+    useTimeoutFn(() => {
+      sendSignIn();
+    }, 500);
+  } else {
+    if (m_strUserNo.value && m_strSkillId.value) sendSignIn();
+  }
+};
+// 链接关闭
+const e_websocketClose = () => {
+  globalState.callCenterIsSignIn = false; // 签出状态
+  console.log(`${getNowDateTime()} 呼叫中心链接关闭`);
+};
+// 链接错误
+const e_websocketError = () => {
+  globalState.callCenterWs = null;
+  globalState.callCenterIsSignIn = false; // 签出状态
+  console.log(`${getNowDateTime()} 呼叫中心链接错误`);
+};
+/*
+* 登录
+* ReqAgentLogin - 登录方法名
+* JobNum - 工号
+* Name - 姓名
+* Extension - 分机号
+* SkillId - 技能组
+* Level - 为要设置的级别,分为9个级别,从高到低分别为0-8;同一技能组中级别越高的坐席优先被分配
+* Role - 角色,保留,不做设置
+* GroupName - 技能组名称
+* OrgId - 组织ID
+* 返回内容:
+{“Action”:”ResAgentLogin”,”Param”:{“Result”:}}
+Result: 3:分机错误 7:已登录 0:登录成功
+ */
+const sendSignIn = () => {
+  globalState.callCenterWs = wsRef.value;
+  const sendObj = {
+    Action: "ReqAgentLogin",
+    Param: {
+      JobNum: m_strJobNum.value,
+      Name: m_strUserName.value,
+      Extension: m_strUserNo.value,
+      SkillId: m_strSkillId.value,
+      Level: m_strLevel.value,
+      Role: "",
+      GroupName: m_strGroup.value,
+      OrgId: m_strCompanyId.value,
+    },
+  };
+  // 发送请求
+  e_TelSendMsg(sendObj);
+  console.log(`${getNowDateTime()} 呼叫中心发起签入`);
+};
+// 签入消息回调
+const retSignIn = (data: any) => {
+  if (data.Param.Result === "0") {
+    // 登录成功
+    m_bLogin.value = true;
+    m_strTelState.value = "100";
+
+    e_TopStateChange(m_strTelState.value);
+    // 登录成功
+    globalState.callCenterIsSignIn = true; // 签入状态
+    if (m_strIsMonitor.value === "1") {
+      // 监控初始化状态
+      ReqAgentMonitor();
+    }
+    console.log(`${getNowDateTime()} 呼叫中心签入成功回调`);
+  } else if (data.Param.Result === "3") {
+    // 分机错误
+    ElMessage.error("分机错误");
+    userAlreadyLogin.value = false; // 将登录状态重置
+    wsRef.value.close();
+    // 分机错误
+  } else if (data.Param.Result === "7") {
+    // 已经处于登录状态
+    // 先签出再签入
+    /* sendSignOut();
+    userAlreadyLogin.value = false; // 将登录状态重置*/
+    wsRef.value.close();
+    ElMessage.error("当前分机已经签入");
+  }
+};
+/**
+ * 状态初始化请求 监听
+ * */
+const ReqAgentMonitor = () => {
+  // 开始坐席状态监控
+  // SkillId 技能组为0则监控所有分机
+  const msgObj = {
+    Action: "ReqAgentMonitor",
+    Param: {
+      Extension: m_strUserNo.value,
+      CompanyId: m_strCompanyId.value,
+      SkillId: "0",
+    },
+  };
+  // 发送请求
+  e_TelSendMsg(msgObj);
+};
+/**
+ * {“Action”:”ResAgentMonitor”,”Param”:[{“Extension”:,”JobNumber”:,”SkillId”:,”Name”:,”Caller”:,”Called”:,”State”}]}
+ * State:0:闲 1:忙 2:会议 3:登出 4:呼入 5:呼出 6:咨询 7:其他 8:通话 9:工单整理
+ * 监控状态初始化返回状态
+ * @param {any} data
+ */
+const seatsList = ref<any[]>([]);
+const ResAgentMonitor = (data: any) => {
+  if (null != data && null != data.Param && data.Param.length > 0) {
+    // 监控分机集合
+    const strMonitorInfo = JSON.stringify(data.Param);
+    console.log(
+      `${getNowDateTime()} 监控分机集合:`,
+      strMonitorInfo,
+      data.Param
+    );
+    mittBus.emit("monitorInfo", data.Param);
+    seatsList.value = data.Param;
+  }
+};
+/*
+ * 签出
+ * ReqAgentLogout - 签出方法名称
+ * Extension - 分机号码
+ */
+const sendSignOut = () => {
+  const objMsg = {
+    Action: "ReqAgentLogout",
+    Param: {
+      Extension: m_strUserNo.value,
+    },
+  };
+  // 发送请求
+  e_TelSendMsg(objMsg);
+};
+/*
+ * 签出事件
+ */
+const retSignOut = () => {
+  // 登出成功
+  m_strTelState.value = "0";
+  m_bLogin.value = false;
+  globalState.callCenterIsSignIn = false; // 签出状态
+  globalState.callCenterWs = null;
+  wsRef.value.close();
+  // 如果用户没有登录 关闭ws
+  if (!userAlreadyLogin.value) {
+    wsRef.value.close();
+  }
+  console.log(`${getNowDateTime()} 呼叫中心签出回调`);
+};
+/**
+ * 异常处理
+ * @param {any} data
+ */
+const retResError = (data: any) => {
+  if (data.Param.Result == "99") {
+    // 掉线
+    m_strTelState.value = "0";
+    e_TopStateChange(m_strTelState.value);
+    globalState.callCenterWs = null;
+    ElMessage.error("连接已断开");
+    // 自动签入
+  }
+};
+// 改变状态方法
+const e_TopStateChange = (state: string) => {
+  console.log(`${getNowDateTime()}:状态改变:`, state);
+  switch (state) {
+    case "0": // 签出
+      break;
+    case "100": // 登录成功
+      break;
+    case "200": // 空闲
+      break;
+    case "201": // 示忙
+      break;
+    case "300": //呼入振铃
+      break;
+    case "301": // 呼入通话
+      break;
+    case "302": // 呼出振铃
+      break;
+    case "303": // 呼出通话
+      break;
+    case "310": // 通话保持
+      break;
+    case "320": // 三方会议
+      break;
+    case "330": // 转接
+      break;
+    case "331": // 转接
+      break;
+    case "900": // 整理
+      break;
+  }
+  // console.log(state);
+};
+/*
+ * 监听
+ */
+const reqMonListen = (strTargetNum: string) => {
+  const objMsg = {
+    Action: "ReqMonListen",
+    Param: {
+      Extension: m_strUserNo.value,
+      TargetExtension: strTargetNum,
+    },
+  };
+  // 发送请求
+  e_TelSendMsg(objMsg);
+};
+/*
+ * 监听返回
+ */
+const retResMonListen = (data: any) => {
+  if (data.Param.Result == "0") {
+    m_IsMonListen.value = "1";
+    // 成功
+  } else {
+    m_IsMonListen.value = "2";
+  }
+};
+/*
+ * 取消监听
+ */
+const reqStopListen = (strTargetNum: string) => {
+  const objMsg = {
+    Action: "ReqStopListen",
+    Param: {
+      Extension: m_strUserNo.value,
+      TargetExtension: strTargetNum,
+    },
+  };
+  // 发送请求
+  e_TelSendMsg(objMsg);
+};
+/*
+ * 取消监听返回
+ */
+const retResStopListen = (data: any) => {
+  if (data.Param.Result == "0") {
+    m_IsMonListen.value = "1";
+    // 成功
+  } else {
+    m_IsMonListen.value = "2";
+  }
+};
+onMounted(async () => {
+  initWs();
+});
+</script>
+<style lang="scss" scoped>
+.content_wrap {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  background-image: url("@/assets/img/seats/bg.png");
+  background-size: 100% 100%;
+}
+</style>

+ 293 - 0
src/views/seatMonitor/left-bottom.vue

@@ -0,0 +1,293 @@
+<template>
+  <div class="left_bottom_wrap">
+    <div class="left_number">
+      <div>
+        <span>登录坐席数量:</span>
+        <CountUp :endVal="loginCount" />
+      </div>
+      <div>
+        <span>呼入接通数量:</span>
+        <CountUp :endVal="state.count.inOn" />
+      </div>
+      <div>
+        <span>有效接通数量:</span>
+        <CountUp :endVal="state.count.validOn" />
+      </div>
+      <div>
+        <span>未接通数量:</span>
+        <CountUp :endVal="state.count.inNoOn" />
+      </div>
+      <div>
+        <span>外呼接通数量:</span>
+        <CountUp :endVal="state.count.outOn" />
+      </div>
+      <div>
+        <span>队列挂断数量:</span>
+        <CountUp :endVal="state.count.inQueueNoOn" />
+      </div>
+    </div>
+    <div class="right_chart">
+      <v-chart class="chart" :option="option" />
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import {
+  onMounted,
+  reactive,
+  ref,
+  computed,
+  onUnmounted,
+  watch,
+  defineAsyncComponent,
+} from "vue";
+import signalR from "@/utils/signalR";
+
+const CountUp = defineAsyncComponent(
+  () => import("@/components/Count-up/index.vue")
+);
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+  content: {
+    type: Object,
+    default: () => {},
+  },
+});
+const option = ref<EmptyObjectType>({});
+const state = reactive<any>({
+  count: {
+    inNoOn: 0,
+    inOn: 0,
+    inQueueNoOn: 0,
+    outOn: 0,
+    validOn: 0,
+  },
+});
+const setOption = async (newData: any) => {
+  option.value = {
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(0,0,0,.6)",
+      borderColor: "rgba(147, 235, 248, .8)",
+      textStyle: {
+        color: "#FFF",
+      },
+    },
+    legend: {
+      data: ["呼入", "外呼", "外呼平均", "呼入平均"],
+      textStyle: {
+        color: "#fff",
+      },
+      top: "0",
+      left: "80px",
+      icon: "roundRect",
+      itemWidth: 16,
+      itemHeight: 16,
+      itemGap: 20,
+    },
+    grid: {
+      left: "50px",
+      right: "10px",
+      bottom: "30px",
+      top: "30px",
+    },
+    xAxis: {
+      data: newData.xData,
+      axisLine: {
+        lineStyle: {
+          color: "#fff",
+        },
+      },
+      axisTick: {
+        show: false,
+      },
+    },
+    yAxis: [
+      {
+        splitLine: { show: false },
+        axisLine: {
+          lineStyle: {
+            color: "#fff",
+          },
+        },
+        axisLabel: {
+          formatter: "{value}",
+        },
+      },
+      {
+        splitLine: { show: false },
+        axisLine: {
+          lineStyle: {
+            color: "#fff",
+          },
+        },
+        axisLabel: {
+          formatter: "{value}% ",
+        },
+      },
+    ],
+    series: [
+      {
+        name: "呼入",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        data: newData.inData,
+      },
+      {
+        name: "外呼",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        z: -12,
+        data: newData.outData,
+      },
+      {
+        name: "呼入平均",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        z: -12,
+        data: newData.inAverageData,
+      },
+      {
+        name: "外呼平均",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        z: -12,
+        data: newData.outAverageData,
+      },
+    ],
+  };
+};
+const seatsList = ref<any>([]);
+// 登录坐席数量
+const loginCount = computed(() => {
+  return seatsList.value.filter((item: any) => item.state !== "logout").length;
+});
+watch(
+  () => props.content,
+  (val: any) => {
+    state.count = val.count[0];
+    let data = {
+      xData: [],
+      inData: [],
+      outData: [],
+      inAverageData: [],
+      outAverageData: [],
+    };
+    val.list.forEach((item: any) => {
+      data.xData.push(item.time);
+      data.inData.push(item.in);
+      data.outData.push(item.out);
+      data.inAverageData.push(item.inAverag);
+      data.outAverageData.push(item.outAverag);
+    });
+    setTimeout(() => {
+      setOption(data);
+    }, 100);
+  }
+);
+watch(
+  () => props.data,
+  (newData: any) => {
+    seatsList.value = newData;
+  },
+  { immediate: true }
+);
+onMounted(() => {
+  signalR.SR.on("SeatState", (res: any) => {
+    const item = seatsList.value.find((item: any) => item.telNo === res.telNo);
+    if (item) {
+      item.state = res.state;
+    }
+  });
+  signalR.SR.on("BsSeatStateDataShowArea3", (res: any) => {
+    state.count = res[0];
+  });
+  signalR.SR.on("BsSeatStateDataShowArea4", (res: any) => {
+    let data = {
+      xData: [],
+      inData: [],
+      outData: [],
+      inAverageData: [],
+      outAverageData: [],
+    };
+    res.forEach((item: any) => {
+      data.xData.push(item.time);
+      data.inData.push(item.in);
+      data.outData.push(item.out);
+      data.inAverageData.push(item.inAverag);
+      data.outAverageData.push(item.outAverag);
+    });
+    setTimeout(() => {
+      setOption(data);
+    }, 100);
+  });
+});
+onUnmounted(() => {
+  signalR.SR.off("SeatState");
+  signalR.SR.off("BsSeatStateDataShowArea3");
+  signalR.SR.off("BsSeatStateDataShowArea4");
+});
+</script>
+<style scoped lang="scss">
+.left_bottom_wrap {
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  font-size: var(--el-font-size-base);
+
+  .left_number {
+    margin: 20px 0;
+    height: calc(100% - 40px);
+    display: flex;
+    flex-direction: column;
+    justify-content: space-around;
+    min-width: 130px;
+
+    div {
+      display: flex;
+      justify-content: space-between;
+    }
+  }
+
+  .right_chart {
+    flex: 1;
+    height: 100%;
+  }
+}
+</style>

+ 171 - 0
src/views/seatMonitor/left-center.vue

@@ -0,0 +1,171 @@
+<template>
+  <div class="top10">
+    <template v-if="data.length">
+      <div v-for="(item, index) in data" :key="index" class="item">
+        <span class="num" :class="'num' + index">
+          {{ index + 1 }}
+        </span>
+        <span>
+          {{ item.userName }}
+        </span>
+        <CountUp :endVal="item.in" :duration="3" />
+      </div>
+    </template>
+    <empty v-else />
+  </div>
+</template>
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted, watch, defineAsyncComponent } from "vue";
+import signalR from "@/utils/signalR";
+
+const Empty = defineAsyncComponent(
+  () => import("@/components/Empty/index.vue")
+);
+const CountUp = defineAsyncComponent(
+  () => import("@/components/Count-up/index.vue")
+);
+const data = ref<EmptyArrayType>([]);
+const props = defineProps({
+  content: {
+    type: Array,
+    default: () => [],
+  },
+});
+watch(
+  () => props.content,
+  (newData: any) => {
+    data.value = newData;
+  }
+);
+onMounted(() => {
+  signalR.SR.on("BsSeatStateDataShowArea2", (res: any) => {
+    data.value = res;
+  });
+});
+onUnmounted(() => {
+  signalR.SR.off("BsSeatStateDataShowArea2");
+});
+</script>
+<style scoped lang="scss">
+.top10 {
+  margin-top: 10px;
+  height: calc(100% - 10px);
+  display: flex;
+  flex-direction: column;
+  transition: max-height 0.3s ease-in;
+  transform-origin: 50% 0;
+  animation: slide-down 0.3s ease-in;
+
+  .item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 10px;
+
+    &:nth-child(even) {
+      background-color: rgba(255, 255, 255, 0.1);
+    }
+  }
+
+  .item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    height: 30px;
+    line-height: 30px;
+    padding: 0 10px;
+
+    &:nth-child(even) {
+      background-color: rgba(255, 255, 255, 0.1);
+    }
+
+    .num {
+      display: inline-block;
+      width: 30px;
+      height: 20px;
+      line-height: 20px;
+      text-align: center;
+      color: #fff;
+      background-color: var(--el-color-primary);
+      border-radius: 5px;
+
+      &:before {
+        content: "";
+        width: 0;
+        height: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 10px solid transparent;
+        border-left: 10px solid var(--el-color-primary);
+        position: absolute;
+        left: 52px;
+      }
+    }
+
+    .num0 {
+      display: inline-block;
+      width: 30px;
+      height: 20px;
+      line-height: 20px;
+      text-align: center;
+      color: #fff;
+      background-color: var(--el-color-danger);
+      border-radius: 5px;
+
+      &:before {
+        content: "";
+        width: 0;
+        height: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 10px solid transparent;
+        border-left: 10px solid var(--el-color-danger);
+        position: absolute;
+        left: 52px;
+      }
+    }
+
+    .num1 {
+      display: inline-block;
+      width: 30px;
+      height: 20px;
+      line-height: 20px;
+      text-align: center;
+      color: #fff;
+      background-color: var(--el-color-warning);
+      border-radius: 5px;
+
+      &:before {
+        content: "";
+        width: 0;
+        height: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 10px solid transparent;
+        border-left: 10px solid var(--el-color-warning);
+        position: absolute;
+        left: 52px;
+      }
+    }
+
+    .num2 {
+      display: inline-block;
+      width: 30px;
+      height: 20px;
+      line-height: 20px;
+      text-align: center;
+      color: #fff;
+      background-color: var(--el-color-success);
+      border-radius: 5px;
+
+      &:before {
+        content: "";
+        width: 0;
+        height: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 10px solid transparent;
+        border-left: 10px solid var(--el-color-success);
+        position: absolute;
+        left: 52px;
+      }
+    }
+  }
+}
+</style>

+ 150 - 0
src/views/seatMonitor/left-top.vue

@@ -0,0 +1,150 @@
+<template>
+  <v-chart class="chart" :option="option" />
+</template>
+<script setup lang="ts">
+import { onMounted, ref, onUnmounted, watch } from "vue";
+import signalR from "@/utils/signalR";
+
+const props = defineProps({
+  content: {
+    type: Array,
+    default: () => [],
+  },
+});
+const option = ref({});
+const setOption = async (newData: any) => {
+  option.value = {
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(0,0,0,.6)",
+      borderColor: "rgba(147, 235, 248, .8)",
+      textStyle: {
+        color: "#FFF",
+      },
+    },
+    legend: {
+      data: ["呼入", "外呼"],
+      textStyle: {
+        color: "#fff",
+      },
+      top: "0",
+    },
+    grid: {
+      left: "40px",
+      right: "10px",
+      bottom: "30px",
+      top: "30px",
+    },
+    xAxis: {
+      data: newData.xData,
+      axisLine: {
+        lineStyle: {
+          color: "#fff",
+        },
+      },
+      axisTick: {
+        show: false,
+      },
+    },
+    yAxis: [
+      {
+        splitLine: { show: false },
+        axisLine: {
+          lineStyle: {
+            color: "#fff",
+          },
+        },
+        axisLabel: {
+          formatter: "{value}",
+        },
+      },
+      {
+        splitLine: { show: false },
+        axisLine: {
+          lineStyle: {
+            color: "#fff",
+          },
+        },
+        axisLabel: {
+          formatter: "{value}% ",
+        },
+      },
+    ],
+    series: [
+      {
+        name: "呼入",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        data: newData.inData,
+      },
+      {
+        name: "外呼",
+        type: "line",
+        symbol: "circle", //数据交叉点样式
+        areaStyle: {
+          //添加背景颜色
+          opacity: 0.3, //透明度
+        },
+        itemStyle: {
+          borderRadius: 5,
+        },
+        smooth: true,
+        z: -12,
+        data: newData.outData,
+      },
+    ],
+  };
+};
+watch(
+  () => props.content,
+  (val: any) => {
+    let data = {
+      xData: [],
+      inData: [],
+      outData: [],
+    };
+    val.forEach((item: any) => {
+      data.xData.push(item.time);
+      data.inData.push(item.in);
+      data.outData.push(item.out);
+    });
+    setTimeout(() => {
+      setOption(data);
+    }, 100);
+  }
+);
+onMounted(() => {
+  signalR.SR.on("BsSeatStateDataShowArea1", (res: any) => {
+    let data = {
+      xData: [],
+      inData: [],
+      outData: [],
+    };
+    res.forEach((item: any) => {
+      data.xData.push(item.time);
+      data.inData.push(item.in);
+      data.outData.push(item.out);
+    });
+    setTimeout(() => {
+      setOption(data);
+    }, 100);
+  });
+});
+onUnmounted(() => {
+  signalR.SR.off("BsSeatStateDataShowArea1");
+});
+</script>
+<style scoped lang="scss">
+.left-top {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 349 - 0
src/views/seatMonitor/right.vue

@@ -0,0 +1,349 @@
+<template>
+  <div
+    class="seats_box"
+    v-loading="loading"
+    element-loading-text="加载中..."
+    element-loading-svg-view-box="-10, -10, 50, 50"
+    element-loading-background="rgba(122, 122, 122, 0.1)"
+  >
+    <div class="seats_container">
+      <div
+        v-for="(item, index) in seatsList"
+        class="seats_item"
+        ref="textRefs"
+        @click="handleClick(index, item)"
+      >
+        <img
+          src="@/assets/img/seats/service.png"
+          alt=""
+          class="seats_item_service"
+        />
+        <p class="seats_item_tel">{{ item.telNo }}</p>
+        <p class="seats_item_name" v-if="item.state === 'logout'">未登录</p>
+        <p class="seats_item_name" v-else>
+          {{ item.workUserName ? item.workUserName : "未知" }}
+        </p>
+        <span @click.stop>
+          <el-dropdown
+            @command="handleCommand($event, item)"
+            class="seats_item_dropdown"
+            trigger="click"
+            v-if="item.state === 'busy' && globalState.callCenterIsSignIn"
+          >
+            <el-icon class="seats_item_more" size="18">
+              <Operation />
+            </el-icon>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="listen">监 听</el-dropdown-item>
+                <el-dropdown-item command="interject">插 话</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </span>
+        <div class="seats_item_state">
+          <img :src="getImageUrl('seats/' + item.state + '.png')" alt="" />
+          <span class="seats_item_telNo">
+            {{ currentStatusText(item.state) }}</span
+          >
+        </div>
+      </div>
+    </div>
+
+    <el-popover
+      ref="popoverRef"
+      :virtual-ref="textRef"
+      :visible="hidePopover"
+      virtual-triggering
+      placement="right-start"
+      @hide="popoverHide"
+      width="220"
+      popper-class="call_popover_popper"
+    >
+      <template #default>
+        <div v-click-outside="onClickOutside" class="call_popover">
+          <div class="call_popover_item">
+            <span class="label">坐席名称:</span>{{ call.workUserName }}
+          </div>
+          <div class="call_popover_item">
+            <span class="label">分机号:</span>{{ call.telNo }}
+          </div>
+          <div class="call_popover_item">
+            <span class="label">通话时长:</span
+            ><span v-if="talkTime">{{ formatDuration(talkTime) }}</span>
+          </div>
+          <div class="call_popover_item">
+            <span class="label">呼入类型:</span>
+            <el-text v-if="call.callDirection === 'inbound'">呼入</el-text>
+            <el-text v-if="call.callDirection === 'outbound'">外呼</el-text>
+            <i></i>
+          </div>
+          <div class="call_popover_item">
+            <span class="label">电话号码:</span>{{ call.otherNumber }}
+          </div>
+          <div class="call_popover_item">
+            <span class="label">今日接听量:</span>{{ call.onStateCount }}
+          </div>
+        </div>
+      </template>
+    </el-popover>
+  </div>
+</template>
+<script setup lang="ts">
+import { onMounted, ref, nextTick, onBeforeUnmount, watch } from "vue";
+import { getImageUrl } from "@/utils/tools";
+import signalR from "@/utils/signalR";
+import { ClickOutside as vClickOutside } from "element-plus";
+import { formatDuration } from "@/utils/formatTime";
+import dayjs from "dayjs";
+import { getExtensionStatus } from "api/seats";
+import { useIntervalFn } from "@vueuse/core";
+import { useGlobalState } from "@/utils/callCenter";
+import { Operation } from "@element-plus/icons-vue";
+import mittBus from "@/utils/mitt";
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const textRefs = ref<EmptyArrayType>([]);
+const textRef = ref();
+const popoverRef = ref();
+const hidePopover = ref(false);
+const call = ref<EmptyObjectType>({});
+const globalState = useGlobalState();
+// 开始签入时长
+const talkTime = ref<any>(0); // 通话时长
+const talkTimer = ref<any>(null); // 通话时长定时器
+const startCallTimer = (second: any) => {
+  if (second) {
+    // 从后台获取签入时长
+    if (second < 0) second = 0; // 防止后台返回的签入时间大于当前时间
+    talkTime.value = second;
+    talkTimer.value = useIntervalFn(() => {
+      talkTime.value++;
+    }, 1000);
+  } else {
+    talkTimer.value = useIntervalFn(() => {
+      talkTime.value++;
+    }, 1000);
+  }
+};
+const handleClick = (index: number, item: { state: string; telNo: string }) => {
+  textRef.value = textRefs.value[index];
+  call.value = item;
+  talkTime.value = 0;
+  if (talkTimer.value) talkTimer.value.pause();
+  if (["busy", "held"].includes(item.state)) {
+    getExtensionStatus({ telno: item.telNo }).then((res: any) => {
+      hidePopover.value = true;
+      call.value = res.result;
+      // 获取现在时间与签入时间的秒数
+      talkTime.value = dayjs().diff(dayjs(res.result.answeredAt), "second");
+      startCallTimer(talkTime.value);
+    });
+  } else {
+    talkTime.value = 0;
+    call.value.otherNumber = "";
+  }
+};
+// 隐藏弹窗
+const popoverHide = () => {
+  if (talkTimer.value) talkTimer.value.pause();
+};
+const onClickOutside = () => {
+  hidePopover.value = false;
+};
+// 设置当前状态的值
+const currentStatusText = (state: string) => {
+  const statusMap: any = {
+    logout: "签出",
+    login: "签入",
+    ready: "示闲",
+    unready: "小休",
+    ring: "振铃中",
+    busy: "通话中",
+    acw: "整理",
+    held: "保持",
+    treeWay: "三方会议",
+  };
+  return statusMap[state] || "";
+};
+const seatsList = ref<any>([]);
+watch(
+  () => props.data,
+  (newData: any) => {
+    seatsList.value = newData;
+  },
+  { immediate: true }
+);
+const loading = ref(false);
+// 对当前状态进行排序
+const sortSeatsList = () => {
+  // 通话中的排序
+  const busyData = seatsList.value.filter((item: any) => item.state === "busy");
+  // 通话中的排序
+  const ringData = seatsList.value.filter((item: any) => item.state === "ring");
+  // 话后整理
+  const acwData = seatsList.value.filter((item: any) => item.state === "acw");
+  // 保持
+  const heldData = seatsList.value.filter((item: any) => item.state === "held");
+  // 三方会议
+  const treeWayData = seatsList.value.filter(
+    (item: any) => item.state === "treeWay"
+  );
+  // 小休
+  const unreadyData = seatsList.value.filter(
+    (item: any) => item.state === "unready"
+  );
+  // 示闲
+  const readyData = seatsList.value.filter(
+    (item: any) => item.state === "ready"
+  );
+  // 签出
+  const logoutData = seatsList.value.filter(
+    (item: any) => item.state === "logout"
+  );
+  seatsList.value = [
+    ...busyData,
+    ...ringData,
+    ...readyData,
+    ...acwData,
+    ...heldData,
+    ...treeWayData,
+    ...unreadyData,
+    ...logoutData,
+  ];
+};
+onMounted(async () => {
+  await nextTick();
+  // await getSeatsList();
+  // 接收消息
+  signalR.SR.on("SeatState", (res: any) => {
+    const item = seatsList.value.find((item: any) => item.telNo === res.telNo);
+    item.loading = true;
+    if (item) {
+      setTimeout(() => {
+        item.state = res.state;
+        item.workUserName = res.workUserName;
+        item.workUserId = res.workUserId;
+        item.loading = false;
+        sortSeatsList();
+        // hidePopover.value = false;
+      }, 500);
+    }
+  });
+  mittBus.on("monitorInfo", (data: any) => {
+    console.log("1111", data);
+  });
+});
+// 监听和插话消息
+const handleCommand = (command: string, item: any) => {
+  console.log(command, item);
+  if (command === "listen") {
+    // 监听
+    globalState.callCenterWs.monitor(
+      item.telNo,
+      globalState.callCenterWs.username
+    );
+  } else if (command === "interject") {
+    // 强插
+    globalState.callCenterWs.intercept(
+      item.telNo,
+      globalState.callCenterWs.username
+    );
+  }
+};
+onBeforeUnmount(() => {
+  signalR.SR.off("SeatState");
+});
+</script>
+<style scoped lang="scss">
+.seats_box {
+  width: 100%;
+  overflow: auto;
+  height: 100%;
+
+  .seats_container {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, 100px);
+    grid-gap: 20px;
+
+    .seats_item {
+      width: 100px;
+      border: 1px solid var(--el-color-primary-light-3);
+      border-radius: 8px;
+      text-align: center;
+      padding: 7px 0;
+      animation: bounce-out 0.3s ease;
+      position: relative;
+      cursor: pointer;
+      &_service {
+        margin: 0 auto;
+        width: 40px;
+      }
+
+      &_tel {
+        font-size: var(--el-font-size-medium);
+        color: var(--el-color-white);
+        margin-top: 8px;
+      }
+
+      &_dropdown {
+        position: absolute;
+        top: 5px;
+        right: 10px;
+        outline: none;
+      }
+
+      &_more {
+        cursor: pointer;
+      }
+
+      &_name {
+        font-size: var(--el-font-size-medium);
+        color: var(--el-color-white);
+        margin-top: 5px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        padding: 0 5px;
+      }
+
+      &_state {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin-top: 8px;
+
+        img {
+          width: 25px;
+          height: 25px;
+        }
+
+        .seats_item_telNo {
+          font-size: var(--el-font-size-medium);
+          color: var(--el-color-white);
+          margin-left: 5px;
+        }
+      }
+    }
+  }
+}
+
+.call_popover_popper {
+  .call_popover {
+    .call_popover_item {
+      margin-bottom: 5px;
+      .label {
+        display: inline-block;
+        width: 90px;
+        text-align: right;
+      }
+    }
+  }
+}
+</style>