<template> <div class="ivr-div"> <ivr-btn :show="!btnStatus.btnLogin" :disabled="btnStatus.btnLogin" icon="qianru" @click="login">签入</ivr-btn> <ivr-btn :show="!btnStatus.btnLogOut" :disabled="btnStatus.btnLogOut" icon="qianchu" @click="logout">签出</ivr-btn> <ivr-btn :show="btnStatus.btnUnhold" :disabled="btnStatus.btnHold" icon="baochi" @click="hold">保持</ivr-btn> <ivr-btn :show="!btnStatus.btnUnhold" :disabled="btnStatus.btnUnhold" icon="baochi" @click="unhold">解除保持</ivr-btn> <ivr-btn :show="true" :disabled="btnStatus.btnDialIn" icon="hujiao" @click="dialIn">内呼</ivr-btn> <ivr-btn :show="true" :disabled="btnStatus.btnDialOut" icon="duiwaihujiao" @click="dialOut">外呼</ivr-btn> <ivr-btn :disabled="btnStatus.btnHangUp" icon="guaji" @click="hangup">挂机</ivr-btn> <ivr-btn :disabled="btnStatus.btnMeeting" icon="hy" @click="meeting">会议</ivr-btn> <ivr-btn :show="!btnStatus.btnIdle" :disabled="btnStatus.btnIdle" icon="xiaoxiu" @click="onidle">退出示忙</ivr-btn> <ivr-btn :show="btnStatus.btnIdle" :disabled="btnStatus.btnBusy" icon="shimang" @click="onbusy">示忙</ivr-btn> <ivr-btn :show="!btnStatus.btnQuitWorkState" :disabled="btnStatus.btnWorkState" icon="icon-work" @click="quitWorkState">退出工作态</ivr-btn> <ivr-btn :show="!btnStatus.btnWorkState" :disabled="btnStatus.btnQuitWorkState" icon="icon-work" @click="goWorkState">进入工作态</ivr-btn> <!--<el-button v-show="!btnStatus.btnLogin" :disabled="btnStatus.btnLogin" @click="login">--> <!--<svg-icon icon-class="qianru" class="ivr-icon"/>--> <!--<div>签入</div>--> <!--</el-button>--> <!--<el-button v-show="!btnStatus.btnLogOut" :disabled="btnStatus.btnLogOut" @click="logout">--> <!--<svg-icon icon-class="qianchu" class="ivr-icon"/>--> <!--<div>签出</div>--> <!--</el-button>--> <!--<el-button v-show="btnStatus.btnUnhold" :disabled="btnStatus.btnHold" @click="hold">--> <!--<svg-icon icon-class="baochi" class="ivr-icon"/>--> <!--<div>保持</div>--> <!--</el-button>--> <!--<el-button v-show="!btnStatus.btnUnhold" :disabled="btnStatus.btnUnhold" @click="unhold">--> <!--<svg-icon icon-class="baochi" class="ivr-icon"/>--> <!--<div>解除保持</div>--> <!--</el-button>--> <!--<el-button :disabled="btnStatus.btnDialIn" @click="dialIn">--> <!--<svg-icon icon-class="hujiao" class="ivr-icon"/>--> <!--<div>内呼</div>--> <!--</el-button>--> <!--<el-button :disabled="btnStatus.btnDialOut" @click="dialOut">--> <!--<svg-icon icon-class="duiwaihujiao" class="ivr-icon"/>--> <!--<div>外呼</div>--> <!--</el-button>--> <!--<el-button :disabled="btnStatus.btnHangUp" @click="logout">--> <!--<svg-icon icon-class="hy" class="ivr-icon"/>--> <!--<div>会议</div>--> <!--</el-button>--> <!--<el-button :disabled="btnStatus.btnIdle" @click="onidle">--> <!--<svg-icon icon-class="xiaoxiu" class="ivr-icon"/>--> <!--<div>小休</div>--> <!--</el-button>--> <!--<el-button :disabled="btnStatus.btnBusy" @click="onbusy">--> <!--<svg-icon icon-class="shimang" class="ivr-icon"/>--> <!--<div>示忙</div>--> <!--</el-button>--> <div class="state-show"> <div>来电号码:<span class="highlight">{{ number }}</span></div> <div>通话时间:<span class="highlight">{{ timeFormat }}</span><span class="highlight">{{ call }}</span></div> </div> <!--软键盘--> <keyboard ref="keyboard" @call="dial"/> <choose-seats ref="chooseseats" :seat-list="seatsList" @call="dial"/> <!--外呼状态栏--> <el-dialog :title="statusText" :visible.sync="statusShow" width="30%" append-to-body> <span>{{ statusDetailText }}</span><span class="dot">...</span> </el-dialog> <!--新建事件页面--> <el-dialog :visible.sync="showAddCase" :close-on-click-modal="false" :close-on-press-escape="false" title="新建事件" width="1200px" custom-class="addcase-dialog" top="5vh" append-to-body> <create-case ref="addcase" @cancel="closeCreateDialog"/> </el-dialog> </div> </template> <script> import sapoOcx from 'sapoOcx' import Keyboard from './keyboard' import IvrBtn from './ivrBtn' import CreateCase from '@/views/caseManage/createCase' import { getToday } from '@/utils/dateutils' import ChooseSeats from './chooseSeats' import { getProject } from '@/utils/baseConfig' import { seatReport } from '@/api/call' export default { name: 'IvrBar', components: { ChooseSeats, CreateCase, IvrBtn, Keyboard }, data() { return { debug: true, // 是否开启debug模式 txtPhoneAccount: this.$store.getters.exten, // 分机号 agentName: 'test', // 坐席名 queue: 'kefu', // 技能组 staffId: '10101', // 工号 skill: '1', // 技能值 busyString: '忙碌', // 致忙原因 deptid: '1', // 部门编号 deptName: '客服部', // 部门名称 autoAcw: '0', // 自动化后 domain: '', security: '', seatsList: [], // 坐席列表 heartbeatInterval: 0, // 心跳interval heartbeatCount: 0, // 心跳heartbeat count heartbeatID: 0, // 心跳heartbeat id webSocket: {}, webVariable: { web_version: '2.17.0505', web_agentStaffid: null, web_agentName: null, web_skill: null, web_reg: null, web_callStatue: null, web_busyStatue: null, web_number: null, web_heartbeatCount: 0, web_heartbeatInterval: 0, web_heartbeatID: 0 }, btnStatus: { btnLogin: false, // 签入 btnLogOut: true, // 签出 btnHold: true, // 保持 btnUnhold: true, // 解除保持 btnHangUp: true, // 挂机 btnDialOut: true, // 外呼 btnDialIn: true, // 内呼 btnAgentList: true, btnBusy: true, // 示忙 btnIdle: true, // 示闲 btnMeeting: true, // 会议 btnPickUp: true, btnExtenPickUp: true, btnGroupPickUp: true, btnSpy: true, btnGroupSpy: true, btnVersion: true, btnGetExtension: true, btnGetAgentStaffid: true, btnGetAgentName: true, btnGetSkill: true, btnGetBusyState: true, btnGetAgentState: true, btnGetCallState: true, btnGetRegState: true, btnGetNumber: true, btnInject: true, btnWorkState: false, // 进入工作态 btnQuitWorkState: true // 退出工作态 }, status: '', // 签入状态:已连接,已签入,未签入 busy: '', // 坐席忙碌状态 :忙碌,'', 另一坐席已登录 call: '', // 通话状态 isInCall: false, // 是否在通话中 isInComing: false, // 是否来电 reg: '', // 话机状态 number: '', // 通话号码 diaStatus: '', // 拨号状态 statusText: '', // 拨号状态标题 statusDetailText: '', // 拨号状态详情 statusShow: false, // 拨号的状态显示 time: 0, // 来电计时,单位:秒 timeInterval: null, threeWayPhone: '', // 三方通话电话号码 ip: getProject().ws_ip, // websocket Ip port: getProject().ws_port, // webSocket Port showAddCase: false, // 是否显示新建案卷的弹窗 notify: null // 来电通知 } }, computed: { timeFormat() { // 将时间格式化为时分秒样式 const h = Math.floor(this.time / 3600) const m = Math.floor((this.time - h * 3600) / 60) const s = this.time - h * 3600 - m * 60 const str = ('0' + h).slice(-2) + ':' + ('0' + m).slice(-2) + ':' + ('0' + s).slice(-2) return str } }, watch: { busy(val) { // 忙碌状态为不忙时,示忙按钮允许点,示闲不允许 if (val === '') { this.btnStatus.btnBusy = false this.btnStatus.btnIdle = true } else if (val === '忙碌') { this.btnStatus.btnBusy = true this.btnStatus.btnIdle = false } } }, created() { this.initSapoOcx() }, methods: { // 坐席签入 login() { // 先判断websocket状态 if (this.$store.getters.websocket === '1') { // 如果没有分机号,先输入分机号,有分机号直接签入 if (!this.txtPhoneAccount) { this.$prompt('请输入分机号', '提示', { confirmButtonText: '确定', cancelButtonText: '取消' }).then(({ value }) => { this.txtPhoneAccount = value this.doLogin() }) } else { this.doLogin() } } else { this.$message.error('websocket连接状态异常,请刷新页面') } }, // websocket执行签入操作 doLogin() { // 替代sapoOcx.login if (this.heartbeatInterval == null || this.heartbeatInterval <= 0) { this.heartbeatInterval = 0 } this.webVariable.web_heartbeatInterval = this.heartbeatInterval this.webVariable.web_extension = this.txtPhoneAccount this.webVariable.web_agentStaffid = this.staffId this.webVariable.web_agentName = this.agentName this.webVariable.web_skill = this.skill // 替代socket.api.login const sendObj = { agent: this.agentName, extension: this.txtPhoneAccount, queue: this.queue, staffid: this.staffId, busyString: this.busyString, skill: this.skill, secid: this.deptid, secname: this.deptName, autoacw: this.autoAcw, domain: this.domain, security: this.security } const strSend = this.createCommand('login', sendObj) if (this.debug) { console.log(strSend) } this.webSocket.send(strSend) }, // 处理登录之后的操作 handleLoginResult(object) { if (object.success === 'true') { // 登录成功 // 处理其他按钮的可点击情况 this.btnStatus.btnLogin = true this.btnStatus.btnLogOut = false this.btnStatus.btnDialOut = false this.btnStatus.btnDialIn = false this.btnStatus.btnHold = false this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = false this.btnStatus.btnIdle = true this.btnStatus.btnMeeting = false this.btnStatus.btnHangUp = false this.status = '已签入' this.call = '空闲' this.$store.commit('SET_ONLINE', '1') // 通知后台已签入 this.sendRecord('1', '签入') // 心跳监测定时器 // if (this.webVariable.web_heartbeatInterval > 0) { // this.webVariable.web_heartbeatID = setInterval(this.sendHearbeatMessage(), this.webVariable.web_heartbeatID * 1000) // } const that = this if (this.heartbeatInterval > 0) { this.heartbeatID = setInterval(function() { console.log('startheartbeat') that.sendHearbeatMessage() }, that.heartbeatID * 1000) } if (this.busy === '') { // OnFree_cb() } else { // OnBusy_cb(busy) // } } } else if (object.resultText) { // 登录失败,显示错误信息 this.showError(object.resultText) if (object.resultText === '座席登录失败, 分机号码错误') { this.txtPhoneAccount = '' } } }, // 坐席签出 logout() { const strSend = 'command:logout\n\n' this.webSocket.send(strSend) }, // 处理签出成功之后的操作 handleLogoutResult(object) { if (object.success === 'true') { this.status = '未签入' this.$message.success('签出成功') // 处理按钮状态 this.btnStatus.btnLogin = false this.btnStatus.btnLogOut = true this.btnStatus.btnDialOut = true this.btnStatus.btnDialIn = true this.btnStatus.btnHold = true this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = false this.btnStatus.btnIdle = true this.btnStatus.btnMeeting = true this.btnStatus.btnHangUp = true this.$store.commit('SET_ONLINE', '0') this.resetWs() // 通知后台已签入 this.sendRecord('1', '签出') // 清除定时器 // clearInterval(this.webVariable.web_heartbeatID) clearInterval(this.heartbeatID) } }, // 外呼 dialOut() { if (this.judgeStatus()) { this.$refs.keyboard.initDialog('out') } }, // 内呼 dialIn() { if (this.judgeStatus()) { this.getExtentionList() this.$refs.chooseseats.initDialog('in') } }, // 获取坐席列表 getExtentionList() { const params = { type: 'agent' } const strSend = this.createCommand('getExtensionList', params) this.webSocket.send(strSend) }, // 拨号 dial(type, tel) { if (type === 'meeting') { this.goMeeting(tel) } if (type === 'out') { tel = '9' + tel } const sendObj = { dialString: tel, cusid: '', cusname: '', proid: '', proname: '' } this.statusShow = true this.statusText = '呼叫中...' this.statusDetailText = '呼叫中' const strSend = this.createCommand('dial', sendObj) this.webSocket.send(strSend) }, // 示忙 onbusy() { if (this.judgeStatus()) { const strSend = this.createCommand('busy') this.webSocket.send(strSend) this.btnStatus.btnBusy = true this.btnStatus.btnIdle = false } }, // 示闲 onidle() { if (this.judgeStatus()) { const strSend = this.createCommand('idle') this.webSocket.send(strSend) this.btnStatus.btnBusy = false this.btnStatus.btnIdle = true } }, // 挂机 hangup() { if (this.judgeStatus()) { // 判断坐席状态 if (this.judgeCallStatus()) { // 判断通话状态 const strSend = this.createCommand('hangup') this.webSocket.send(strSend) } } }, // 保持 hold() { if (this.judgeStatus()) { // 判断坐席状态 if (this.judgeCallStatus()) { // 判断通话状态 const strSend = this.createCommand('hold') this.webSocket.send(strSend) this.sendRecord('3', '进入保持') } } }, // 解除保持 unhold() { if (this.judgeStatus()) { const strSend = this.createCommand('unhold') this.webSocket.send(strSend) this.sendRecord('3', '解除保持') } }, // 处理接通来电事宜 incommingAnswerd(object) { this.isInComing = true this.notify.close() // 关闭来电提醒 // 处理按钮显示问题 this.btnStatus.btnDialOut = true this.btnStatus.btnDialIn = true this.btnStatus.btnHold = false this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = false this.btnStatus.btnIdle = true this.btnStatus.btnMeeting = true this.btnStatus.btnHangUp = false // 弹出新建工单窗口 this.showAddCase = true const data = { callid: object.callid, number: object.number, dialStartStamp: getToday('yyyy-MM-dd hh:mm:ss') } console.log(data) const that = this setTimeout(function() { that.$refs['addcase'].initData(data) }, 500) }, // 新建事件完毕调用 closeCreateDialog() { this.showAddCase = false // 关闭弹窗 }, // 进入工作态 goWorkState() { // this.$message.info('已进入工作态') this.btnStatus.btnWorkState = true this.btnStatus.btnQuitWorkState = false this.sendRecord('2', '进入工作态') this.onbusy() // 置忙 }, // 退出工作态 quitWorkState() { // this.$message.info('已退出工作态') this.btnStatus.btnWorkState = false this.btnStatus.btnQuitWorkState = true this.sendRecord('2', '退出工作态') this.onidle() // 置闲 }, // 点击会议,打开会议弹窗 meeting() { this.$refs.chooseseats.initDialog('meeting') }, // 开启三方通话 goMeeting(tel) { const params = { dialString: tel } const strSend = this.createCommand('threeWay', params) this.webSocket.send(strSend) }, // 初始化 initSapoOcx() { const { ip, port } = this sapoOcx.api.setDebugMode() // 连接服务器,参数:ip,端口,初始化websocket if (ip && port) { this.connect(ip, port) } else { this.$message.error('请检查websock连接配置') } }, // 创建连接 connect: function(server, port) { sapoOcx.api.config.serverIp = server if (port != null && port !== '') { sapoOcx.api.config.commuPort = port } this.connectToServer() }, // 连接服务器 connectToServer: function() { const that = this if (!sapoOcx.api.config.initFinish) { sapoOcx.api.config.initFinish = true setTimeout(that.connectToServer(), 200) } else { this.initConnection(sapoOcx.api.config.serverIp, sapoOcx.api.config.commuPort) } }, // 初始化websocket连接 initConnection(ip, port) { const wsUrl = 'ws://' + ip + ':' + port + '/spcc/cti' this.webSocket = new WebSocket(wsUrl) this.webSocket.onopen = this.wsOpen this.webSocket.onclose = this.wsClose this.webSocket.onmessage = this.wsMessage this.webSocket.onerror = this.wsError }, // webSocket onOpen wsOpen(event) { this.status = '已连接' this.$store.commit('SET_WEBSOCKET', '1') this.btnStatus.btnLogin = false }, // webSocket onClose wsClose(event) { this.resetWs() clearInterval(this.webVariable.web_heartbeatID) this.$store.commit('SET_WEBSOCKET', '1') }, // 处理websocket 返回消息 wsMessage(event) { const that = this // 将websocket 返回值转成对象 object const obj = event.data const data = obj.split('\n') const object = {} data.forEach(function(value) { if (value !== '') { const objData = value.split(':') object[objData[0]] = objData[1] } }) console.log(object) // 根据返回值处理 const messageType = object.messageType // 消息类型 if (messageType === 'event') { const name = object.name if (name === 'agent status') { // 坐席状态 } if (name === 'call status') { // 话机状态 if (object.call === '空闲') { this.call = '空闲' // 如果正在通话 if (this.isInCall === true) { this.statusDetailText = '通话结束' this.statusShow = false // 结束计时器 if (this.timeInterval) { this.$message.info('通话结束') clearInterval(this.timeInterval) this.timeInterval = null this.time = 0 } // 如果是来电,进入工作态 if (this.isInComing) { this.goWorkState() } this.isInCall = false } } else if (object.call === '外呼通话' || object.call === '呼入通话' || object.call === '自动外呼接入') { this.call = '通话中' this.isInCall = true this.statusDetailText = object.call // 开始通话后3s弹窗消失 if (this.statusShow) { setTimeout(function() { that.statusShow = false }, 3000) } this.time = 0 this.timeInterval = setInterval(function() { that.time += 1 }, 1000) } else { this.isInCall = true this.statusDetailText = object.call } } if (name === 'outbound ringing') { // 外呼振铃,除了挂断其他不允许点 this.btnStatus.btnDialOut = true this.btnStatus.btnDialIn = true this.btnStatus.btnHold = true this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = true this.btnStatus.btnIdle = true this.btnStatus.btnMeeting = true this.btnStatus.btnHangUp = false } if (name === 'outbound answered') { // 外呼接通,允许保持 this.isInComing = false this.btnStatus.btnDialOut = true this.btnStatus.btnDialIn = true this.btnStatus.btnHold = false this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = true this.btnStatus.btnIdle = true this.btnStatus.btnMeeting = true this.btnStatus.btnHangUp = false } if (name === 'incomming ringing') { this.notify = this.$notify({ title: '未接来电', message: object.number, duration: 0 }) } if (name === 'incomming answered') { // 来电接通 this.incommingAnswerd(object) } if (name === 'hangup') { // 挂断, 冻结和解除冻结不好用,其他均可 this.btnStatus.btnDialOut = false this.btnStatus.btnDialIn = false this.btnStatus.btnHold = true this.btnStatus.btnUnhold = true this.btnStatus.btnBusy = false this.btnStatus.btnIdle = false this.btnStatus.btnMeeting = false this.btnStatus.btnHangUp = false } if (name === 'status changed') { // 状态改变 this.busy = object.busy // 坐席忙碌状态 // this.call = object.call // 通话忙碌状态 this.reg = object.reg // 话机状态 this.number = object.number ? object.number : '' // 来电号码 this.webVariable.web_reg = object.reg this.webVariable.web_callStatue = object.call this.webVariable.web_agentStatue = object.agent this.webVariable.web_busyStatue = object.busy this.webVariable.web_number = object.number if (this.reg === '离线') { this.$message.warning('话机已离线,请检查!') if (this.statusShow === true) { // 如果有呼叫弹窗,关闭 this.statusShow = false } } } if (name === 'queue status') { // OnQueueStatusChanged_cb(object.idle, object.agent, object.guest, object.queue) } if (name === 'busy status') { // busy = object.busy // if (busy == '') { // OnFree_cb() // } else { // OnBusy_cb(busy) // } } } else if (messageType === 'heartbeat response') { // 心跳回复 this.webVariable.web_hearbeatCount = 0 this.heartbeatCount = 0 } else if (messageType === 'command response') { // 命令回复 const command = object.command if (command === 'login') { this.handleLoginResult(object) } else if (command === 'logout') { this.handleLogoutResult(object) } else if (command === 'getExtensionList') { this.handleExtensionList(object) } else if (command === 'dial') { if (object.success === 'true') { this.statusDetailText = object.resultText } } else if (object.resultText === '准备挂断' && object.success === 'true') { this.statusShow = false } else if (object.resultText === '准备保持' && object.success === 'true') { if (object.type === '') { // 保持成功后 this.btnStatus.btnUnhold = false this.btnStatus.btnHold = true } else if (object.type === 'off') { // 解除保持成功后 this.btnStatus.btnUnhold = true this.btnStatus.btnHold = false } } } }, // 获取坐席列表,存到seatsList handleExtensionList(object) { if (object.success) { const test = object.resultText const index = test.indexOf(',') if (index > 0) { const messageList = test.split(',') console.log(messageList.length) for (let i = 0; i < messageList.length; i++) { const tList = messageList[i].split('|') const seat = { exten: tList[0], name: tList[1], loginId: tList[2], sector: tList[3], group: tList[4], state: tList[5], busy: tList[6] } this.seatsList.push(seat) } } } }, // webSocket onError wsError(event) { console.log(event) }, resetWs() { this.webVariable.web_extension = null this.webVariable.web_agentStaffid = null this.webVariable.web_agentName = null this.webVariable.web_skill = null this.webVariable.web_reg = null this.webVariable.web_callStatue = null this.webVariable.web_agentStatue = null this.webVariable.web_busyStatue = null this.webVariable.web_number = null }, // 发送心跳,三次没有响应则断开连接 sendHearbeatMessage() { // if (this.webVariable.web_hearbeatCount >= 3) { // this.webSocket.close() // } else { // this.webVariable.web_hearbeatCount += 1 // const strSend = this.createCommand('heartbeat') // this.webSocket.send(strSend) // } if (this.heartbeatCount >= 3) { this.webSocket.close() } else { this.heartbeatCount += 1 const strSend = this.createCommand('heartbeat') console.log(strSend) this.webSocket.send(strSend) } }, // 显示错误信息 showError(message) { this.$message.error(message) }, // 创建命令 createCommand(command, object = null) { let sendStr = 'command:' if (command) { sendStr += command + '\n' if (object) { for (const key in object) { sendStr += key + ':' + object[key] + '\n' } } sendStr += '\n' } return sendStr }, // 判断坐席状态 judgeStatus() { if (this.status === '已签入') { return true } else { this.$message.warning('请先签入呼叫平台') return false } }, // 判断通话状态 judgeCallStatus() { if (this.isInCall) { return true } else { this.$message.warning('未在通话中') return false } }, // 向后台写记录 sendRecord(type, recordName) { seatReport(type, recordName) } } } </script> <style rel="stylesheet/scss" lang="scss" scoped> .ivr-div{ position: fixed; top:0px; left:240px; padding-top: 10px; .el-button--default{ width:50px; display: inline-block; line-height: 1; white-space: nowrap; cursor: pointer; background-color:transparent; border-style:none; color: #ffffff; -webkit-appearance: none; text-align: center; -webkit-box-sizing: border-box; box-sizing: border-box; outline: none; margin: 0; -webkit-transition: .1s; transition: .1s; font-weight: 500; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; padding: 3px 3px; font-size: 12px; .ivr-icon{ width: 100%; text-align: center; display: block; font-size: 25px; margin-bottom: 2px; } } .el-button.is-disabled, .el-button.is-disabled:hover, .el-button.is-disabled:focus{ color: #e1e1e1; .ivr-icon{ color: #e1e1e1 !important; } } .state-show{ display: inline-block; width:180px; margin-left: 20px; border:1px solid #409EFF; color:#ffffff; font-size:12px; line-height: 17px; padding:5px 5px; height: 45px; box-sizing: border-box; transform: translate(0, -8px); } .highlight{ font-size:13px; font-weight: 600; color:#00ffff; padding-left: 5px; } } </style> <style> .addcase-dialog{ } .dot { font-family: simsun; } :root .dot { display: inline-block; width: 1.5em; vertical-align: bottom; overflow: hidden; } @-webkit-keyframes dot { 0% { width: 0; margin-right: 1.5em; } 33% { width: .5em; margin-right: 1em; } 66% { width: 1em; margin-right: .5em; } 100% { width: 1.5em; margin-right: 0;} } .dot { -webkit-animation: dot 3s infinite step-start; } @keyframes dot { 0% { width: 0; margin-right: 1.5em; } 33% { width: .5em; margin-right: 1em; } 66% { width: 1em; margin-right: .5em; } 100% { width: 1.5em; margin-right: 0;} } .dot { animation: dot 3s infinite step-start; } </style>