WXL (wul)
2025-11-13 de147dda682f8ac597bbcc8555b57acbdf45dba2
src/components/CallCenterLs/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,717 @@
<template>
  <div class="call-center-container">
    <!-- ä¸»æŽ§åˆ¶åŒº -->
    <div class="control-section">
      <div class="phone-control-card">
        <h3 class="section-title">电话控制</h3>
        <div class="input-group">
          <div class="form-field">
            <label class="form-label">客户电话号码</label>
            <input
              v-model="customerPhone"
              type="text"
              placeholder="请输入电话号码"
              :disabled="isCalling"
              class="phone-input"
            />
          </div>
          <div class="button-group">
            <button
              @click="handleCall"
              :class="['call-btn', callButtonClass]"
              :disabled="!canMakeCall"
            >
              <span class="btn-icon">📞</span>
              {{ callButtonText }}
            </button>
            <button
              @click="handleSeatLogout"
              :class="[
                'seat-btn',
                'logout',
                { 'in-call': isInCall || isCalling },
              ]"
              :disabled="!canLogout"
            >
              <span class="btn-icon">🚪</span>
              {{ isInCall || isCalling ? "强制签出" : "签出" }}
            </button>
            <button
              @click="handleHangup"
              class="hangup-btn"
              :disabled="!canHangup"
            >
              <span class="btn-icon">📵</span>
              æŒ‚æ–­
            </button>
          </div>
        </div>
      </div>
      <!-- çŠ¶æ€æ˜¾ç¤ºåŒº -->
      <div class="status-card">
        <h3 class="section-title">状态监控</h3>
        <div class="status-grid">
          <div class="status-item">
            <span class="status-label">座席状态:</span>
            <span :class="['status-indicator', seatStatusClass]">
              <span class="status-dot"></span>
              {{ seatStatusText }}
            </span>
          </div>
          <div class="status-item">
            <span class="status-label">通话状态:</span>
            <span :class="['status-indicator', callStatusClass]">
              <span class="status-dot"></span>
              {{ callStatusText }}
            </span>
          </div>
          <div class="status-item" v-if="callDuration">
            <span class="status-label">通话时长:</span>
            <span class="duration-display"> â±ï¸ {{ callDuration }} </span>
          </div>
        </div>
      </div>
    </div>
    <!-- è°ƒè¯•面板 -->
    <div class="debug-section">
      <el-collapse accordion>
        <el-collapse-item name="debug">
          <template slot="title">
            <div class="debug-header">
              <span class="debug-title">呼叫调试日志</span>
              <span class="debug-subtitle">点击查看详细通话信息</span>
            </div>
          </template>
          <div class="debug-content">
            <WebsocketDemo
              ref="callComponent"
              :customer-phone="customerPhone"
              :auto-login="true"
              @status-change="onSeatStatusChange"
              @call-status="onCallStatusChange"
              @error="onCallError"
              class="call-component"
            />
          </div>
        </el-collapse-item>
      </el-collapse>
    </div>
  </div>
</template>
<script>
import WebsocketDemo from "../../views/followvisit/discharge/ClickCall.vue";
export default {
  name: "CallCenterModal",
  components: {
    WebsocketDemo,
  },
  props: {
    initialPhone: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      customerPhone: "",
      isSeatLoggedIn: false,
      callStatus: "idle",
      callStartTime: null,
      callDuration: "00:00",
      durationTimer: null,
      lastError: null,
    };
  },
  computed: {
    isCalling() {
      return this.callStatus === "calling";
    },
    isInCall() {
      return this.callStatus === "connected";
    },
    canLogout() {
      // åªæœ‰åœ¨å·²ç­¾å…¥çŠ¶æ€æ‰å…è®¸ç­¾å‡ºï¼ˆæ— è®ºæ˜¯å¦åœ¨é€šè¯ä¸­ï¼‰
      return this.isSeatLoggedIn && !this.isSeatLoggingOut;
    },
    canMakeCall() {
      return (
        this.isSeatLoggedIn &&
        this.customerPhone &&
        this.callStatus === "idle" &&
        !this.lastError
      );
    },
    canHangup() {
      return this.isCalling || this.isInCall;
    },
    callButtonClass() {
      if (!this.canMakeCall) return "disabled";
      return this.isCalling ? "calling" : "idle";
    },
    callButtonText() {
      if (this.isCalling) return "呼叫中...";
      if (this.isInCall) return "通话中";
      return "开始呼叫";
    },
    seatStatusClass() {
      return this.isSeatLoggedIn ? "success" : "error";
    },
    seatStatusText() {
      return this.isSeatLoggedIn ? "已签入" : "未签入";
    },
    callStatusClass() {
      switch (this.callStatus) {
        case "connected":
          return "success";
        case "calling":
          return "warning";
        default:
          return "idle";
      }
    },
    callStatusText() {
      switch (this.callStatus) {
        case "connected":
          return "通话中";
        case "calling":
          return "呼叫中";
        default:
          return "空闲";
      }
    },
  },
  watch: {
    initialPhone: {
      immediate: true,
      handler(newVal) {
        if (newVal) {
          this.customerPhone = newVal;
        }
      },
    },
  },
  methods: {
    handleCall() {
      if (!this.canMakeCall) return;
      this.$refs.callComponent.callout(this.customerPhone);
      this.startCallTimer();
    },
    async handleSeatLogout() {
      if (!this.canLogout) return;
      try {
        // å¦‚果正在通话中,先挂断
        if (this.isInCall || this.isCalling) {
          // æ˜¾ç¤ºç¡®è®¤å¯¹è¯æ¡†
          const confirm = await this.$confirm(
            "确定要签出吗?签出将结束当前通话",
            "提示",
            {
              confirmButtonText: "确定",
              cancelButtonText: "取消",
              type: "warning",
            }
          );
          await this.handleHangup();
        }
        console.log(2);
        // æ‰§è¡Œç­¾å‡º
        await this.$refs.callComponent.handleSeatLogout();
        console.log(3);
        this.isSeatLoggedIn = false;
        this.$message.success("座席签出成功");
      } catch (error) {
        if (error !== "cancel") {
          // å¿½ç•¥ç”¨æˆ·å–消的情况
          console.error("签出失败:", error);
          this.$message.error("座席签出失败");
        }
      }
    },
    handleHangup() {
      this.$refs.callComponent.hangup();
      this.stopCallTimer();
      this.callStatus = "idle";
    },
    onSeatStatusChange(status) {
      this.isSeatLoggedIn = status.isLoggedIn;
      // å¦‚果座席签出,重置通话状态
      if (!status.isLoggedIn) {
        this.callStatus = "idle";
        this.stopCallTimer();
      }
    },
    onCallStatusChange(status) {
      this.callStatus = status.status;
      if (status.status == "connected") {
        this.startCallTimer();
      } else if (status.status == "idle") {
        this.stopCallTimer();
      }
    },
    onCallError(error) {
      this.lastError = error;
      this.$emit("error", error);
    },
    startCallTimer() {
      this.callStartTime = new Date();
      this.durationTimer = setInterval(() => {
        if (this.callStartTime) {
          const duration = Math.floor((new Date() - this.callStartTime) / 1000);
          const minutes = Math.floor(duration / 60)
            .toString()
            .padStart(2, "0");
          const seconds = (duration % 60).toString().padStart(2, "0");
          this.callDuration = `${minutes}:${seconds}`;
        }
      }, 1000);
    },
    stopCallTimer() {
      if (this.durationTimer) {
        clearInterval(this.durationTimer);
        this.durationTimer = null;
      }
      this.callDuration = "00:00";
      this.callStartTime = null;
    },
    handleSeatBusy() {
      this.$refs.callComponent.afk();
    },
    handleSeatReady() {
      this.$refs.callComponent.online();
    },
    handleHold() {
      this.$refs.callComponent.hold();
    },
    handleResume() {
      this.$refs.callComponent.holdresume();
    },
    // æä¾›ç»™çˆ¶ç»„件调用的方法
    setPhoneNumber(phone) {
      this.customerPhone = phone;
    },
    autoCall(phone) {
      this.setPhoneNumber(phone);
      this.$nextTick(() => {
        this.handleCall();
      });
    },
  },
  beforeUnmount() {
    this.stopCallTimer();
    // ç»„件销毁前尝试签出
    if (this.isSeatLoggedIn) {
      this.handleSeatLogout().catch(console.error);
    }
  },
};
</script>
<style lang="scss" scoped>
.call-center-container {
  height: 100%;
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 0;
  background: #f8fafc;
}
// æŽ§åˆ¶åŒºåŸŸæ ·å¼
.control-section {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  padding: 20px;
  padding-bottom: 0;
  @media (max-width: 1024px) {
    grid-template-columns: 1fr;
  }
}
.seat-btn.logout {
  background: linear-gradient(135deg, #f97316, #ea580c);
  color: white;
  &:hover:not(:disabled) {
    box-shadow: 0 6px 16px rgba(249, 115, 22, 0.4);
  }
  // é€šè¯ä¸­çš„特殊样式
  &.in-call {
    background: linear-gradient(135deg, #ef4444, #dc2626);
    animation: pulse 1.5s infinite;
    &:hover:not(:disabled) {
      box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
    }
  }
}
// é€šè¯ä¸­ç­¾å‡ºæŒ‰é’®çš„特殊动画
@keyframes pulse-red {
  0% {
    box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5);
  }
  50% {
    box-shadow: 0 4px 20px rgba(239, 68, 68, 0.8);
  }
  100% {
    box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5);
  }
}
.section-title {
  font-size: 16px;
  font-weight: 600;
  color: #1e293b;
  margin-bottom: 16px;
  display: flex;
  align-items: center;
  &::before {
    content: "";
    width: 3px;
    height: 16px;
    background: #3b82f6;
    margin-right: 8px;
    border-radius: 2px;
  }
}
// å¡ç‰‡é€šç”¨æ ·å¼
.phone-control-card,
.status-card {
  background: white;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  border: 1px solid #e2e8f0;
}
// è¡¨å•控件样式
.form-field {
  margin-bottom: 20px;
}
.form-label {
  display: block;
  font-weight: 500;
  color: #475569;
  margin-bottom: 8px;
  font-size: 14px;
}
.phone-input {
  width: 100%;
  padding: 12px;
  border: 2px solid #e2e8f0;
  border-radius: 8px;
  font-size: 14px;
  transition: all 0.3s ease;
  &:focus {
    outline: none;
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
  }
  &:disabled {
    background-color: #f8fafc;
    color: #94a3b8;
    cursor: not-allowed;
  }
}
.button-group {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}
// æŒ‰é’®åŸºç¡€æ ·å¼
.call-btn,
.hangup-btn {
  padding: 12px 28px; /* å¢žåŠ å†…è¾¹è·ï¼Œä½¿æŒ‰é’®æ›´å¤§æ–¹ */
  border: none;
  border-radius: 12px; /* ä½¿ç”¨æ›´å¤§çš„圆角,创造“药丸”形 */
  cursor: pointer;
  font-size: 14px;
  font-weight: 600; /* å­—体加粗 */
  transition: all 0.3s ease; /* å¹³æ»‘过渡所有属性 */
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px; /* å›¾æ ‡å’Œæ–‡å­—的间距 */
  min-width: 120px; /* è®¾ç½®æœ€å°å®½åº¦ */
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1); /* å¤šå±‚阴影增强立体感 */
}
.call-btn {
  background: linear-gradient(135deg, #10b981, #059669); /* ç»¿è‰²æ¸å˜ */
  color: white;
}
.call-btn:hover:not(.disabled) {
  transform: translateY(-2px); /* æ‚¬åœæ—¶è½»å¾®ä¸Šæµ® */
  box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4); /* æ‚¬åœæ—¶é˜´å½±æ›´æ˜Žæ˜¾ */
}
.call-btn.calling {
  background: linear-gradient(
    135deg,
    #f59e0b,
    #d97706
  ); /* å‘¼å«ä¸­çŠ¶æ€æ”¹ä¸ºæ©™è‰²æ¸å˜ */
  animation: pulse 1.5s infinite; /* å‘¼å«ä¸­æ·»åŠ å‘¼å¸è„‰å†²åŠ¨ç”» */
}
.hangup-btn {
  background: linear-gradient(135deg, #ef4444, #dc2626); /* çº¢è‰²æ¸å˜ */
  color: white;
}
.hangup-btn:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
/* ç¦ç”¨çŠ¶æ€ */
.call-btn.disabled,
.hangup-btn:disabled {
  background: #cbd5e1 !important;
  cursor: not-allowed;
  transform: none !important;
  box-shadow: none !important;
}
/* è„‰å†²åŠ¨ç”»å®šä¹‰ */
@keyframes pulse {
  0% {
    box-shadow: 0 4px 12px rgba(245, 158, 11, 0.5);
  }
  50% {
    box-shadow: 0 4px 20px rgba(245, 158, 11, 0.8);
  }
  100% {
    box-shadow: 0 4px 12px rgba(245, 158, 11, 0.5);
  }
}
// çŠ¶æ€æ˜¾ç¤ºæ ·å¼
.status-grid {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.status-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px;
  background: #f8fafc;
  border-radius: 8px;
}
.status-label {
  font-size: 14px;
  color: #475569;
  font-weight: 500;
}
.status-indicator {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 500;
  padding: 4px 12px;
  border-radius: 20px;
}
.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  animation: pulse 2s infinite;
}
.status-indicator.success {
  background: #f0fdf4;
  color: #059669;
  .status-dot {
    background: #059669;
  }
}
.status-indicator.error {
  background: #fef2f2;
  color: #dc2626;
  .status-dot {
    background: #dc2626;
  }
}
.status-indicator.warning {
  background: #fffbeb;
  color: #d97706;
  .status-dot {
    background: #f59e0b;
  }
}
.status-indicator.idle {
  background: #f8fafc;
  color: #64748b;
  .status-dot {
    background: #94a3b8;
  }
}
.duration-display {
  font-family: "Courier New", monospace;
  font-weight: 600;
  color: #059669;
  font-size: 14px;
}
// è°ƒè¯•面板样式
.debug-section {
  background: white;
  margin: 0 20px 20px;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.debug-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding-right: 20px;
}
.debug-title {
  font-size: 16px;
  font-weight: 600;
  color: #1e293b;
  margin-bottom: 16px;
  display: flex;
  align-items: center;
  &::before {
    content: "";
    width: 3px;
    height: 16px;
    background: #3b82f6;
    margin-right: 8px;
    border-radius: 2px;
  }
}
.debug-subtitle {
  font-size: 12px;
  color: #64748b;
}
.debug-content {
  padding: 0;
}
.call-component {
  min-height: 200px;
  border-top: 1px solid #e2e8f0;
}
// åŠ¨ç”»å®šä¹‰
@keyframes pulse {
  0% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
  100% {
    opacity: 1;
  }
}
// å“åº”式设计
@media (max-width: 768px) {
  .control-section {
    padding: 16px;
    grid-template-columns: 1fr;
  }
  .phone-control-card,
  .status-card {
    padding: 16px;
  }
  .button-group {
    flex-direction: column;
  }
  .status-item {
    flex-direction: column;
    gap: 8px;
    align-items: flex-start;
  }
  .debug-section {
    margin: 0 16px 16px;
  }
}
@media (max-width: 480px) {
  .call-center-container {
    gap: 12px;
  }
  .control-section {
    padding: 12px;
  }
}
</style>