Browse Source

热线客服工作台功能提交

main
18226920803 1 month ago
parent
commit
607ff6aa41
  1. 55
      docs/sql/业务脚本.sql
  2. 8
      smart-flow-api/pom.xml
  3. 41
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/controller/PhoneController.java
  4. 17
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/dao/PhoneRecordDao.java
  5. 76
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/entity/PhoneRecordEntity.java
  6. 48
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/form/PhoneRecordQueryForm.java
  7. 74
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/vo/PhoneRecordVO.java
  8. 141
      smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/service/PhoneService.java
  9. 8
      smart-flow-api/sa-base/pom.xml
  10. 11
      smart-flow-web/src/api/business/phone/phone-api.js
  11. 151
      smart-flow-web/src/utils/phone-websocket.js
  12. 463
      smart-flow-web/src/views/jwpy/myrx/desk/index.vue

55
docs/sql/业务脚本.sql

@ -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;

8
smart-flow-api/pom.xml

@ -123,6 +123,12 @@
<version>${p6spy.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>${springboot.version}</version>
</dependency>
<dependency>
<groupId>cn.com.kingbase</groupId>
<artifactId>kingbase8</artifactId>
@ -472,4 +478,4 @@
</profile>
</profiles>
</project>
</project>

41
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/controller/PhoneController.java

@ -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);
}
}

17
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/dao/PhoneRecordDao.java

@ -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> {
}

76
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/entity/PhoneRecordEntity.java

@ -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;
}

48
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/form/PhoneRecordQueryForm.java

@ -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;
}

74
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/domain/vo/PhoneRecordVO.java

@ -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;
}

141
smart-flow-api/sa-admin/src/main/java/net/lab1024/sa/admin/module/business/phone/service/PhoneService.java

@ -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();
}
}

8
smart-flow-api/sa-base/pom.xml

@ -308,7 +308,13 @@
<artifactId>tika-core</artifactId>
</dependency>
<!-- WebSocket 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
</project>
</project>

11
smart-flow-web/src/api/business/phone/phone-api.js

@ -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}`);
},
};

151
smart-flow-web/src/utils/phone-websocket.js

@ -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();

463
smart-flow-web/src/views/jwpy/myrx/desk/index.vue

@ -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>
<div>
<h2 class="title">热线客服工作台</h2>
<p class="subtitle">话务坐席专用集成来电弹屏电话接听通话历史及现场快速登记工单</p>
</div>
</a-space>
</div>
<div class="content-box">
<div class="table-placeholder">
<a-empty description="坐席客服工作台加载中,软电话条及来电弹屏模块开发中" />
<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>
</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>
</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…
Cancel
Save