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> |
|||
<div class="page-container"> |
|||
<div class="glass-card"> |
|||
<div class="header"> |
|||
<a-space size="middle"> |
|||
<div class="icon-wrapper"> |
|||
<CustomerServiceOutlined class="icon" /> |
|||
<div class="content-box"> |
|||
<div class="search-box"> |
|||
<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> |
|||
<h2 class="title">热线客服工作台</h2> |
|||
<p class="subtitle">话务坐席专用,集成来电弹屏、电话接听、通话历史及现场快速登记工单。</p> |
|||
</div> |
|||
</a-space> |
|||
</div> |
|||
|
|||
<div class="content-box"> |
|||
<div class="table-placeholder"> |
|||
<a-empty description="坐席客服工作台加载中,软电话条及来电弹屏模块开发中" /> |
|||
<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> |
|||
</template> |
|||
|
|||
<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> |
|||
|
|||
<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 { |
|||
width: 60px; |
|||
height: 60px; |
|||
border-radius: 12px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
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> |
|||
Loading…
Reference in new issue