12 changed files with 1076 additions and 17 deletions
@ -0,0 +1,55 @@ |
|||||
|
-- ============================================= |
||||
|
-- 电话管理系统 - 人大金仓数据库表结构 |
||||
|
-- 创建日期: 2026-06-09 |
||||
|
-- ============================================= |
||||
|
|
||||
|
-- 1. 电话记录表 |
||||
|
CREATE TABLE IF NOT EXISTS t_phone_record ( |
||||
|
id BIGINT PRIMARY KEY, |
||||
|
call_id VARCHAR(100) NOT NULL, |
||||
|
caller_number VARCHAR(50), |
||||
|
caller_name VARCHAR(100), |
||||
|
callee_number VARCHAR(50), |
||||
|
callee_name VARCHAR(100), |
||||
|
call_direction VARCHAR(20), |
||||
|
call_start_time TIMESTAMP, |
||||
|
call_end_time TIMESTAMP, |
||||
|
call_duration INTEGER, |
||||
|
call_status VARCHAR(20), |
||||
|
recording_url VARCHAR(500), |
||||
|
has_recording INTEGER DEFAULT 0, |
||||
|
call_type VARCHAR(20), |
||||
|
remark TEXT, |
||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
|
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
|
deleted INTEGER DEFAULT 0 |
||||
|
); |
||||
|
|
||||
|
COMMENT ON TABLE t_phone_record IS '电话记录表'; |
||||
|
COMMENT ON COLUMN t_phone_record.id IS '主键ID'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_id IS '通话ID(呼叫系统唯一标识)'; |
||||
|
COMMENT ON COLUMN t_phone_record.caller_number IS '主叫号码'; |
||||
|
COMMENT ON COLUMN t_phone_record.caller_name IS '主叫姓名'; |
||||
|
COMMENT ON COLUMN t_phone_record.callee_number IS '被叫号码'; |
||||
|
COMMENT ON COLUMN t_phone_record.callee_name IS '被叫姓名'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_direction IS '通话方向:IN-呼入, OUT-呼出'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_start_time IS '通话开始时间'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_end_time IS '通话结束时间'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_duration IS '通话时长(秒)'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_status IS '通话状态:ANSWERED-已接通, MISSED-未接听, BUSY-忙线, REJECTED-拒接'; |
||||
|
COMMENT ON COLUMN t_phone_record.recording_url IS '录音文件URL'; |
||||
|
COMMENT ON COLUMN t_phone_record.has_recording IS '是否有录音:0-无, 1-有'; |
||||
|
COMMENT ON COLUMN t_phone_record.call_type IS '通话类型:NORMAL-普通, TRANSFER-转接, CONFERENCE-会议'; |
||||
|
COMMENT ON COLUMN t_phone_record.remark IS '备注'; |
||||
|
COMMENT ON COLUMN t_phone_record.create_time IS '创建时间'; |
||||
|
COMMENT ON COLUMN t_phone_record.update_time IS '更新时间'; |
||||
|
COMMENT ON COLUMN t_phone_record.deleted IS '逻辑删除:0-未删除, 1-已删除'; |
||||
|
|
||||
|
-- 创建索引 |
||||
|
CREATE INDEX idx_phone_record_call_id ON t_phone_record(call_id); |
||||
|
CREATE INDEX idx_phone_record_caller_number ON t_phone_record(caller_number); |
||||
|
CREATE INDEX idx_phone_record_call_start_time ON t_phone_record(call_start_time); |
||||
|
CREATE INDEX idx_phone_record_call_status ON t_phone_record(call_status); |
||||
|
|
||||
|
-- 序列(用于主键自增) |
||||
|
CREATE SEQUENCE IF NOT EXISTS seq_phone_record_id START WITH 1 INCREMENT BY 1; |
||||
@ -0,0 +1,41 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.controller; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.annotation.Resource; |
||||
|
import jakarta.validation.Valid; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.form.PhoneRecordQueryForm; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.vo.PhoneRecordVO; |
||||
|
import net.lab1024.sa.admin.module.business.phone.service.PhoneService; |
||||
|
import net.lab1024.sa.base.common.domain.PageResult; |
||||
|
import net.lab1024.sa.base.common.domain.ResponseDTO; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
|
||||
|
/** |
||||
|
* 电话管理控制器 |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@RestController |
||||
|
@Tag(name = "业务功能-电话管理") |
||||
|
public class PhoneController { |
||||
|
|
||||
|
@Resource |
||||
|
private PhoneService phoneService; |
||||
|
|
||||
|
@Operation(summary = "分页查询电话记录") |
||||
|
@PostMapping("/phone/record/queryPage") |
||||
|
public ResponseDTO<PageResult<PhoneRecordVO>> queryPhoneRecordPage(@RequestBody @Valid PhoneRecordQueryForm queryForm) { |
||||
|
return phoneService.queryPhoneRecordPage(queryForm); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "模拟来电(测试用)") |
||||
|
@PostMapping("/phone/simulate/incomingCall") |
||||
|
public ResponseDTO<String> simulateIncomingCall( |
||||
|
@RequestParam String callerNumber, |
||||
|
@RequestParam String callerName, |
||||
|
@RequestParam String agentId) { |
||||
|
return phoneService.simulateIncomingCall(callerNumber, callerName, agentId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.dao; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.entity.PhoneRecordEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** |
||||
|
* 电话记录DAO |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@Mapper |
||||
|
@Component |
||||
|
public interface PhoneRecordDao extends BaseMapper<PhoneRecordEntity> { |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* 电话记录实体类 |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@Data |
||||
|
@TableName("t_phone_record") |
||||
|
@Schema(description = "电话记录") |
||||
|
public class PhoneRecordEntity { |
||||
|
|
||||
|
@TableId(type = IdType.INPUT) |
||||
|
@Schema(description = "主键ID") |
||||
|
private Long id; |
||||
|
|
||||
|
@Schema(description = "通话ID(呼叫系统唯一标识)") |
||||
|
private String callId; |
||||
|
|
||||
|
@Schema(description = "主叫号码") |
||||
|
private String callerNumber; |
||||
|
|
||||
|
@Schema(description = "主叫姓名") |
||||
|
private String callerName; |
||||
|
|
||||
|
@Schema(description = "被叫号码") |
||||
|
private String calleeNumber; |
||||
|
|
||||
|
@Schema(description = "被叫姓名") |
||||
|
private String calleeName; |
||||
|
|
||||
|
@Schema(description = "通话方向:IN-呼入, OUT-呼出") |
||||
|
private String callDirection; |
||||
|
|
||||
|
@Schema(description = "通话开始时间") |
||||
|
private LocalDateTime callStartTime; |
||||
|
|
||||
|
@Schema(description = "通话结束时间") |
||||
|
private LocalDateTime callEndTime; |
||||
|
|
||||
|
@Schema(description = "通话时长(秒)") |
||||
|
private Integer callDuration; |
||||
|
|
||||
|
@Schema(description = "通话状态:ANSWERED-已接通, MISSED-未接听, BUSY-忙线, REJECTED-拒接") |
||||
|
private String callStatus; |
||||
|
|
||||
|
@Schema(description = "录音文件URL") |
||||
|
private String recordingUrl; |
||||
|
|
||||
|
@Schema(description = "是否有录音:0-无, 1-有") |
||||
|
private Integer hasRecording; |
||||
|
|
||||
|
@Schema(description = "通话类型:NORMAL-普通, TRANSFER-转接, CONFERENCE-会议") |
||||
|
private String callType; |
||||
|
|
||||
|
@Schema(description = "备注") |
||||
|
private String remark; |
||||
|
|
||||
|
@Schema(description = "创建时间") |
||||
|
private LocalDateTime createTime; |
||||
|
|
||||
|
@Schema(description = "更新时间") |
||||
|
private LocalDateTime updateTime; |
||||
|
|
||||
|
@Schema(description = "逻辑删除:0-未删除, 1-已删除") |
||||
|
private Integer deleted; |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.domain.form; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
import net.lab1024.sa.base.common.domain.PageParam; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* 电话记录查询表单 |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Schema(description = "电话记录查询表单") |
||||
|
public class PhoneRecordQueryForm extends PageParam { |
||||
|
|
||||
|
@Schema(description = "通话ID") |
||||
|
private String callId; |
||||
|
|
||||
|
@Schema(description = "主叫号码") |
||||
|
private String callerNumber; |
||||
|
|
||||
|
@Schema(description = "主叫姓名") |
||||
|
private String callerName; |
||||
|
|
||||
|
@Schema(description = "被叫号码") |
||||
|
private String calleeNumber; |
||||
|
|
||||
|
@Schema(description = "通话方向:IN-呼入, OUT-呼出") |
||||
|
private String callDirection; |
||||
|
|
||||
|
@Schema(description = "通话状态:ANSWERED-已接通, MISSED-未接听, BUSY-忙线, REJECTED-拒接") |
||||
|
private String callStatus; |
||||
|
|
||||
|
@Schema(description = "通话类型:NORMAL-普通, TRANSFER-转接, CONFERENCE-会议") |
||||
|
private String callType; |
||||
|
|
||||
|
@Schema(description = "开始时间") |
||||
|
private LocalDateTime startTime; |
||||
|
|
||||
|
@Schema(description = "结束时间") |
||||
|
private LocalDateTime endTime; |
||||
|
|
||||
|
@Schema(description = "是否有录音:0-无, 1-有") |
||||
|
private Integer hasRecording; |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.domain.vo; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* 电话记录VO |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Schema(description = "电话记录VO") |
||||
|
public class PhoneRecordVO { |
||||
|
|
||||
|
@Schema(description = "主键ID") |
||||
|
private Long id; |
||||
|
|
||||
|
@Schema(description = "通话ID") |
||||
|
private String callId; |
||||
|
|
||||
|
@Schema(description = "主叫号码") |
||||
|
private String callerNumber; |
||||
|
|
||||
|
@Schema(description = "主叫姓名") |
||||
|
private String callerName; |
||||
|
|
||||
|
@Schema(description = "被叫号码") |
||||
|
private String calleeNumber; |
||||
|
|
||||
|
@Schema(description = "被叫姓名") |
||||
|
private String calleeName; |
||||
|
|
||||
|
@Schema(description = "通话方向:IN-呼入, OUT-呼出") |
||||
|
private String callDirection; |
||||
|
|
||||
|
@Schema(description = "通话方向文本") |
||||
|
private String callDirectionText; |
||||
|
|
||||
|
@Schema(description = "通话开始时间") |
||||
|
private LocalDateTime callStartTime; |
||||
|
|
||||
|
@Schema(description = "通话结束时间") |
||||
|
private LocalDateTime callEndTime; |
||||
|
|
||||
|
@Schema(description = "通话时长(秒)") |
||||
|
private Integer callDuration; |
||||
|
|
||||
|
@Schema(description = "通话时长文本") |
||||
|
private String callDurationText; |
||||
|
|
||||
|
@Schema(description = "通话状态:ANSWERED-已接通, MISSED-未接听, BUSY-忙线, REJECTED-拒接") |
||||
|
private String callStatus; |
||||
|
|
||||
|
@Schema(description = "通话状态文本") |
||||
|
private String callStatusText; |
||||
|
|
||||
|
@Schema(description = "录音文件URL") |
||||
|
private String recordingUrl; |
||||
|
|
||||
|
@Schema(description = "是否有录音:0-无, 1-有") |
||||
|
private Integer hasRecording; |
||||
|
|
||||
|
@Schema(description = "通话类型:NORMAL-普通, TRANSFER-转接, CONFERENCE-会议") |
||||
|
private String callType; |
||||
|
|
||||
|
@Schema(description = "备注") |
||||
|
private String remark; |
||||
|
|
||||
|
@Schema(description = "创建时间") |
||||
|
private LocalDateTime createTime; |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
package net.lab1024.sa.admin.module.business.phone.service; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers; |
||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||
|
import jakarta.annotation.Resource; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import net.lab1024.sa.admin.module.business.phone.dao.PhoneRecordDao; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.entity.PhoneRecordEntity; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.form.PhoneRecordQueryForm; |
||||
|
import net.lab1024.sa.admin.module.business.phone.domain.vo.PhoneRecordVO; |
||||
|
import net.lab1024.sa.base.common.domain.PageResult; |
||||
|
import net.lab1024.sa.base.common.domain.ResponseDTO; |
||||
|
import net.lab1024.sa.base.common.util.SmartBeanUtil; |
||||
|
import net.lab1024.sa.base.common.util.SmartPageUtil; |
||||
|
import net.lab1024.sa.base.config.PhoneWebSocketHandler; |
||||
|
import org.apache.commons.lang3.StringUtils; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** |
||||
|
* 电话管理业务服务类 |
||||
|
* |
||||
|
* @Author SmartFlow |
||||
|
* @Date 2026-06-09 |
||||
|
*/ |
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class PhoneService { |
||||
|
|
||||
|
@Resource |
||||
|
private PhoneRecordDao phoneRecordDao; |
||||
|
|
||||
|
@Resource |
||||
|
private PhoneWebSocketHandler phoneWebSocketHandler; |
||||
|
|
||||
|
/** |
||||
|
* 分页查询电话记录列表 |
||||
|
*/ |
||||
|
public ResponseDTO<PageResult<PhoneRecordVO>> queryPhoneRecordPage(PhoneRecordQueryForm queryForm) { |
||||
|
Page<?> queryPage = SmartPageUtil.convert2PageQuery(queryForm); |
||||
|
Page<PhoneRecordEntity> page = new Page<>(queryPage.getCurrent(), queryPage.getSize()); |
||||
|
LambdaQueryWrapper<PhoneRecordEntity> wrapper = Wrappers.lambdaQuery(); |
||||
|
|
||||
|
if (StringUtils.isNotBlank(queryForm.getCallerNumber())) { |
||||
|
wrapper.like(PhoneRecordEntity::getCallerNumber, queryForm.getCallerNumber()); |
||||
|
} |
||||
|
if (StringUtils.isNotBlank(queryForm.getCallerName())) { |
||||
|
wrapper.like(PhoneRecordEntity::getCallerName, queryForm.getCallerName()); |
||||
|
} |
||||
|
if (StringUtils.isNotBlank(queryForm.getCallStatus())) { |
||||
|
wrapper.eq(PhoneRecordEntity::getCallStatus, queryForm.getCallStatus()); |
||||
|
} |
||||
|
if (queryForm.getStartTime() != null) { |
||||
|
wrapper.ge(PhoneRecordEntity::getCallStartTime, queryForm.getStartTime()); |
||||
|
} |
||||
|
if (queryForm.getEndTime() != null) { |
||||
|
wrapper.le(PhoneRecordEntity::getCallStartTime, queryForm.getEndTime()); |
||||
|
} |
||||
|
|
||||
|
wrapper.eq(PhoneRecordEntity::getDeleted, 0); |
||||
|
wrapper.orderByDesc(PhoneRecordEntity::getCallStartTime); |
||||
|
|
||||
|
phoneRecordDao.selectPage(page, wrapper); |
||||
|
List<PhoneRecordVO> voList = SmartBeanUtil.copyList(page.getRecords(), PhoneRecordVO.class); |
||||
|
|
||||
|
voList.forEach(vo -> { |
||||
|
vo.setCallDirectionText("IN".equals(vo.getCallDirection()) ? "呼入" : "呼出"); |
||||
|
vo.setCallStatusText(getCallStatusText(vo.getCallStatus())); |
||||
|
vo.setCallDurationText(formatDuration(vo.getCallDuration())); |
||||
|
}); |
||||
|
|
||||
|
PageResult<PhoneRecordVO> pageResult = SmartPageUtil.convert2PageResult(page, voList); |
||||
|
return ResponseDTO.ok(pageResult); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 模拟接收来电(用于测试) |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public ResponseDTO<String> simulateIncomingCall(String callerNumber, String callerName, String agentId) { |
||||
|
String callId = "CALL-" + System.currentTimeMillis(); |
||||
|
|
||||
|
PhoneRecordEntity record = new PhoneRecordEntity(); |
||||
|
record.setId(System.currentTimeMillis()); |
||||
|
record.setCallId(callId); |
||||
|
record.setCallerNumber(callerNumber); |
||||
|
record.setCallerName(callerName); |
||||
|
record.setCallDirection("IN"); |
||||
|
record.setCallStartTime(LocalDateTime.now()); |
||||
|
record.setCallStatus("ANSWERED"); |
||||
|
record.setHasRecording(0); |
||||
|
record.setCreateTime(LocalDateTime.now()); |
||||
|
record.setUpdateTime(LocalDateTime.now()); |
||||
|
record.setDeleted(0); |
||||
|
|
||||
|
phoneRecordDao.insert(record); |
||||
|
|
||||
|
Map<String, Object> callData = new HashMap<>(); |
||||
|
callData.put("callId", callId); |
||||
|
callData.put("callerNumber", callerNumber); |
||||
|
callData.put("callerName", callerName); |
||||
|
callData.put("callStartTime", LocalDateTime.now().toString()); |
||||
|
callData.put("callDirection", "IN"); |
||||
|
|
||||
|
phoneWebSocketHandler.sendIncomingCall(agentId, callData); |
||||
|
|
||||
|
log.info("模拟来电成功 - 来电号码: {}, 来电姓名: {}, 坐席: {}", callerNumber, callerName, agentId); |
||||
|
|
||||
|
return ResponseDTO.ok(callId); |
||||
|
} |
||||
|
|
||||
|
private String getCallStatusText(String status) { |
||||
|
if (status == null) return ""; |
||||
|
switch (status) { |
||||
|
case "ANSWERED": return "已接"; |
||||
|
case "MISSED": return "未接"; |
||||
|
case "BUSY": return "忙线"; |
||||
|
case "REJECTED": return "拒接"; |
||||
|
default: return status; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String formatDuration(Integer seconds) { |
||||
|
if (seconds == null || seconds == 0) return "0秒"; |
||||
|
int hours = seconds / 3600; |
||||
|
int minutes = (seconds % 3600) / 60; |
||||
|
int secs = seconds % 60; |
||||
|
|
||||
|
StringBuilder sb = new StringBuilder(); |
||||
|
if (hours > 0) sb.append(hours).append("小时"); |
||||
|
if (minutes > 0) sb.append(minutes).append("分钟"); |
||||
|
if (secs > 0) sb.append(secs).append("秒"); |
||||
|
return sb.toString(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
import { postRequest, getRequest } from '/@/lib/axios'; |
||||
|
|
||||
|
export const phoneApi = { |
||||
|
queryPhoneRecordPage: (param) => { |
||||
|
return postRequest('/phone/record/queryPage', param); |
||||
|
}, |
||||
|
|
||||
|
simulateIncomingCall: (callerNumber, callerName, agentId) => { |
||||
|
return postRequest(`/phone/simulate/incomingCall?callerNumber=${callerNumber}&callerName=${callerName}&agentId=${agentId}`); |
||||
|
}, |
||||
|
}; |
||||
@ -0,0 +1,151 @@ |
|||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
class PhoneWebSocket { |
||||
|
constructor() { |
||||
|
this.ws = null; |
||||
|
this.agentId = null; |
||||
|
this.reconnectTimer = null; |
||||
|
this.heartbeatTimer = null; |
||||
|
this.reconnectCount = 0; |
||||
|
this.maxReconnectCount = 5; |
||||
|
this.reconnectInterval = 3000; |
||||
|
this.heartbeatInterval = 30000; |
||||
|
this.listeners = {}; |
||||
|
} |
||||
|
|
||||
|
connect(agentId) { |
||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
||||
|
console.log('WebSocket已连接'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.agentId = agentId; |
||||
|
const baseUrl = import.meta.env.VITE_APP_API_URL || ''; |
||||
|
const wsUrl = baseUrl.replace('http', 'ws') + '/websocket/phone?agentId=' + agentId; |
||||
|
|
||||
|
try { |
||||
|
this.ws = new WebSocket(wsUrl); |
||||
|
|
||||
|
this.ws.onopen = () => { |
||||
|
console.log('WebSocket连接成功'); |
||||
|
this.reconnectCount = 0; |
||||
|
this.startHeartbeat(); |
||||
|
this.emit('connected'); |
||||
|
}; |
||||
|
|
||||
|
this.ws.onmessage = (event) => { |
||||
|
try { |
||||
|
const data = JSON.parse(event.data); |
||||
|
console.log('收到WebSocket消息:', data); |
||||
|
|
||||
|
if (data.type === 'INCOMING_CALL') { |
||||
|
this.emit('incomingCall', data.data); |
||||
|
} else if (data.type === 'CALL_ENDED') { |
||||
|
this.emit('callEnded', data.callId); |
||||
|
} else { |
||||
|
this.emit('message', data); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('解析WebSocket消息失败:', error); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
this.ws.onerror = (error) => { |
||||
|
console.error('WebSocket错误:', error); |
||||
|
this.emit('error', error); |
||||
|
}; |
||||
|
|
||||
|
this.ws.onclose = (event) => { |
||||
|
console.log('WebSocket连接关闭:', event.code, event.reason); |
||||
|
this.stopHeartbeat(); |
||||
|
this.emit('disconnected'); |
||||
|
|
||||
|
if (event.code !== 1000) { |
||||
|
this.reconnect(); |
||||
|
} |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
console.error('WebSocket连接失败:', error); |
||||
|
this.reconnect(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
reconnect() { |
||||
|
if (this.reconnectCount >= this.maxReconnectCount) { |
||||
|
console.log('达到最大重连次数,停止重连'); |
||||
|
message.error('WebSocket连接失败,请刷新页面重试'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.reconnectCount++; |
||||
|
console.log(`准备第${this.reconnectCount}次重连...`); |
||||
|
|
||||
|
this.reconnectTimer = setTimeout(() => { |
||||
|
this.connect(this.agentId); |
||||
|
}, this.reconnectInterval); |
||||
|
} |
||||
|
|
||||
|
startHeartbeat() { |
||||
|
this.stopHeartbeat(); |
||||
|
this.heartbeatTimer = setInterval(() => { |
||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
||||
|
this.ws.send(JSON.stringify({ type: 'PING', timestamp: Date.now() })); |
||||
|
} |
||||
|
}, this.heartbeatInterval); |
||||
|
} |
||||
|
|
||||
|
stopHeartbeat() { |
||||
|
if (this.heartbeatTimer) { |
||||
|
clearInterval(this.heartbeatTimer); |
||||
|
this.heartbeatTimer = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
on(event, callback) { |
||||
|
if (!this.listeners[event]) { |
||||
|
this.listeners[event] = []; |
||||
|
} |
||||
|
this.listeners[event].push(callback); |
||||
|
} |
||||
|
|
||||
|
off(event, callback) { |
||||
|
if (this.listeners[event]) { |
||||
|
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
emit(event, data) { |
||||
|
if (this.listeners[event]) { |
||||
|
this.listeners[event].forEach(callback => { |
||||
|
try { |
||||
|
callback(data); |
||||
|
} catch (error) { |
||||
|
console.error(`执行${event}事件回调失败:`, error); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
disconnect() { |
||||
|
this.stopHeartbeat(); |
||||
|
if (this.reconnectTimer) { |
||||
|
clearTimeout(this.reconnectTimer); |
||||
|
this.reconnectTimer = null; |
||||
|
} |
||||
|
if (this.ws) { |
||||
|
this.ws.close(1000, '客户端主动关闭'); |
||||
|
this.ws = null; |
||||
|
} |
||||
|
this.listeners = {}; |
||||
|
} |
||||
|
|
||||
|
send(data) { |
||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
||||
|
this.ws.send(JSON.stringify(data)); |
||||
|
} else { |
||||
|
console.warn('WebSocket未连接,无法发送消息'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const phoneWebSocket = new PhoneWebSocket(); |
||||
@ -1,33 +1,466 @@ |
|||||
<template> |
<template> |
||||
<div class="page-container"> |
<div class="page-container"> |
||||
<div class="glass-card"> |
<div class="glass-card"> |
||||
<div class="header"> |
|
||||
<a-space size="middle"> |
|
||||
<div class="icon-wrapper"> |
|
||||
<CustomerServiceOutlined class="icon" /> |
|
||||
</div> |
|
||||
<div> |
|
||||
<h2 class="title">热线客服工作台</h2> |
|
||||
<p class="subtitle">话务坐席专用,集成来电弹屏、电话接听、通话历史及现场快速登记工单。</p> |
|
||||
</div> |
|
||||
</a-space> |
|
||||
</div> |
|
||||
|
|
||||
<div class="content-box"> |
<div class="content-box"> |
||||
<div class="table-placeholder"> |
<div class="search-box"> |
||||
<a-empty description="坐席客服工作台加载中,软电话条及来电弹屏模块开发中" /> |
<a-form layout="inline" :model="queryForm" @finish="handleQuery"> |
||||
|
<a-form-item label="接通结果"> |
||||
|
<a-button-group> |
||||
|
<a-button |
||||
|
:type="queryForm.callStatus === 'ANSWERED' ? 'primary' : 'default'" |
||||
|
@click="queryForm.callStatus = queryForm.callStatus === 'ANSWERED' ? '' : 'ANSWERED'" |
||||
|
> |
||||
|
已接 |
||||
|
</a-button> |
||||
|
<a-button |
||||
|
:type="queryForm.callStatus === 'MISSED' ? 'primary' : 'default'" |
||||
|
@click="queryForm.callStatus = queryForm.callStatus === 'MISSED' ? '' : 'MISSED'" |
||||
|
> |
||||
|
未接 |
||||
|
</a-button> |
||||
|
</a-button-group> |
||||
|
</a-form-item> |
||||
|
<a-form-item label="来电时间"> |
||||
|
<a-range-picker |
||||
|
v-model:value="queryForm.dateRange" |
||||
|
:presets="datePresets" |
||||
|
show-time |
||||
|
format="YYYY-MM-DD HH:mm:ss" |
||||
|
/> |
||||
|
</a-form-item> |
||||
|
<a-form-item> |
||||
|
<a-space> |
||||
|
<a-button type="primary" html-type="submit"> |
||||
|
<template #icon><SearchOutlined /></template> |
||||
|
查询 |
||||
|
</a-button> |
||||
|
<a-button @click="handleReset"> |
||||
|
<template #icon><ReloadOutlined /></template> |
||||
|
重置 |
||||
|
</a-button> |
||||
|
<a-button type="primary" v-if="hasPermission('myrx:phone:mock')" danger @click="handleSimulateCall"> |
||||
|
<template #icon><PhoneOutlined /></template> |
||||
|
模拟来电 |
||||
|
</a-button> |
||||
|
</a-space> |
||||
|
</a-form-item> |
||||
|
</a-form> |
||||
|
</div> |
||||
|
|
||||
|
<div class="table-box"> |
||||
|
<a-table |
||||
|
:columns="columns" |
||||
|
:data-source="tableData" |
||||
|
:loading="tableLoading" |
||||
|
:pagination="pagination" |
||||
|
row-key="id" |
||||
|
@change="handleTableChange" |
||||
|
> |
||||
|
<template #bodyCell="{ column, record }"> |
||||
|
<template v-if="column.dataIndex === 'callDirection'"> |
||||
|
<a-tag :color="record.callDirection === 'IN' ? 'green' : 'blue'"> |
||||
|
{{ record.callDirectionText }} |
||||
|
</a-tag> |
||||
|
</template> |
||||
|
<template v-if="column.dataIndex === 'callStatus'"> |
||||
|
<a-tag :color="getStatusColor(record.callStatus)"> |
||||
|
{{ record.callStatusText }} |
||||
|
</a-tag> |
||||
|
</template> |
||||
|
<template v-if="column.dataIndex === 'hasRecording'"> |
||||
|
<a-tag v-if="record.hasRecording === 1" color="green">有录音</a-tag> |
||||
|
<a-tag v-else color="default">无录音</a-tag> |
||||
|
</template> |
||||
|
<template v-if="column.dataIndex === 'callStartTime'"> |
||||
|
{{ formatDateTime(record.callStartTime) }} |
||||
|
</template> |
||||
|
</template> |
||||
|
</a-table> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
|
|
||||
|
<a-modal |
||||
|
v-model:open="incomingCallModalVisible" |
||||
|
title="来电弹屏" |
||||
|
:footer="null" |
||||
|
:closable="false" |
||||
|
:maskClosable="false" |
||||
|
width="500px" |
||||
|
class="incoming-call-modal" |
||||
|
> |
||||
|
<div class="incoming-call-content"> |
||||
|
<div class="call-icon"> |
||||
|
<PhoneOutlined class="ringing-icon" /> |
||||
|
</div> |
||||
|
<div class="call-info"> |
||||
|
<h3>新来电</h3> |
||||
|
<div class="info-item"> |
||||
|
<span class="label">来电号码:</span> |
||||
|
<span class="value">{{ incomingCallData.callerNumber }}</span> |
||||
|
</div> |
||||
|
<div class="info-item"> |
||||
|
<span class="label">来电姓名:</span> |
||||
|
<span class="value">{{ incomingCallData.callerName || '未知' }}</span> |
||||
|
</div> |
||||
|
<div class="info-item"> |
||||
|
<span class="label">来电时间:</span> |
||||
|
<span class="value">{{ formatDateTime(incomingCallData.callStartTime) }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="call-actions"> |
||||
|
<a-button type="primary" size="large" @click="handleAnswerCall"> |
||||
|
<template #icon><PhoneOutlined /></template> |
||||
|
接听 |
||||
|
</a-button> |
||||
|
<a-button size="large" @click="handleRejectCall"> |
||||
|
<template #icon><PhoneOutlined class="rotate-135" /></template> |
||||
|
挂断 |
||||
|
</a-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</a-modal> |
||||
</div> |
</div> |
||||
</template> |
</template> |
||||
|
|
||||
<script setup> |
<script setup> |
||||
import { CustomerServiceOutlined } from '@ant-design/icons-vue'; |
import { ref, reactive, onMounted, onUnmounted } from 'vue'; |
||||
|
import { CustomerServiceOutlined, SearchOutlined, ReloadOutlined, PhoneOutlined } from '@ant-design/icons-vue'; |
||||
|
import { phoneApi } from '/@/api/business/phone/phone-api'; |
||||
|
import { phoneWebSocket } from '/@/utils/phone-websocket'; |
||||
|
import { message } from 'ant-design-vue'; |
||||
|
import dayjs from 'dayjs'; |
||||
|
import { useUserStore } from '/@/store/modules/system/user'; |
||||
|
import { some } from 'lodash-es'; |
||||
|
const userStore = useUserStore(); |
||||
|
|
||||
|
const queryForm = reactive({ |
||||
|
callerNumber: '', |
||||
|
callerName: '', |
||||
|
callStatus: '', |
||||
|
dateRange: [], |
||||
|
pageNum: 1, |
||||
|
pageSize: 10, |
||||
|
}); |
||||
|
|
||||
|
const tableData = ref([]); |
||||
|
const tableLoading = ref(false); |
||||
|
const pagination = reactive({ |
||||
|
current: 1, |
||||
|
pageSize: 10, |
||||
|
total: 0, |
||||
|
showSizeChanger: true, |
||||
|
showTotal: (total) => `共 ${total} 条`, |
||||
|
}); |
||||
|
|
||||
|
const incomingCallModalVisible = ref(false); |
||||
|
const incomingCallData = ref({}); |
||||
|
|
||||
|
const datePresets = [ |
||||
|
{ |
||||
|
label: '今天', |
||||
|
value: [dayjs().startOf('day'), dayjs().endOf('day')], |
||||
|
}, |
||||
|
{ |
||||
|
label: '昨天', |
||||
|
value: [dayjs().subtract(1, 'day').startOf('day'), dayjs().subtract(1, 'day').endOf('day')], |
||||
|
}, |
||||
|
{ |
||||
|
label: '近7天', |
||||
|
value: [dayjs().subtract(7, 'day'), dayjs()], |
||||
|
}, |
||||
|
{ |
||||
|
label: '近30天', |
||||
|
value: [dayjs().subtract(30, 'day'), dayjs()], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const columns = [ |
||||
|
{ |
||||
|
title: '来电号码', |
||||
|
dataIndex: 'callerNumber', |
||||
|
width: 150, |
||||
|
}, |
||||
|
{ |
||||
|
title: '来电姓名', |
||||
|
dataIndex: 'callerName', |
||||
|
width: 120, |
||||
|
}, |
||||
|
{ |
||||
|
title: '通话方向', |
||||
|
dataIndex: 'callDirection', |
||||
|
width: 100, |
||||
|
}, |
||||
|
{ |
||||
|
title: '接通结果', |
||||
|
dataIndex: 'callStatus', |
||||
|
width: 100, |
||||
|
}, |
||||
|
{ |
||||
|
title: '通话时长', |
||||
|
dataIndex: 'callDurationText', |
||||
|
width: 100, |
||||
|
}, |
||||
|
{ |
||||
|
title: '来电时间', |
||||
|
dataIndex: 'callStartTime', |
||||
|
width: 180, |
||||
|
}, |
||||
|
{ |
||||
|
title: '录音', |
||||
|
dataIndex: 'hasRecording', |
||||
|
width: 80, |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const loadData = async () => { |
||||
|
tableLoading.value = true; |
||||
|
try { |
||||
|
const params = { |
||||
|
pageNum: pagination.current, |
||||
|
pageSize: pagination.pageSize, |
||||
|
callerNumber: queryForm.callerNumber || undefined, |
||||
|
callerName: queryForm.callerName || undefined, |
||||
|
callStatus: queryForm.callStatus || undefined, |
||||
|
startTime: queryForm.dateRange && queryForm.dateRange[0] ? queryForm.dateRange[0].format('YYYY-MM-DD HH:mm:ss') : undefined, |
||||
|
endTime: queryForm.dateRange && queryForm.dateRange[1] ? queryForm.dateRange[1].format('YYYY-MM-DD HH:mm:ss') : undefined, |
||||
|
}; |
||||
|
|
||||
|
const res = await phoneApi.queryPhoneRecordPage(params); |
||||
|
if (res.ok) { |
||||
|
tableData.value = res.data.list || []; |
||||
|
pagination.total = res.data.total || 0; |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('加载数据失败:', error); |
||||
|
message.error('加载数据失败'); |
||||
|
} finally { |
||||
|
tableLoading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleQuery = () => { |
||||
|
pagination.current = 1; |
||||
|
loadData(); |
||||
|
}; |
||||
|
|
||||
|
const handleReset = () => { |
||||
|
queryForm.callerNumber = ''; |
||||
|
queryForm.callerName = ''; |
||||
|
queryForm.callStatus = ''; |
||||
|
queryForm.dateRange = []; |
||||
|
pagination.current = 1; |
||||
|
loadData(); |
||||
|
}; |
||||
|
|
||||
|
const handleTableChange = (pag) => { |
||||
|
pagination.current = pag.current; |
||||
|
pagination.pageSize = pag.pageSize; |
||||
|
loadData(); |
||||
|
}; |
||||
|
|
||||
|
const handleSimulateCall = async () => { |
||||
|
const agentId = '1'; |
||||
|
const callerNumber = '138' + Math.floor(Math.random() * 100000000).toString().padStart(8, '0'); |
||||
|
const callerName = '测试用户' + Math.floor(Math.random() * 100); |
||||
|
|
||||
|
try { |
||||
|
const res = await phoneApi.simulateIncomingCall(callerNumber, callerName, agentId); |
||||
|
if (res.ok) { |
||||
|
message.success('模拟来电已发送,请等待弹屏'); |
||||
|
loadData(); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('模拟来电失败:', error); |
||||
|
message.error('模拟来电失败'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleIncomingCall = (data) => { |
||||
|
incomingCallData.value = data; |
||||
|
incomingCallModalVisible.value = true; |
||||
|
message.info(`新来电:${data.callerNumber} - ${data.callerName || '未知'}`); |
||||
|
}; |
||||
|
|
||||
|
const handleAnswerCall = () => { |
||||
|
message.success('已接听来电'); |
||||
|
incomingCallModalVisible.value = false; |
||||
|
loadData(); |
||||
|
}; |
||||
|
|
||||
|
const handleRejectCall = () => { |
||||
|
message.info('已挂断来电'); |
||||
|
incomingCallModalVisible.value = false; |
||||
|
loadData(); |
||||
|
}; |
||||
|
|
||||
|
// 检查是否有指定权限 |
||||
|
function hasPermission(permission) { |
||||
|
// 超级管理员 |
||||
|
if (userStore.administratorFlag) { |
||||
|
return true; |
||||
|
} |
||||
|
// 获取功能点权限 |
||||
|
const userPointsList = userStore.getPointList; |
||||
|
if (!userPointsList) { |
||||
|
return false; |
||||
|
} |
||||
|
return some(userPointsList, ['webPerms', permission]); |
||||
|
} |
||||
|
|
||||
|
const getStatusColor = (status) => { |
||||
|
const colorMap = { |
||||
|
ANSWERED: 'green', |
||||
|
MISSED: 'red', |
||||
|
BUSY: 'orange', |
||||
|
REJECTED: 'default', |
||||
|
}; |
||||
|
return colorMap[status] || 'default'; |
||||
|
}; |
||||
|
|
||||
|
const formatDateTime = (dateTime) => { |
||||
|
if (!dateTime) return '-'; |
||||
|
return dayjs(dateTime).format('YYYY-MM-DD HH:mm:ss'); |
||||
|
}; |
||||
|
|
||||
|
onMounted(() => { |
||||
|
loadData(); |
||||
|
phoneWebSocket.connect('1'); |
||||
|
phoneWebSocket.on('incomingCall', handleIncomingCall); |
||||
|
}); |
||||
|
|
||||
|
onUnmounted(() => { |
||||
|
phoneWebSocket.off('incomingCall', handleIncomingCall); |
||||
|
}); |
||||
</script> |
</script> |
||||
|
|
||||
<style scoped> |
<style scoped> |
||||
|
.page-container { |
||||
|
padding: 16px; |
||||
|
background: #f0f2f5; |
||||
|
min-height: 100vh; |
||||
|
} |
||||
|
|
||||
|
.glass-card { |
||||
|
background: #fff; |
||||
|
border-radius: 8px; |
||||
|
padding: 24px; |
||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
|
} |
||||
|
|
||||
|
.header { |
||||
|
margin-bottom: 24px; |
||||
|
padding-bottom: 16px; |
||||
|
border-bottom: 1px solid #f0f0f0; |
||||
|
} |
||||
|
|
||||
.icon-wrapper { |
.icon-wrapper { |
||||
|
width: 60px; |
||||
|
height: 60px; |
||||
|
border-radius: 12px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
background: linear-gradient(135deg, #13c2c2 0%, #87e8de 100%); |
background: linear-gradient(135deg, #13c2c2 0%, #87e8de 100%); |
||||
} |
} |
||||
|
|
||||
|
.icon { |
||||
|
font-size: 32px; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.title { |
||||
|
margin: 0; |
||||
|
font-size: 24px; |
||||
|
font-weight: 600; |
||||
|
color: #262626; |
||||
|
} |
||||
|
|
||||
|
.subtitle { |
||||
|
margin: 4px 0 0; |
||||
|
font-size: 14px; |
||||
|
color: #8c8c8c; |
||||
|
} |
||||
|
|
||||
|
.content-box { |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.search-box { |
||||
|
margin-bottom: 16px; |
||||
|
padding: 16px; |
||||
|
background: #fafafa; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.table-box { |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.incoming-call-modal :deep(.ant-modal-content) { |
||||
|
border-radius: 12px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.incoming-call-content { |
||||
|
text-align: center; |
||||
|
padding: 20px 0; |
||||
|
} |
||||
|
|
||||
|
.call-icon { |
||||
|
margin-bottom: 24px; |
||||
|
} |
||||
|
|
||||
|
.ringing-icon { |
||||
|
font-size: 64px; |
||||
|
color: #52c41a; |
||||
|
animation: ring 1.5s ease-in-out infinite; |
||||
|
} |
||||
|
|
||||
|
@keyframes ring { |
||||
|
0%, 100% { |
||||
|
transform: rotate(0deg); |
||||
|
} |
||||
|
10%, 30% { |
||||
|
transform: rotate(-15deg); |
||||
|
} |
||||
|
20%, 40% { |
||||
|
transform: rotate(15deg); |
||||
|
} |
||||
|
50% { |
||||
|
transform: rotate(0deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.call-info h3 { |
||||
|
margin: 0 0 16px; |
||||
|
font-size: 20px; |
||||
|
color: #262626; |
||||
|
} |
||||
|
|
||||
|
.info-item { |
||||
|
margin-bottom: 12px; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
|
||||
|
.info-item .label { |
||||
|
color: #8c8c8c; |
||||
|
margin-right: 8px; |
||||
|
} |
||||
|
|
||||
|
.info-item .value { |
||||
|
color: #262626; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.call-actions { |
||||
|
margin-top: 32px; |
||||
|
display: flex; |
||||
|
gap: 16px; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.rotate-135 { |
||||
|
transform: rotate(135deg); |
||||
|
} |
||||
</style> |
</style> |
||||
Loading…
Reference in new issue