temp jitsi
This commit is contained in:
14
urbanLifelineServ/.vscode/launch.json
vendored
14
urbanLifelineServ/.vscode/launch.json
vendored
@@ -1,6 +1,20 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "java",
|
||||||
|
"name": "URLQRCodeParseTest",
|
||||||
|
"request": "launch",
|
||||||
|
"mainClass": "org.xyzh.workcase.test.URLQRCodeParseTest",
|
||||||
|
"projectName": "workcase"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "java",
|
||||||
|
"name": "QRCodeTest",
|
||||||
|
"request": "launch",
|
||||||
|
"mainClass": "org.xyzh.workcase.test.QRCodeTest",
|
||||||
|
"projectName": "workcase"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "java",
|
"type": "java",
|
||||||
"name": "AesEncryptUtil",
|
"name": "AesEncryptUtil",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.xyzh.api.workcase.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Jitsi Meet JWT Token服务接口
|
||||||
|
* @filename JitsiTokenService.java
|
||||||
|
* @author claude
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
public interface JitsiTokenService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 生成Jitsi Meet JWT Token
|
||||||
|
* @param roomName Jitsi房间名
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param userName 用户名称
|
||||||
|
* @param isModerator 是否为主持人
|
||||||
|
* @return JWT Token字符串
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
String generateJwtToken(String roomName, String userId, String userName, boolean isModerator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 验证JWT Token是否有效
|
||||||
|
* @param token JWT Token
|
||||||
|
* @return boolean
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
boolean validateJwtToken(String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 构建Jitsi Meet iframe URL
|
||||||
|
* @param roomName Jitsi房间名
|
||||||
|
* @param jwtToken JWT Token
|
||||||
|
* @param config Jitsi配置项(可选)
|
||||||
|
* @return iframe URL字符串
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
String buildIframeUrl(String roomName, String jwtToken, JSONObject config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 生成唯一的Jitsi房间名
|
||||||
|
* @param workcaseId 工单ID
|
||||||
|
* @return 房间名字符串
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
String generateRoomName(String workcaseId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package org.xyzh.api.workcase.service;
|
||||||
|
|
||||||
|
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||||
|
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||||
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 视频会议服务接口,管理Jitsi Meet视频会议
|
||||||
|
* @filename VideoMeetingService.java
|
||||||
|
* @author claude
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
public interface VideoMeetingService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 创建视频会议
|
||||||
|
* @param meetingDTO 会议信息
|
||||||
|
* @param userId 创建者用户ID
|
||||||
|
* @return ResultDomain<VideoMeetingVO>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> createMeeting(TbVideoMeetingDTO meetingDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取会议信息
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @param userId 请求用户ID(用于权限验证)
|
||||||
|
* @return ResultDomain<VideoMeetingVO>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> getMeetingInfo(String meetingId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 验证用户是否有权访问会议
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return ResultDomain<Boolean>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<Boolean> validateMeetingAccess(String meetingId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 生成用户专属的会议访问URL(包含用户专属JWT Token)
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return ResultDomain<VideoMeetingVO>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> generateUserMeetingUrl(String meetingId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 开始会议(更新状态为ongoing)
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @return ResultDomain<Boolean>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<Boolean> startMeeting(String meetingId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 结束会议(更新状态为ended)
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @return ResultDomain<VideoMeetingVO>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> endMeeting(String meetingId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取聊天室当前活跃的会议
|
||||||
|
* @param roomId 聊天室ID
|
||||||
|
* @return ResultDomain<VideoMeetingVO>
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> getActiveMeetingByRoom(String roomId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 检查用户是否为聊天室成员(内部方法)
|
||||||
|
* @param roomId 聊天室ID
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return boolean
|
||||||
|
* @author claude
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
boolean isMemberOfRoom(String roomId, String userId);
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
<groupId>org.xyzh.apis</groupId>
|
<groupId>org.xyzh.apis</groupId>
|
||||||
<artifactId>api-auth</artifactId>
|
<artifactId>api-auth</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xyzh.apis</groupId>
|
||||||
|
<artifactId>api-file</artifactId>
|
||||||
|
</dependency>
|
||||||
<!-- <dependency>
|
<!-- <dependency>
|
||||||
<groupId>org.xyzh.apis</groupId>
|
<groupId>org.xyzh.apis</groupId>
|
||||||
<artifactId>api-ai</artifactId>
|
<artifactId>api-ai</artifactId>
|
||||||
@@ -122,6 +126,31 @@
|
|||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- ZXing 二维码解析 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>core</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.zxing</groupId>
|
||||||
|
<artifactId>javase</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- HTTP Client 用于下载文件 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
|
<artifactId>httpclient5</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jsoup 用于解析 HTML -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.17.2</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -268,6 +268,100 @@ public class WorkcaseChatContorller {
|
|||||||
return chatRoomService.assignCustomerService(roomId);
|
return chatRoomService.assignCustomerService(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================= 视频会议管理(Jitsi Meet) =========================
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private org.xyzh.api.workcase.service.VideoMeetingService videoMeetingService;
|
||||||
|
|
||||||
|
@Operation(summary = "创建视频会议")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@PostMapping("/meeting/create")
|
||||||
|
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> createVideoMeeting(
|
||||||
|
@RequestBody org.xyzh.api.workcase.dto.TbVideoMeetingDTO meetingDTO) {
|
||||||
|
ValidationResult vr = ValidationUtils.validate(meetingDTO, Arrays.asList(
|
||||||
|
ValidationUtils.requiredString("roomId", "聊天室ID")
|
||||||
|
));
|
||||||
|
if (!vr.isValid()) {
|
||||||
|
return ResultDomain.failure(vr.getAllErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return videoMeetingService.createMeeting(meetingDTO);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取会议信息")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@GetMapping("/meeting/{meetingId}")
|
||||||
|
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getMeetingInfo(
|
||||||
|
@PathVariable(value = "meetingId") String meetingId) {
|
||||||
|
String userId = LoginUtil.getCurrentUserId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return videoMeetingService.getMeetingInfo(meetingId, userId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "加入会议(生成用户专属JWT)")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@PostMapping("/meeting/{meetingId}/join")
|
||||||
|
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> joinMeeting(
|
||||||
|
@PathVariable(value = "meetingId") String meetingId) {
|
||||||
|
String userId = LoginUtil.getCurrentUserId();
|
||||||
|
|
||||||
|
// 验证加入权限
|
||||||
|
ResultDomain<Boolean> accessCheck = videoMeetingService.validateMeetingAccess(meetingId, userId);
|
||||||
|
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
|
||||||
|
return ResultDomain.failure("您无权加入此会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成用户专属的iframe URL(包含JWT Token)
|
||||||
|
try {
|
||||||
|
return videoMeetingService.generateUserMeetingUrl(meetingId, userId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "开始会议")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@PostMapping("/meeting/{meetingId}/start")
|
||||||
|
public ResultDomain<Boolean> startMeeting(@PathVariable(value = "meetingId") String meetingId) {
|
||||||
|
try {
|
||||||
|
return videoMeetingService.startMeeting(meetingId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "结束会议")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@PostMapping("/meeting/{meetingId}/end")
|
||||||
|
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> endMeeting(
|
||||||
|
@PathVariable(value = "meetingId") String meetingId) {
|
||||||
|
try {
|
||||||
|
return videoMeetingService.endMeeting(meetingId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取聊天室当前活跃会议")
|
||||||
|
@PreAuthorize("hasAuthority('workcase:room:meeting')")
|
||||||
|
@GetMapping("/meeting/room/{roomId}/active")
|
||||||
|
public ResultDomain<org.xyzh.api.workcase.vo.VideoMeetingVO> getActiveMeetingByRoom(
|
||||||
|
@PathVariable(value = "roomId") String roomId) {
|
||||||
|
try {
|
||||||
|
return videoMeetingService.getActiveMeetingByRoom(roomId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================= 微信客服消息回调 =========================
|
// ========================= 微信客服消息回调 =========================
|
||||||
|
|
||||||
// @Operation(summary = "微信客服消息回调验证(GET)")
|
// @Operation(summary = "微信客服消息回调验证(GET)")
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package org.xyzh.workcase.service;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import org.apache.dubbo.config.annotation.DubboService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.xyzh.api.workcase.service.JitsiTokenService;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Jitsi Meet JWT Token服务实现类
|
||||||
|
* @filename JitsiTokenServiceImpl.java
|
||||||
|
* @author claude
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
|
||||||
|
public class JitsiTokenServiceImpl implements JitsiTokenService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JitsiTokenServiceImpl.class);
|
||||||
|
|
||||||
|
@Value("${jitsi.app.id:urbanLifeline}")
|
||||||
|
private String jitsiAppId;
|
||||||
|
|
||||||
|
@Value("${jitsi.app.secret:your-secret-key-change-in-production}")
|
||||||
|
private String jitsiAppSecret;
|
||||||
|
|
||||||
|
@Value("${jitsi.server.url:https://meet.jit.si}")
|
||||||
|
private String jitsiServerUrl;
|
||||||
|
|
||||||
|
@Value("${jitsi.token.expiration:7200000}")
|
||||||
|
private Long tokenExpiration; // 默认2小时
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generateJwtToken(String roomName, String userId, String userName, boolean isModerator) {
|
||||||
|
logger.info("生成Jitsi JWT Token: roomName={}, userId={}, userName={}, isModerator={}",
|
||||||
|
roomName, userId, userName, isModerator);
|
||||||
|
|
||||||
|
try {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long exp = now + tokenExpiration;
|
||||||
|
|
||||||
|
// 构建用户上下文
|
||||||
|
Map<String, Object> userContext = new HashMap<>();
|
||||||
|
userContext.put("id", userId);
|
||||||
|
userContext.put("name", userName);
|
||||||
|
userContext.put("moderator", isModerator);
|
||||||
|
|
||||||
|
// 构建JWT claims
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("context", Map.of("user", userContext));
|
||||||
|
claims.put("room", roomName);
|
||||||
|
claims.put("iss", jitsiAppId);
|
||||||
|
claims.put("aud", "jitsi");
|
||||||
|
claims.put("sub", jitsiServerUrl);
|
||||||
|
claims.put("exp", exp / 1000); // 秒级时间戳
|
||||||
|
claims.put("nbf", now / 1000);
|
||||||
|
|
||||||
|
// 生成JWT Token
|
||||||
|
String token = Jwts.builder()
|
||||||
|
.setClaims(claims)
|
||||||
|
.setIssuedAt(new Date(now))
|
||||||
|
.setExpiration(new Date(exp))
|
||||||
|
.signWith(SignatureAlgorithm.HS256, jitsiAppSecret.getBytes())
|
||||||
|
.compact();
|
||||||
|
|
||||||
|
logger.info("JWT Token生成成功: roomName={}", roomName);
|
||||||
|
return token;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("生成JWT Token失败: roomName={}, error={}", roomName, e.getMessage(), e);
|
||||||
|
throw new RuntimeException("生成JWT Token失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean validateJwtToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.setSigningKey(jitsiAppSecret.getBytes())
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
|
||||||
|
// 检查过期时间
|
||||||
|
Date expiration = claims.getExpiration();
|
||||||
|
return expiration.after(new Date());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("JWT Token验证失败: error={}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildIframeUrl(String roomName, String jwtToken, JSONObject config) {
|
||||||
|
logger.info("构建Jitsi iframe URL: roomName={}", roomName);
|
||||||
|
|
||||||
|
StringBuilder url = new StringBuilder();
|
||||||
|
url.append(jitsiServerUrl).append("/").append(roomName);
|
||||||
|
|
||||||
|
// 添加JWT Token
|
||||||
|
url.append("?jwt=").append(jwtToken);
|
||||||
|
|
||||||
|
// 添加默认配置
|
||||||
|
url.append("&config.startWithAudioMuted=false");
|
||||||
|
url.append("&config.startWithVideoMuted=false");
|
||||||
|
url.append("&config.enableWelcomePage=false");
|
||||||
|
url.append("&config.prejoinPageEnabled=false");
|
||||||
|
url.append("&config.disableDeepLinking=true");
|
||||||
|
url.append("&config.enableChat=true");
|
||||||
|
url.append("&config.enableScreenSharing=true");
|
||||||
|
|
||||||
|
// 界面配置
|
||||||
|
url.append("&interfaceConfig.SHOW_JITSI_WATERMARK=false");
|
||||||
|
url.append("&interfaceConfig.SHOW_WATERMARK_FOR_GUESTS=false");
|
||||||
|
url.append("&interfaceConfig.DISABLE_JOIN_LEAVE_NOTIFICATIONS=false");
|
||||||
|
url.append("&interfaceConfig.DEFAULT_BACKGROUND=#474747");
|
||||||
|
|
||||||
|
// 添加自定义配置
|
||||||
|
if (config != null && !config.isEmpty()) {
|
||||||
|
config.forEach((key, value) -> {
|
||||||
|
url.append("&config.").append(key).append("=").append(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String iframeUrl = url.toString();
|
||||||
|
logger.info("iframe URL构建成功: url={}", iframeUrl);
|
||||||
|
return iframeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String generateRoomName(String workcaseId) {
|
||||||
|
// 格式: workcase_{workcaseId}_{timestamp}
|
||||||
|
String roomName = String.format("workcase_%s_%d",
|
||||||
|
workcaseId != null ? workcaseId : "default",
|
||||||
|
System.currentTimeMillis());
|
||||||
|
|
||||||
|
logger.info("生成Jitsi房间名: roomName={}", roomName);
|
||||||
|
return roomName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package org.xyzh.workcase.service;
|
||||||
|
|
||||||
|
import org.apache.dubbo.config.annotation.DubboService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
||||||
|
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||||
|
import org.xyzh.api.workcase.service.JitsiTokenService;
|
||||||
|
import org.xyzh.api.workcase.service.VideoMeetingService;
|
||||||
|
import org.xyzh.api.workcase.vo.ChatMemberVO;
|
||||||
|
import org.xyzh.api.workcase.vo.VideoMeetingVO;
|
||||||
|
import org.xyzh.common.auth.utils.LoginUtil;
|
||||||
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
|
import org.xyzh.common.utils.id.IdUtil;
|
||||||
|
import org.xyzh.workcase.mapper.TbChatRoomMemberMapper;
|
||||||
|
import org.xyzh.workcase.mapper.TbVideoMeetingMapper;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 视频会议服务实现类
|
||||||
|
* @filename VideoMeetingServiceImpl.java
|
||||||
|
* @author claude
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0)
|
||||||
|
public class VideoMeetingServiceImpl implements VideoMeetingService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(VideoMeetingServiceImpl.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TbVideoMeetingMapper videoMeetingMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TbChatRoomMemberMapper chatRoomMemberMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JitsiTokenService jitsiTokenService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResultDomain<VideoMeetingVO> createMeeting(TbVideoMeetingDTO meetingDTO) {
|
||||||
|
// 获取当前用户ID
|
||||||
|
String userId = LoginUtil.getCurrentUserId();
|
||||||
|
logger.info("创建视频会议: roomId={}, workcaseId={}, userId={}",
|
||||||
|
meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证用户是否为聊天室成员
|
||||||
|
if (!isMemberOfRoom(meetingDTO.getRoomId(), userId)) {
|
||||||
|
logger.warn("用户不是聊天室成员,无法创建会议: roomId={}, userId={}",
|
||||||
|
meetingDTO.getRoomId(), userId);
|
||||||
|
return ResultDomain.failure("您不是聊天室成员,无法创建会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查聊天室是否已有进行中的会议
|
||||||
|
TbVideoMeetingDTO existingMeetingFilter = new TbVideoMeetingDTO();
|
||||||
|
existingMeetingFilter.setRoomId(meetingDTO.getRoomId());
|
||||||
|
existingMeetingFilter.setStatus("ongoing");
|
||||||
|
List<VideoMeetingVO> existingMeetings = videoMeetingMapper.selectVideoMeetingList(existingMeetingFilter);
|
||||||
|
|
||||||
|
if (existingMeetings != null && !existingMeetings.isEmpty()) {
|
||||||
|
logger.warn("聊天室已有进行中的会议: roomId={}", meetingDTO.getRoomId());
|
||||||
|
return ResultDomain.failure("聊天室已有进行中的会议,请稍后再试");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成会议ID和房间名
|
||||||
|
String meetingId = IdUtil.generateUUID();
|
||||||
|
String jitsiRoomName = jitsiTokenService.generateRoomName(meetingDTO.getWorkcaseId());
|
||||||
|
|
||||||
|
// 4. 获取用户信息(从聊天室成员表)
|
||||||
|
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
|
||||||
|
memberFilter.setRoomId(meetingDTO.getRoomId());
|
||||||
|
memberFilter.setUserId(userId);
|
||||||
|
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||||
|
|
||||||
|
String userName = "用户";
|
||||||
|
String userType = "guest";
|
||||||
|
if (members != null && !members.isEmpty()) {
|
||||||
|
ChatMemberVO member = members.get(0);
|
||||||
|
userName = member.getUserName();
|
||||||
|
userType = member.getUserType();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 生成创建者的JWT Token(创建者默认为主持人)
|
||||||
|
String jwtToken = jitsiTokenService.generateJwtToken(
|
||||||
|
jitsiRoomName,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
true // 创建者为主持人
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 构建iframe URL
|
||||||
|
String iframeUrl = jitsiTokenService.buildIframeUrl(jitsiRoomName, jwtToken, meetingDTO.getConfig());
|
||||||
|
|
||||||
|
// 7. 填充会议信息
|
||||||
|
meetingDTO.setMeetingId(meetingId);
|
||||||
|
meetingDTO.setJitsiRoomName(jitsiRoomName);
|
||||||
|
meetingDTO.setJwtToken(jwtToken); // 存储创建者的token(可选)
|
||||||
|
meetingDTO.setIframeUrl(iframeUrl);
|
||||||
|
meetingDTO.setStatus("scheduled");
|
||||||
|
meetingDTO.setCreatorId(userId);
|
||||||
|
meetingDTO.setCreatorType(userType);
|
||||||
|
meetingDTO.setCreatorName(userName);
|
||||||
|
meetingDTO.setParticipantCount(0);
|
||||||
|
meetingDTO.setOptsn(IdUtil.getOptsn());
|
||||||
|
|
||||||
|
if (meetingDTO.getMaxParticipants() == null) {
|
||||||
|
meetingDTO.setMaxParticipants(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 插入数据库
|
||||||
|
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
|
||||||
|
if (rows > 0) {
|
||||||
|
logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}",
|
||||||
|
meetingId, jitsiRoomName);
|
||||||
|
|
||||||
|
// 9. 返回VO
|
||||||
|
VideoMeetingVO meetingVO = new VideoMeetingVO();
|
||||||
|
BeanUtils.copyProperties(meetingDTO, meetingVO);
|
||||||
|
return ResultDomain.success("创建会议成功", meetingVO);
|
||||||
|
} else {
|
||||||
|
logger.error("插入会议记录失败: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("创建会议失败");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("创建视频会议异常: roomId={}, error={}",
|
||||||
|
meetingDTO.getRoomId(), e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("创建会议失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<VideoMeetingVO> getMeetingInfo(String meetingId, String userId) {
|
||||||
|
logger.info("获取会议信息: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setMeetingId(meetingId);
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
|
||||||
|
// 验证访问权限
|
||||||
|
ResultDomain<Boolean> accessCheck = validateMeetingAccess(meetingId, userId);
|
||||||
|
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
|
||||||
|
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
return ResultDomain.failure("您无权访问此会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("获取会议信息成功: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.success("获取会议信息成功", meeting);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取会议信息异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("获取会议信息失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Boolean> validateMeetingAccess(String meetingId, String userId) {
|
||||||
|
logger.info("验证会议访问权限: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取会议信息
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setMeetingId(meetingId);
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.success("会议不存在", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
|
||||||
|
// 2. 检查用户是否为聊天室成员
|
||||||
|
boolean isMember = isMemberOfRoom(meeting.getRoomId(), userId);
|
||||||
|
|
||||||
|
logger.info("会议访问权限验证结果: meetingId={}, userId={}, hasAccess={}",
|
||||||
|
meetingId, userId, isMember);
|
||||||
|
return ResultDomain.success("会议访问权限验证成功", isMember);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("验证会议访问权限异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("验证访问权限失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<VideoMeetingVO> generateUserMeetingUrl(String meetingId, String userId) {
|
||||||
|
logger.info("生成用户专属会议URL: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取会议信息
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setMeetingId(meetingId);
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
|
||||||
|
// 2. 验证访问权限
|
||||||
|
ResultDomain<Boolean> accessCheck = validateMeetingAccess(meetingId, userId);
|
||||||
|
if (!accessCheck.getSuccess() || !accessCheck.getData()) {
|
||||||
|
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
return ResultDomain.failure("您无权访问此会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取用户信息
|
||||||
|
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
|
||||||
|
memberFilter.setRoomId(meeting.getRoomId());
|
||||||
|
memberFilter.setUserId(userId);
|
||||||
|
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||||
|
|
||||||
|
String userName = "用户";
|
||||||
|
boolean isModerator = false;
|
||||||
|
|
||||||
|
if (members != null && !members.isEmpty()) {
|
||||||
|
ChatMemberVO member = members.get(0);
|
||||||
|
userName = member.getUserName();
|
||||||
|
// 客服人员设为主持人
|
||||||
|
isModerator = "agent".equals(member.getUserType());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 生成用户专属JWT Token
|
||||||
|
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||||
|
meeting.getJitsiRoomName(),
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
isModerator
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 构建用户专属iframe URL
|
||||||
|
String userIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||||
|
meeting.getJitsiRoomName(),
|
||||||
|
userJwtToken,
|
||||||
|
meeting.getConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 更新VO
|
||||||
|
meeting.setJwtToken(userJwtToken);
|
||||||
|
meeting.setIframeUrl(userIframeUrl);
|
||||||
|
|
||||||
|
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
return ResultDomain.success("生成用户专属会议URL成功", meeting);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("生成用户专属会议URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("生成会议URL失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResultDomain<Boolean> startMeeting(String meetingId) {
|
||||||
|
logger.info("开始会议: meetingId={}", meetingId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
|
||||||
|
updateDTO.setMeetingId(meetingId);
|
||||||
|
updateDTO.setStatus("ongoing");
|
||||||
|
updateDTO.setActualStartTime(new Date());
|
||||||
|
|
||||||
|
int rows = videoMeetingMapper.updateVideoMeeting(updateDTO);
|
||||||
|
if (rows > 0) {
|
||||||
|
logger.info("会议开始成功: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.success("会议开始成功", true);
|
||||||
|
} else {
|
||||||
|
logger.warn("会议开始失败(可能不存在): meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议不存在或已开始");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("开始会议异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("开始会议失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResultDomain<VideoMeetingVO> endMeeting(String meetingId) {
|
||||||
|
logger.info("结束会议: meetingId={}", meetingId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取会议信息
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setMeetingId(meetingId);
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
|
||||||
|
// 2. 计算会议时长
|
||||||
|
Integer durationSeconds = null;
|
||||||
|
if (meeting.getActualStartTime() != null) {
|
||||||
|
long duration = (System.currentTimeMillis() - meeting.getActualStartTime().getTime()) / 1000;
|
||||||
|
durationSeconds = (int) duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新会议状态
|
||||||
|
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
|
||||||
|
updateDTO.setMeetingId(meetingId);
|
||||||
|
updateDTO.setStatus("ended");
|
||||||
|
updateDTO.setActualEndTime(new Date());
|
||||||
|
updateDTO.setDurationSeconds(durationSeconds);
|
||||||
|
|
||||||
|
int rows = videoMeetingMapper.updateVideoMeeting(updateDTO);
|
||||||
|
if (rows > 0) {
|
||||||
|
// 4. 更新VO
|
||||||
|
meeting.setStatus("ended");
|
||||||
|
meeting.setActualEndTime(new Date());
|
||||||
|
meeting.setDurationSeconds(durationSeconds);
|
||||||
|
|
||||||
|
logger.info("会议结束成功: meetingId={}, duration={}秒", meetingId, durationSeconds);
|
||||||
|
return ResultDomain.success("会议结束成功", meeting);
|
||||||
|
} else {
|
||||||
|
logger.warn("会议结束失败: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("结束会议失败");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("结束会议异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("结束会议失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<VideoMeetingVO> getActiveMeetingByRoom(String roomId) {
|
||||||
|
logger.info("获取聊天室活跃会议: roomId={}", roomId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setRoomId(roomId);
|
||||||
|
filter.setStatus("ongoing");
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.info("聊天室无活跃会议: roomId={}", roomId);
|
||||||
|
return ResultDomain.failure("无活跃会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
logger.info("找到活跃会议: roomId={}, meetingId={}", roomId, meeting.getMeetingId());
|
||||||
|
return ResultDomain.success("找到活跃会议", meeting);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取活跃会议异常: roomId={}, error={}", roomId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("获取活跃会议失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMemberOfRoom(String roomId, String userId) {
|
||||||
|
logger.debug("检查用户是否为聊天室成员: roomId={}, userId={}", roomId, userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO();
|
||||||
|
filter.setRoomId(roomId);
|
||||||
|
filter.setUserId(userId);
|
||||||
|
filter.setStatus("active");
|
||||||
|
|
||||||
|
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(filter);
|
||||||
|
boolean isMember = members != null && !members.isEmpty();
|
||||||
|
|
||||||
|
logger.debug("用户成员检查结果: roomId={}, userId={}, isMember={}",
|
||||||
|
roomId, userId, isMember);
|
||||||
|
return isMember;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("检查用户成员身份异常: roomId={}, userId={}, error={}",
|
||||||
|
roomId, userId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
package org.xyzh.workcase.service;
|
package org.xyzh.workcase.service;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
import org.apache.dubbo.config.annotation.DubboReference;
|
||||||
import org.apache.dubbo.config.annotation.DubboService;
|
import org.apache.dubbo.config.annotation.DubboService;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpGet;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.xyzh.api.file.dto.TbSysFileDTO;
|
||||||
import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
|
import org.xyzh.api.workcase.dto.TbWorkcaseDTO;
|
||||||
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
|
import org.xyzh.api.workcase.dto.TbWorkcaseDeviceDTO;
|
||||||
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
|
import org.xyzh.api.workcase.dto.TbWorkcaseProcessDTO;
|
||||||
@@ -20,10 +35,23 @@ import org.xyzh.workcase.enums.WorkcaseProcessAction;
|
|||||||
import org.xyzh.workcase.mapper.TbWorkcaseDeviceMapper;
|
import org.xyzh.workcase.mapper.TbWorkcaseDeviceMapper;
|
||||||
import org.xyzh.workcase.mapper.TbWorkcaseMapper;
|
import org.xyzh.workcase.mapper.TbWorkcaseMapper;
|
||||||
import org.xyzh.workcase.mapper.TbWorkcaseProcessMapper;
|
import org.xyzh.workcase.mapper.TbWorkcaseProcessMapper;
|
||||||
|
import org.xyzh.api.file.service.FileService;
|
||||||
import org.xyzh.api.workcase.dto.TbChatRoomDTO;
|
import org.xyzh.api.workcase.dto.TbChatRoomDTO;
|
||||||
import org.xyzh.api.workcase.service.ChatRoomService;
|
import org.xyzh.api.workcase.service.ChatRoomService;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.google.zxing.BinaryBitmap;
|
||||||
|
import com.google.zxing.DecodeHintType;
|
||||||
|
import com.google.zxing.MultiFormatReader;
|
||||||
|
import com.google.zxing.Result;
|
||||||
|
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
|
||||||
|
import com.google.zxing.common.HybridBinarizer;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.jsoup.select.Elements;
|
||||||
|
|
||||||
@DubboService(version = "1.0.0",group = "workcase",timeout = 30000,retries = 0)
|
@DubboService(version = "1.0.0",group = "workcase",timeout = 30000,retries = 0)
|
||||||
public class WorkcaseServiceImpl implements WorkcaseService {
|
public class WorkcaseServiceImpl implements WorkcaseService {
|
||||||
@@ -41,6 +69,9 @@ public class WorkcaseServiceImpl implements WorkcaseService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ChatRoomService chatRoomService;
|
private ChatRoomService chatRoomService;
|
||||||
|
|
||||||
|
@DubboReference(version = "1.0.0",group = "file",timeout = 30000,retries = 0)
|
||||||
|
private FileService fileService;
|
||||||
|
|
||||||
// ====================== 工单管理 ======================
|
// ====================== 工单管理 ======================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -69,6 +100,9 @@ public class WorkcaseServiceImpl implements WorkcaseService {
|
|||||||
// 统一由后端从登录态设置 creator,避免前端传入不可信
|
// 统一由后端从登录态设置 creator,避免前端传入不可信
|
||||||
workcase.setCreator(LoginUtil.getCurrentUserId());
|
workcase.setCreator(LoginUtil.getCurrentUserId());
|
||||||
|
|
||||||
|
// 解析设备铭牌二维码
|
||||||
|
// ResultDomain<TbWorkcaseDeviceDTO> deviceResult = anylizeQrCode(workcase.getDeviceNamePlateImg());
|
||||||
|
|
||||||
int rows = workcaseMapper.insertWorkcase(workcase);
|
int rows = workcaseMapper.insertWorkcase(workcase);
|
||||||
if (rows > 0) {
|
if (rows > 0) {
|
||||||
// 创建工单处理记录
|
// 创建工单处理记录
|
||||||
@@ -89,9 +123,24 @@ public class WorkcaseServiceImpl implements WorkcaseService {
|
|||||||
updateRoom.setWorkcaseId(workcase.getWorkcaseId());
|
updateRoom.setWorkcaseId(workcase.getWorkcaseId());
|
||||||
chatRoomService.updateChatRoom(updateRoom);
|
chatRoomService.updateChatRoom(updateRoom);
|
||||||
|
|
||||||
|
// 插入设备文件记录到数据库
|
||||||
|
// if (deviceResult.getSuccess() && deviceResult.getData() != null) {
|
||||||
|
// List<TbWorkcaseDeviceDTO> deviceList = (List<TbWorkcaseDeviceDTO>) deviceResult.getData();
|
||||||
|
// for (TbWorkcaseDeviceDTO deviceDTO : deviceList) {
|
||||||
|
// deviceDTO.setWorkcaseId(workcase.getWorkcaseId());
|
||||||
|
// try {
|
||||||
|
// workcaseDeviceMapper.insertWorkcaseDevice(deviceDTO);
|
||||||
|
// logger.info("设备文件记录插入成功: workcaseId={}, fileName={}",
|
||||||
|
// workcase.getWorkcaseId(), deviceDTO.getFileName());
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// logger.error("设备文件记录插入失败: " + deviceDTO.getFileName(), e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
syncWorkcaseToCrm(workcase);
|
syncWorkcaseToCrm(workcase);
|
||||||
return ResultDomain.success("创建成功", workcase);
|
return ResultDomain.success("创建成功", workcase);
|
||||||
|
// return ResultDomain.success(deviceResult.getSuccess() ? "创建成功" : "设备铭牌二维码解析失败", workcase);
|
||||||
}
|
}
|
||||||
return ResultDomain.failure("创建失败");
|
return ResultDomain.failure("创建失败");
|
||||||
}
|
}
|
||||||
@@ -445,4 +494,349 @@ public class WorkcaseServiceImpl implements WorkcaseService {
|
|||||||
return ResultDomain.success("查询成功", pageDomain);
|
return ResultDomain.success("查询成功", pageDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 扫描设备铭牌二维码,并下载其中的文件,构建WorkDeviceDTO列表
|
||||||
|
* @param qrcodeFileId 二维码文件id
|
||||||
|
* @return WorkDeviceDTO列表
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-12-25
|
||||||
|
*/
|
||||||
|
private ResultDomain<TbWorkcaseDeviceDTO> anylizeQrCode(String qrcodeFileId){
|
||||||
|
List<TbWorkcaseDeviceDTO> workDeviceList = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("开始解析设备铭牌二维码: qrcodeFileId={}", qrcodeFileId);
|
||||||
|
|
||||||
|
// 1. 从 FileService 获取二维码图片
|
||||||
|
ResultDomain<byte[]> downloadResult = fileService.downloadFile(qrcodeFileId);
|
||||||
|
if (!downloadResult.getSuccess()) {
|
||||||
|
logger.error("下载二维码图片失败: {}", downloadResult.getMessage());
|
||||||
|
return ResultDomain.failure("下载二维码图片失败: " + downloadResult.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] qrcodeImageBytes = downloadResult.getData();
|
||||||
|
logger.info("二维码图片下载成功,大小: {} bytes", qrcodeImageBytes.length);
|
||||||
|
|
||||||
|
// 2. 解析二维码内容
|
||||||
|
String qrcodeContent = decodeQRCode(qrcodeImageBytes);
|
||||||
|
if (qrcodeContent == null || qrcodeContent.isEmpty()) {
|
||||||
|
logger.error("二维码解析失败或内容为空");
|
||||||
|
return ResultDomain.failure("二维码解析失败或内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("二维码解析成功,内容: {}", qrcodeContent);
|
||||||
|
|
||||||
|
// 3. 判断二维码内容类型并解析
|
||||||
|
String deviceCode = null;
|
||||||
|
String device = null;
|
||||||
|
JSONArray files = null;
|
||||||
|
|
||||||
|
// 尝试判断是 URL 还是 JSON
|
||||||
|
if (qrcodeContent.startsWith("http://") || qrcodeContent.startsWith("https://")) {
|
||||||
|
// 二维码包含 URL,需要访问 H5 页面解析
|
||||||
|
logger.info("检测到二维码包含URL,开始访问页面: {}", qrcodeContent);
|
||||||
|
JSONObject pageData = parseDevicePageFromUrl(qrcodeContent);
|
||||||
|
|
||||||
|
if (pageData == null) {
|
||||||
|
logger.error("访问URL并解析页面失败");
|
||||||
|
return ResultDomain.failure("访问设备信息页面失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceCode = pageData.getString("deviceCode");
|
||||||
|
device = pageData.getString("device");
|
||||||
|
files = pageData.getJSONArray("files");
|
||||||
|
} else {
|
||||||
|
// 二维码直接包含 JSON 数据
|
||||||
|
logger.info("检测到二维码包含JSON数据,直接解析");
|
||||||
|
JSONObject qrcodeData = JSON.parseObject(qrcodeContent);
|
||||||
|
deviceCode = qrcodeData.getString("deviceCode");
|
||||||
|
device = qrcodeData.getString("device");
|
||||||
|
files = qrcodeData.getJSONArray("files");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
logger.warn("二维码中没有文件信息");
|
||||||
|
return ResultDomain.success("二维码中没有文件信息", workDeviceList);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("设备编号: {}, 设备名称: {}, 文件数量: {}", deviceCode, device, files.size());
|
||||||
|
|
||||||
|
// 4. 下载并存储每个文件
|
||||||
|
for (int i = 0; i < files.size(); i++) {
|
||||||
|
JSONObject fileInfo = files.getJSONObject(i);
|
||||||
|
String fileName = fileInfo.getString("fileName");
|
||||||
|
String fileUrl = fileInfo.getString("url");
|
||||||
|
|
||||||
|
logger.info("开始下载文件 [{}/{}]: {}", i + 1, files.size(), fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载文件
|
||||||
|
byte[] fileBytes = downloadFileFromUrl(fileUrl);
|
||||||
|
if (fileBytes == null || fileBytes.length == 0) {
|
||||||
|
logger.error("文件下载失败: {}", fileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("文件下载成功: {}, 大小: {} bytes", fileName, fileBytes.length);
|
||||||
|
|
||||||
|
// 推断文件类型
|
||||||
|
String contentType = getContentTypeFromFileName(fileName);
|
||||||
|
|
||||||
|
// 通过 FileService 存储文件
|
||||||
|
ResultDomain<TbSysFileDTO> uploadResult = fileService.uploadFileBytes(
|
||||||
|
fileBytes,
|
||||||
|
fileName,
|
||||||
|
contentType,
|
||||||
|
"workcase",
|
||||||
|
deviceCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResult.getSuccess()) {
|
||||||
|
TbSysFileDTO uploadedFile = uploadResult.getData();
|
||||||
|
logger.info("文件上传成功: fileId={}, fileName={}", uploadedFile.getFileId(), fileName);
|
||||||
|
|
||||||
|
// 创建 TbWorkcaseDeviceDTO 对象
|
||||||
|
TbWorkcaseDeviceDTO deviceDTO = new TbWorkcaseDeviceDTO();
|
||||||
|
deviceDTO.setDevice(device);
|
||||||
|
deviceDTO.setDeviceCode(deviceCode);
|
||||||
|
deviceDTO.setFileId(uploadedFile.getFileId());
|
||||||
|
deviceDTO.setFileName(fileName);
|
||||||
|
deviceDTO.setFileRootId(uploadedFile.getFileRootId());
|
||||||
|
deviceDTO.setOptsn(IdUtil.getOptsn());
|
||||||
|
|
||||||
|
workDeviceList.add(deviceDTO);
|
||||||
|
} else {
|
||||||
|
logger.error("文件上传失败: {}, 原因: {}", fileName, uploadResult.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("处理文件失败: " + fileName, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("二维码解析完成,成功处理 {} 个文件", workDeviceList.size());
|
||||||
|
return ResultDomain.success("解析成功,处理了 " + workDeviceList.size() + " 个文件", workDeviceList);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("解析设备铭牌二维码失败", e);
|
||||||
|
return ResultDomain.failure("解析设备铭牌二维码失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 解析设备页面信息
|
||||||
|
*/
|
||||||
|
private JSONObject parseDevicePageFromUrl(String url) {
|
||||||
|
try {
|
||||||
|
logger.info("开始访问设备信息页面: {}", url);
|
||||||
|
|
||||||
|
// 访问 URL 并获取 HTML 内容
|
||||||
|
Document doc = Jsoup.connect(url)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.timeout(10000)
|
||||||
|
.followRedirects(true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
logger.info("页面访问成功,开始解析内容");
|
||||||
|
|
||||||
|
// 解析设备编号和型号(从页面标题或特定元素中)
|
||||||
|
String deviceInfo = doc.select("body").text();
|
||||||
|
|
||||||
|
// 尝试匹配设备编号模式:数字格式如 202508012
|
||||||
|
String deviceCode = null;
|
||||||
|
String device = null;
|
||||||
|
|
||||||
|
// 从页面内容中提取设备编号和型号
|
||||||
|
if (deviceInfo.contains("(") && deviceInfo.contains(")")) {
|
||||||
|
// 例如: "202508012 (THHM1800PL)"
|
||||||
|
int start = deviceInfo.indexOf("(");
|
||||||
|
int end = deviceInfo.indexOf(")", start);
|
||||||
|
if (start > 0 && end > start) {
|
||||||
|
// 提取括号前的设备编号
|
||||||
|
String beforeBracket = deviceInfo.substring(Math.max(0, start - 20), start).trim();
|
||||||
|
String[] parts = beforeBracket.split("\\s+");
|
||||||
|
if (parts.length > 0) {
|
||||||
|
deviceCode = parts[parts.length - 1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取括号内的型号
|
||||||
|
device = deviceInfo.substring(start + 1, end).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("提取的设备信息 - 编号: {}, 型号: {}", deviceCode, device);
|
||||||
|
|
||||||
|
// 解析文件列表
|
||||||
|
JSONArray files = new JSONArray();
|
||||||
|
|
||||||
|
// 查找所有下载链接(通常是 PDF、图片等文件)
|
||||||
|
Elements links = doc.select("a[href]");
|
||||||
|
for (Element link : links) {
|
||||||
|
String href = link.attr("abs:href"); // 获取绝对 URL
|
||||||
|
String text = link.text().trim();
|
||||||
|
|
||||||
|
// 过滤出文件下载链接(包含文件扩展名)
|
||||||
|
if (isFileLink(href) && !text.isEmpty()) {
|
||||||
|
JSONObject fileInfo = new JSONObject();
|
||||||
|
fileInfo.put("fileName", text);
|
||||||
|
fileInfo.put("url", href);
|
||||||
|
files.add(fileInfo);
|
||||||
|
logger.info("发现文件: {} -> {}", text, href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到链接,尝试从 script 标签中查找 JSON 数据
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
Elements scripts = doc.select("script");
|
||||||
|
for (Element script : scripts) {
|
||||||
|
String scriptContent = script.html();
|
||||||
|
// 尝试查找 JSON 数据
|
||||||
|
if (scriptContent.contains("files") || scriptContent.contains("fileName")) {
|
||||||
|
try {
|
||||||
|
// 尝试解析嵌入的 JSON
|
||||||
|
int jsonStart = scriptContent.indexOf("{");
|
||||||
|
int jsonEnd = scriptContent.lastIndexOf("}") + 1;
|
||||||
|
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||||
|
String jsonStr = scriptContent.substring(jsonStart, jsonEnd);
|
||||||
|
JSONObject embedded = JSON.parseObject(jsonStr);
|
||||||
|
if (embedded.containsKey("files")) {
|
||||||
|
files = embedded.getJSONArray("files");
|
||||||
|
if (deviceCode == null) {
|
||||||
|
deviceCode = embedded.getString("deviceCode");
|
||||||
|
}
|
||||||
|
if (device == null) {
|
||||||
|
device = embedded.getString("device");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 忽略解析错误,继续下一个 script
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCode == null || files.isEmpty()) {
|
||||||
|
logger.error("无法从页面中提取完整的设备信息");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.put("deviceCode", deviceCode);
|
||||||
|
result.put("device", device != null ? device : "Unknown");
|
||||||
|
result.put("files", files);
|
||||||
|
|
||||||
|
logger.info("页面解析成功,设备编号: {}, 文件数量: {}", deviceCode, files.size());
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("解析设备页面失败: " + url, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为文件下载链接
|
||||||
|
*/
|
||||||
|
private boolean isFileLink(String url) {
|
||||||
|
if (url == null || url.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lowerUrl = url.toLowerCase();
|
||||||
|
return lowerUrl.endsWith(".pdf") ||
|
||||||
|
lowerUrl.endsWith(".jpg") ||
|
||||||
|
lowerUrl.endsWith(".jpeg") ||
|
||||||
|
lowerUrl.endsWith(".png") ||
|
||||||
|
lowerUrl.endsWith(".doc") ||
|
||||||
|
lowerUrl.endsWith(".docx") ||
|
||||||
|
lowerUrl.endsWith(".xls") ||
|
||||||
|
lowerUrl.endsWith(".xlsx") ||
|
||||||
|
lowerUrl.contains("/download") ||
|
||||||
|
lowerUrl.contains("/file/") ||
|
||||||
|
lowerUrl.contains(".pdf?") ||
|
||||||
|
lowerUrl.contains(".jpg?");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码二维码图片
|
||||||
|
*/
|
||||||
|
private String decodeQRCode(byte[] imageBytes) {
|
||||||
|
try {
|
||||||
|
InputStream inputStream = new ByteArrayInputStream(imageBytes);
|
||||||
|
BufferedImage bufferedImage = ImageIO.read(inputStream);
|
||||||
|
|
||||||
|
if (bufferedImage == null) {
|
||||||
|
logger.error("无法读取图片");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
BinaryBitmap binaryBitmap = new BinaryBitmap(
|
||||||
|
new HybridBinarizer(
|
||||||
|
new BufferedImageLuminanceSource(bufferedImage)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
MultiFormatReader reader = new MultiFormatReader();
|
||||||
|
java.util.Map<DecodeHintType, Object> hints = new java.util.HashMap<>();
|
||||||
|
hints.put(DecodeHintType.CHARACTER_SET, "UTF-8");
|
||||||
|
|
||||||
|
Result result = reader.decode(binaryBitmap, hints);
|
||||||
|
return result.getText();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("解码二维码失败", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从URL下载文件
|
||||||
|
*/
|
||||||
|
private byte[] downloadFileFromUrl(String fileUrl) {
|
||||||
|
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
|
||||||
|
HttpGet httpGet = new HttpGet(URI.create(fileUrl));
|
||||||
|
|
||||||
|
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
|
||||||
|
if (response.getCode() == 200) {
|
||||||
|
return EntityUtils.toByteArray(response.getEntity());
|
||||||
|
} else {
|
||||||
|
logger.error("下载文件失败,HTTP状态码: {}", response.getCode());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("下载文件失败: " + fileUrl, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文件名推断 Content-Type
|
||||||
|
*/
|
||||||
|
private String getContentTypeFromFileName(String fileName) {
|
||||||
|
if (fileName == null) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
String lowerFileName = fileName.toLowerCase();
|
||||||
|
if (lowerFileName.endsWith(".pdf")) {
|
||||||
|
return "application/pdf";
|
||||||
|
} else if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
|
||||||
|
return "image/jpeg";
|
||||||
|
} else if (lowerFileName.endsWith(".png")) {
|
||||||
|
return "image/png";
|
||||||
|
} else if (lowerFileName.endsWith(".doc")) {
|
||||||
|
return "application/msword";
|
||||||
|
} else if (lowerFileName.endsWith(".docx")) {
|
||||||
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
} else if (lowerFileName.endsWith(".xls")) {
|
||||||
|
return "application/vnd.ms-excel";
|
||||||
|
} else if (lowerFileName.endsWith(".xlsx")) {
|
||||||
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
} else {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { http } from '@/utils/http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建视频会议参数
|
||||||
|
*/
|
||||||
|
export interface CreateMeetingParams {
|
||||||
|
roomId: string
|
||||||
|
workcaseId?: string
|
||||||
|
meetingName: string
|
||||||
|
maxParticipants?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频会议VO
|
||||||
|
*/
|
||||||
|
export interface VideoMeetingVO {
|
||||||
|
meetingId: string
|
||||||
|
roomId: string
|
||||||
|
workcaseId?: string
|
||||||
|
meetingName: string
|
||||||
|
meetingPassword?: string
|
||||||
|
jwtToken: string
|
||||||
|
jitsiRoomName: string
|
||||||
|
jitsiServerUrl: string
|
||||||
|
status: string
|
||||||
|
creatorId: string
|
||||||
|
creatorType: string
|
||||||
|
creatorName: string
|
||||||
|
participantCount: number
|
||||||
|
maxParticipants: number
|
||||||
|
actualStartTime?: string
|
||||||
|
actualEndTime?: string
|
||||||
|
durationSeconds?: number
|
||||||
|
durationFormatted?: string
|
||||||
|
iframeUrl: string
|
||||||
|
config?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建视频会议
|
||||||
|
*/
|
||||||
|
export const createVideoMeeting = (params: CreateMeetingParams) => {
|
||||||
|
return http.post<VideoMeetingVO>('/workcase/chat/meeting/create', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会议信息
|
||||||
|
*/
|
||||||
|
export const getMeetingInfo = (meetingId: string) => {
|
||||||
|
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天室活跃会议
|
||||||
|
*/
|
||||||
|
export const getActiveMeeting = (roomId: string) => {
|
||||||
|
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/room/${roomId}/active`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入会议(生成用户专属JWT)
|
||||||
|
*/
|
||||||
|
export const joinMeeting = (meetingId: string) => {
|
||||||
|
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/join`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始会议
|
||||||
|
*/
|
||||||
|
export const startVideoMeeting = (meetingId: string) => {
|
||||||
|
return http.post(`/workcase/chat/meeting/${meetingId}/start`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束会议
|
||||||
|
*/
|
||||||
|
export const endVideoMeeting = (meetingId: string) => {
|
||||||
|
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/end`)
|
||||||
|
}
|
||||||
@@ -47,18 +47,59 @@ $brand-color-hover: #004488;
|
|||||||
|
|
||||||
// ==================== Jitsi Meet会议容器 ====================
|
// ==================== Jitsi Meet会议容器 ====================
|
||||||
.meeting-container {
|
.meeting-container {
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
height: 400px;
|
|
||||||
background: #000;
|
|
||||||
border-bottom: 2px solid $brand-color;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 500px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.meeting-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-meeting-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 48px);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮禁用状态
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
|
|
||||||
<!-- Jitsi Meet会议iframe -->
|
<!-- Jitsi Meet会议iframe -->
|
||||||
<div v-if="showMeeting && meetingUrl" class="meeting-container">
|
<div v-if="showMeeting && meetingUrl" class="meeting-container">
|
||||||
<IframeView :src="meetingUrl" />
|
<div class="meeting-header">
|
||||||
|
<span>视频会议进行中</span>
|
||||||
|
<button class="close-meeting-btn" @click="handleEndMeeting">
|
||||||
|
<X :size="20" />
|
||||||
|
结束会议
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<IframeView :src="meetingUrl" class="meeting-iframe" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 聊天消息列表 -->
|
<!-- 聊天消息列表 -->
|
||||||
@@ -72,10 +79,14 @@
|
|||||||
<footer class="input-area">
|
<footer class="input-area">
|
||||||
<!-- 操作按钮区域 -->
|
<!-- 操作按钮区域 -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<!-- 发起会议按钮(始终显示) -->
|
<!-- 发起会议按钮 -->
|
||||||
<button class="action-btn" @click="$emit('start-meeting')">
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
:disabled="meetingLoading || showMeeting"
|
||||||
|
@click="handleStartMeeting"
|
||||||
|
>
|
||||||
<Video :size="18" />
|
<Video :size="18" />
|
||||||
发起会议
|
{{ showMeeting ? '会议进行中' : '发起会议' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 额外的操作按钮插槽 -->
|
<!-- 额外的操作按钮插槽 -->
|
||||||
@@ -125,17 +136,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, onMounted } from 'vue'
|
||||||
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
|
import { FileText, Video, Paperclip, Send, X } from 'lucide-vue-next'
|
||||||
import IframeView from 'shared/components/iframe/IframeView.vue'
|
import IframeView from 'shared/components/iframe/IframeView.vue'
|
||||||
import type { ChatRoomMessageVO } from '@/types/workcase'
|
import type { ChatRoomMessageVO } from '@/types/workcase'
|
||||||
|
import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatRoomMessageVO[]
|
messages: ChatRoomMessageVO[]
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
|
roomId: string
|
||||||
roomName?: string
|
roomName?: string
|
||||||
meetingUrl?: string
|
workcaseId?: string
|
||||||
showMeeting?: boolean
|
|
||||||
fileDownloadUrl?: string
|
fileDownloadUrl?: string
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
loadingMore?: boolean
|
loadingMore?: boolean
|
||||||
@@ -143,7 +155,6 @@ interface Props {
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
roomName: '聊天室',
|
roomName: '聊天室',
|
||||||
showMeeting: false,
|
|
||||||
fileDownloadUrl: '',
|
fileDownloadUrl: '',
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
loadingMore: false
|
loadingMore: false
|
||||||
@@ -153,11 +164,76 @@ const FILE_DOWNLOAD_URL = props.fileDownloadUrl
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'send-message': [content: string, files: File[]]
|
'send-message': [content: string, files: File[]]
|
||||||
'start-meeting': []
|
|
||||||
'download-file': [fileId: string]
|
'download-file': [fileId: string]
|
||||||
'load-more': []
|
'load-more': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 会议相关状态
|
||||||
|
const showMeeting = ref(false)
|
||||||
|
const meetingUrl = ref('')
|
||||||
|
const currentMeetingId = ref('')
|
||||||
|
const meetingLoading = ref(false)
|
||||||
|
|
||||||
|
// 创建并加入会议
|
||||||
|
const handleStartMeeting = async () => {
|
||||||
|
try {
|
||||||
|
meetingLoading.value = true
|
||||||
|
|
||||||
|
// 创建会议
|
||||||
|
const createRes = await createVideoMeeting({
|
||||||
|
roomId: props.roomId,
|
||||||
|
workcaseId: props.workcaseId,
|
||||||
|
meetingName: `工单 ${props.workcaseId || props.roomId} 技术支持`,
|
||||||
|
maxParticipants: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
if (createRes.code === 0 && createRes.data) {
|
||||||
|
currentMeetingId.value = createRes.data.meetingId
|
||||||
|
meetingUrl.value = createRes.data.iframeUrl
|
||||||
|
showMeeting.value = true
|
||||||
|
} else {
|
||||||
|
console.error('创建会议失败:', createRes.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建会议异常:', error)
|
||||||
|
} finally {
|
||||||
|
meetingLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束会议
|
||||||
|
const handleEndMeeting = async () => {
|
||||||
|
if (!currentMeetingId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await endVideoMeeting(currentMeetingId.value)
|
||||||
|
showMeeting.value = false
|
||||||
|
meetingUrl.value = ''
|
||||||
|
currentMeetingId.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('结束会议失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有活跃会议
|
||||||
|
const checkActiveMeeting = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getActiveMeeting(props.roomId)
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
currentMeetingId.value = res.data.meetingId
|
||||||
|
meetingUrl.value = res.data.iframeUrl
|
||||||
|
showMeeting.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('无活跃会议')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时检查是否有活跃会议
|
||||||
|
onMounted(() => {
|
||||||
|
checkActiveMeeting()
|
||||||
|
})
|
||||||
|
|
||||||
// 滚动到顶部加载更多
|
// 滚动到顶部加载更多
|
||||||
const handleScroll = (e: Event) => {
|
const handleScroll = (e: Event) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
@@ -293,7 +369,9 @@ const renderMarkdown = (text: string): string => {
|
|||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom
|
scrollToBottom,
|
||||||
|
handleStartMeeting,
|
||||||
|
handleEndMeeting
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface TbWorkcaseDTO extends BaseDTO {
|
|||||||
/** 工单ID */
|
/** 工单ID */
|
||||||
workcaseId?: string
|
workcaseId?: string
|
||||||
/** 聊天室ID */
|
/** 聊天室ID */
|
||||||
roomId: string
|
roomId?: string
|
||||||
/** 来客ID */
|
/** 来客ID */
|
||||||
userId?: string
|
userId?: string
|
||||||
/** 来客姓名 */
|
/** 来客姓名 */
|
||||||
@@ -21,7 +21,7 @@ export interface TbWorkcaseDTO extends BaseDTO {
|
|||||||
/** 设备代码 */
|
/** 设备代码 */
|
||||||
deviceCode?: string
|
deviceCode?: string
|
||||||
deviceNamePlate?: string
|
deviceNamePlate?: string
|
||||||
deviceNamePlateImg: string
|
deviceNamePlateImg?: string
|
||||||
/** 地址 */
|
/** 地址 */
|
||||||
address?: string
|
address?: string
|
||||||
/** 故障描述 */
|
/** 故障描述 */
|
||||||
|
|||||||
@@ -148,6 +148,16 @@
|
|||||||
<el-button type="primary" @click="createTicket">创建</el-button>
|
<el-button type="primary" @click="createTicket">创建</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 工单详情弹窗 -->
|
||||||
|
<el-dialog v-model="showDetailDialog" title="工单详情" width="900px" destroy-on-close>
|
||||||
|
<WorkcaseDetail
|
||||||
|
v-if="showDetailDialog"
|
||||||
|
:workcase="currentWorkcase"
|
||||||
|
mode="view"
|
||||||
|
@cancel="showDetailDialog = false"
|
||||||
|
/>
|
||||||
|
</el-dialog>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,8 +167,9 @@ import AdminLayout from '@/views/admin/AdminLayout.vue'
|
|||||||
import { Plus, Search } from 'lucide-vue-next'
|
import { Plus, Search } from 'lucide-vue-next'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { workcaseAPI } from '@/api/workcase'
|
import { workcaseAPI } from '@/api/workcase'
|
||||||
|
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
|
||||||
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase'
|
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase'
|
||||||
import type { PageRequest, PageParam, ResultDomain } from 'shared/types'
|
import type { PageRequest, PageParam } from 'shared/types'
|
||||||
|
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
const typeFilter = ref('')
|
const typeFilter = ref('')
|
||||||
@@ -168,11 +179,11 @@ const currentPage = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
|
const showDetailDialog = ref(false)
|
||||||
|
const currentWorkcase = ref<TbWorkcaseDTO>({})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const formData = ref<TbWorkcaseDTO>({
|
const formData = ref<TbWorkcaseDTO>({
|
||||||
roomId: '',
|
|
||||||
deviceNamePlateImg: '',
|
|
||||||
username: '',
|
username: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
device: '',
|
device: '',
|
||||||
@@ -215,7 +226,7 @@ const loadWorkcases = async () => {
|
|||||||
const res = await workcaseAPI.getWorkcasePage(pageRequest)
|
const res = await workcaseAPI.getWorkcasePage(pageRequest)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
workcaseList.value = res.dataList || res.pageDomain?.dataList || []
|
workcaseList.value = res.dataList || res.pageDomain?.dataList || []
|
||||||
total.value = res.pageParam?.totalElements || 0
|
total.value = res.pageDomain?.pageParam?.total || 0
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message || '加载失败')
|
ElMessage.error(res.message || '加载失败')
|
||||||
}
|
}
|
||||||
@@ -365,8 +376,8 @@ const handlePageChange = (page: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const viewDetail = (row: TbWorkcaseDTO) => {
|
const viewDetail = (row: TbWorkcaseDTO) => {
|
||||||
ElMessage.info(`查看工单详情: ${row.workcaseId}`)
|
currentWorkcase.value = { ...row }
|
||||||
// TODO: 跳转到工单详情页面
|
showDetailDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignTicket = (row: TbWorkcaseDTO) => {
|
const assignTicket = (row: TbWorkcaseDTO) => {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
<div class="table-label">铭牌照片</div>
|
<div class="table-label">铭牌照片</div>
|
||||||
<div class="table-value table-value-full">
|
<div class="table-value table-value-full">
|
||||||
<div class="nameplate-photo" @click="previewNameplateImage">
|
<div class="nameplate-photo" @click="previewNameplateImage">
|
||||||
<img :src="formData.deviceNamePlateImg" alt="设备铭牌" />
|
<img :src="getImageUrl(formData.deviceNamePlateImg)" alt="设备铭牌" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="photos-grid">
|
<div class="photos-grid">
|
||||||
<div v-for="(img, index) in formData.imgs" :key="index" class="photo-item">
|
<div v-for="(img, index) in formData.imgs" :key="index" class="photo-item">
|
||||||
<img :src="img" alt="故障照片" />
|
<img :src="getImageUrl(img)" alt="故障照片" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mode !== 'view'" class="photo-upload">
|
<div v-if="mode !== 'view'" class="photo-upload">
|
||||||
<Plus :size="32" />
|
<Plus :size="32" />
|
||||||
@@ -185,6 +185,7 @@ import { ChatMessage } from '@/views/public/ChatRoom/'
|
|||||||
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage } from 'element-plus'
|
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage } from 'element-plus'
|
||||||
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
|
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
|
||||||
import type { TbWorkcaseDTO } from '@/types/workcase/workcase'
|
import type { TbWorkcaseDTO } from '@/types/workcase/workcase'
|
||||||
|
import { FILE_DOWNLOAD_URL } from '@/config'
|
||||||
|
|
||||||
interface TimelineItem {
|
interface TimelineItem {
|
||||||
status: 'system' | 'manager' | 'engineer'
|
status: 'system' | 'manager' | 'engineer'
|
||||||
@@ -225,6 +226,14 @@ const timeline = ref<TimelineItem[]>([
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function getImageUrl(fileId: string): string {
|
||||||
|
if (!fileId) return ''
|
||||||
|
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
|
||||||
|
return fileId
|
||||||
|
}
|
||||||
|
return `${FILE_DOWNLOAD_URL}${fileId}`
|
||||||
|
}
|
||||||
|
|
||||||
// 根据 workcaseId 获取聊天室ID
|
// 根据 workcaseId 获取聊天室ID
|
||||||
const loadChatRoom = async () => {
|
const loadChatRoom = async () => {
|
||||||
if (!formData.value.workcaseId) return
|
if (!formData.value.workcaseId) return
|
||||||
|
|||||||
@@ -214,5 +214,54 @@ export const workcaseChatAPI = {
|
|||||||
*/
|
*/
|
||||||
getWordCloudPage(pageRequest: PageRequest<TbWordCloudDTO>): Promise<ResultDomain<TbWordCloudDTO>> {
|
getWordCloudPage(pageRequest: PageRequest<TbWordCloudDTO>): Promise<ResultDomain<TbWordCloudDTO>> {
|
||||||
return request<TbWordCloudDTO>({ url: `${this.baseUrl}/wordcloud/page`, method: 'POST', data: pageRequest })
|
return request<TbWordCloudDTO>({ url: `${this.baseUrl}/wordcloud/page`, method: 'POST', data: pageRequest })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ====================== 视频会议管理(Jitsi Meet) ======================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建视频会议
|
||||||
|
*/
|
||||||
|
createVideoMeeting(params: {
|
||||||
|
roomId: string
|
||||||
|
workcaseId?: string
|
||||||
|
meetingName: string
|
||||||
|
maxParticipants?: number
|
||||||
|
}): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/create`, method: 'POST', data: params })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会议信息
|
||||||
|
*/
|
||||||
|
getMeetingInfo(meetingId: string): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/${meetingId}`, method: 'GET' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天室活跃会议
|
||||||
|
*/
|
||||||
|
getActiveMeeting(roomId: string): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/room/${roomId}/active`, method: 'GET' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入会议(生成用户专属JWT)
|
||||||
|
*/
|
||||||
|
joinMeeting(meetingId: string): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/${meetingId}/join`, method: 'POST' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始会议
|
||||||
|
*/
|
||||||
|
startVideoMeeting(meetingId: string): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/${meetingId}/start`, method: 'POST' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束会议
|
||||||
|
*/
|
||||||
|
endVideoMeeting(meetingId: string): Promise<ResultDomain<any>> {
|
||||||
|
return request({ url: `${this.baseUrl}/meeting/${meetingId}/end`, method: 'POST' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/meeting/Meeting/Meeting",
|
"path": "pages/meeting/Meeting",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,10 +532,43 @@ function handleWorkcaseAction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发起会议
|
// 发起会议
|
||||||
function startMeeting() {
|
async function startMeeting() {
|
||||||
uni.navigateTo({
|
try {
|
||||||
url: `/pages/meeting/Meeting/Meeting?roomId=${roomId.value}&workcaseId=${workcaseId.value}`
|
uni.showLoading({ title: '创建会议中...' })
|
||||||
|
|
||||||
|
// 调用后端API创建会议
|
||||||
|
const res = await workcaseChatAPI.createVideoMeeting({
|
||||||
|
roomId: roomId.value,
|
||||||
|
workcaseId: workcaseId.value,
|
||||||
|
meetingName: `工单 ${workcaseId.value || roomId.value} 技术支持`,
|
||||||
|
maxParticipants: 10
|
||||||
})
|
})
|
||||||
|
|
||||||
|
uni.hideLoading()
|
||||||
|
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const meetingUrl = res.data.iframeUrl
|
||||||
|
const meetingId = res.data.meetingId
|
||||||
|
|
||||||
|
// 小程序/App使用webview打开会议
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/meeting/MeetingView/MeetingView?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}`,
|
||||||
|
success: () => {
|
||||||
|
console.log('[chatRoom] 跳转会议页面成功')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('[chatRoom] 跳转会议页面失败:', err)
|
||||||
|
uni.showToast({ title: '打开会议失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '创建会议失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
console.error('[chatRoom] 创建会议失败:', e)
|
||||||
|
uni.showToast({ title: '创建会议失败', icon: 'none' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.meeting-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.back-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right {
|
||||||
|
.end-btn {
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<view class="meeting-page">
|
||||||
|
<!-- 自定义导航栏 -->
|
||||||
|
<view class="meeting-nav" :style="{ paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px' }">
|
||||||
|
<view class="nav-back" @tap="confirmExit">
|
||||||
|
<text class="back-icon">←</text>
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">视频会议</text>
|
||||||
|
<view class="nav-right" @tap="endMeeting">
|
||||||
|
<text class="end-btn">结束会议</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Web-view加载Jitsi Meet -->
|
||||||
|
<web-view
|
||||||
|
:src="meetingUrl"
|
||||||
|
:webview-styles="webviewStyles"
|
||||||
|
@message="handleWebViewMessage"
|
||||||
|
@error="handleWebViewError"
|
||||||
|
></web-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { workcaseChatAPI } from '@/api/workcase'
|
||||||
|
|
||||||
|
const statusBarHeight = ref(44)
|
||||||
|
const navBarHeight = ref(88)
|
||||||
|
const meetingUrl = ref('')
|
||||||
|
const meetingId = ref('')
|
||||||
|
|
||||||
|
const webviewStyles = ref({
|
||||||
|
progress: {
|
||||||
|
color: '#667eea'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 获取状态栏高度
|
||||||
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight || 44
|
||||||
|
navBarHeight.value = statusBarHeight.value + 44
|
||||||
|
|
||||||
|
// 获取页面参数
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1] as any
|
||||||
|
if (currentPage && currentPage.options) {
|
||||||
|
meetingUrl.value = decodeURIComponent(currentPage.options.meetingUrl || '')
|
||||||
|
meetingId.value = currentPage.options.meetingId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MeetingView] 会议页面加载:', {
|
||||||
|
meetingId: meetingId.value,
|
||||||
|
meetingUrl: meetingUrl.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 确认退出
|
||||||
|
function confirmExit() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要退出会议吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束会议
|
||||||
|
async function endMeeting() {
|
||||||
|
if (!meetingId.value) {
|
||||||
|
uni.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要结束会议吗?这将关闭所有参与者的会议。',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: '结束会议中...' })
|
||||||
|
await workcaseChatAPI.endVideoMeeting(meetingId.value)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '会议已结束', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
console.error('[MeetingView] 结束会议失败:', e)
|
||||||
|
uni.showToast({ title: '结束会议失败', icon: 'none' })
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理webview消息
|
||||||
|
function handleWebViewMessage(e: any) {
|
||||||
|
console.log('[MeetingView] webview消息:', e)
|
||||||
|
// 可以在这里处理Jitsi Meet发送的消息
|
||||||
|
// 例如:会议结束、参与者加入/离开等事件
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理webview错误
|
||||||
|
function handleWebViewError(e: any) {
|
||||||
|
console.error('[MeetingView] webview错误:', e)
|
||||||
|
uni.showToast({ title: '会议加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import url("./Meeting.scss")
|
||||||
|
</style>
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f4f5f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: #fff;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding-left: 24rpx;
|
|
||||||
padding-right: 24rpx;
|
|
||||||
padding-bottom: 16rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-back {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-back-icon {
|
|
||||||
width: 20rpx;
|
|
||||||
height: 20rpx;
|
|
||||||
border-left: 4rpx solid #333;
|
|
||||||
border-bottom: 4rpx solid #333;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 64rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-capsule {
|
|
||||||
width: 174rpx;
|
|
||||||
height: 64rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-container {
|
|
||||||
margin-top: 176rpx;
|
|
||||||
padding: 48rpx 32rpx;
|
|
||||||
min-height: calc(100vh - 176rpx);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 80rpx 40rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-icon {
|
|
||||||
width: 200rpx;
|
|
||||||
height: 200rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(145deg, #e8f7ff 0%, #c5e4ff 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 48rpx;
|
|
||||||
box-shadow: 0 10rpx 40rpx rgba(180,220,255,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-text {
|
|
||||||
font-size: 96rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-name {
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #1d72d3;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-desc {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 64rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-actions {
|
|
||||||
margin-bottom: 80rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-btn {
|
|
||||||
height: 96rpx;
|
|
||||||
padding: 0 60rpx;
|
|
||||||
background: linear-gradient(90deg, #173294 0%, #4a6fd9 100%);
|
|
||||||
border-radius: 48rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-text {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-tips {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
background: #f5f8ff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-item {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-meeting {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-webview {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 48rpx;
|
|
||||||
padding: 32rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
margin-top: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
padding: 24rpx 40rpx;
|
|
||||||
background: #f5f8ff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.active {
|
|
||||||
background: #fff7e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leave-btn {
|
|
||||||
background: #fff1f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-icon {
|
|
||||||
font-size: 48rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-label {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- #ifdef APP -->
|
|
||||||
<scroll-view style="flex:1">
|
|
||||||
<!-- #endif -->
|
|
||||||
<view class="page">
|
|
||||||
<!-- 自定义导航栏 -->
|
|
||||||
<view class="nav" :style="{ paddingTop: headerPaddingTop + 'px', height: headerTotalHeight + 'px' }">
|
|
||||||
<view class="nav-back" @tap="goBack">
|
|
||||||
<view class="nav-back-icon"></view>
|
|
||||||
</view>
|
|
||||||
<text class="nav-title">视频会议</text>
|
|
||||||
<view class="nav-capsule"></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 会议内容区 -->
|
|
||||||
<view class="meeting-container" :style="{ marginTop: headerTotalHeight + 'px' }">
|
|
||||||
<!-- 会议信息 -->
|
|
||||||
<view class="meeting-info" v-if="!isInMeeting">
|
|
||||||
<view class="meeting-icon">
|
|
||||||
<text class="icon-text">📹</text>
|
|
||||||
</view>
|
|
||||||
<text class="meeting-name">{{ meetingName || '视频会议' }}</text>
|
|
||||||
<text class="meeting-desc">与客服进行实时视频沟通</text>
|
|
||||||
|
|
||||||
<view class="meeting-actions">
|
|
||||||
<view class="join-btn" @tap="joinMeeting">
|
|
||||||
<text class="join-text">加入会议</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="meeting-tips">
|
|
||||||
<text class="tip-item">• 请确保网络连接稳定</text>
|
|
||||||
<text class="tip-item">• 允许摄像头和麦克风权限</text>
|
|
||||||
<text class="tip-item">• 建议在安静环境下进行会议</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 会议中状态 -->
|
|
||||||
<view class="in-meeting" v-else>
|
|
||||||
<!-- Jitsi Meet iframe 容器 -->
|
|
||||||
<web-view v-if="iframeUrl" :src="iframeUrl" class="meeting-webview"></web-view>
|
|
||||||
|
|
||||||
<!-- 会议控制栏 -->
|
|
||||||
<view class="meeting-controls">
|
|
||||||
<view class="control-btn" :class="{ active: isMuted }" @tap="toggleMute">
|
|
||||||
<text class="control-icon">{{ isMuted ? '🔇' : '🔊' }}</text>
|
|
||||||
<text class="control-label">{{ isMuted ? '取消静音' : '静音' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="control-btn" :class="{ active: isVideoOff }" @tap="toggleVideo">
|
|
||||||
<text class="control-icon">{{ isVideoOff ? '📷' : '📹' }}</text>
|
|
||||||
<text class="control-label">{{ isVideoOff ? '开启视频' : '关闭视频' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="control-btn leave-btn" @tap="leaveMeeting">
|
|
||||||
<text class="control-icon">📞</text>
|
|
||||||
<text class="control-label">离开会议</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<!-- #ifdef APP -->
|
|
||||||
</scroll-view>
|
|
||||||
<!-- #endif -->
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import type { VideoMeetingVO } from '@/types/workcase'
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const headerPaddingTop = ref<number>(44)
|
|
||||||
const headerTotalHeight = ref<number>(88)
|
|
||||||
const roomId = ref<string>('')
|
|
||||||
const workcaseId = ref<string>('')
|
|
||||||
const meetingName = ref<string>('视频会议')
|
|
||||||
const isInMeeting = ref<boolean>(false)
|
|
||||||
const iframeUrl = ref<string>('')
|
|
||||||
const isMuted = ref<boolean>(false)
|
|
||||||
const isVideoOff = ref<boolean>(false)
|
|
||||||
|
|
||||||
// 会议信息
|
|
||||||
const meeting = ref<VideoMeetingVO>({})
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
uni.getSystemInfo({
|
|
||||||
success: (res) => {
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
try {
|
|
||||||
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
|
|
||||||
headerPaddingTop.value = menuButtonInfo.top
|
|
||||||
headerTotalHeight.value = menuButtonInfo.bottom + 8
|
|
||||||
} catch (e) {
|
|
||||||
headerPaddingTop.value = res.statusBarHeight || 44
|
|
||||||
headerTotalHeight.value = (res.statusBarHeight || 44) + 44
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
headerPaddingTop.value = res.statusBarHeight || 44
|
|
||||||
headerTotalHeight.value = (res.statusBarHeight || 44) + 44
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取页面参数
|
|
||||||
const pages = getCurrentPages()
|
|
||||||
const currentPage = pages[pages.length - 1] as any
|
|
||||||
if (currentPage && currentPage.options) {
|
|
||||||
roomId.value = currentPage.options.roomId || ''
|
|
||||||
workcaseId.value = currentPage.options.workcaseId || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMeetingInfo()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 加载会议信息
|
|
||||||
function loadMeetingInfo() {
|
|
||||||
console.log('加载会议信息:', roomId.value)
|
|
||||||
// TODO: 调用 workcaseChatAPI 获取会议信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加入会议
|
|
||||||
function joinMeeting() {
|
|
||||||
uni.showLoading({ title: '正在加入会议...' })
|
|
||||||
|
|
||||||
// 模拟加入会议
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.hideLoading()
|
|
||||||
isInMeeting.value = true
|
|
||||||
// TODO: 实际调用API创建/加入会议,获取iframeUrl
|
|
||||||
// iframeUrl.value = meeting.value.iframeUrl || ''
|
|
||||||
|
|
||||||
uni.showToast({
|
|
||||||
title: '已加入会议',
|
|
||||||
icon: 'success'
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 离开会议
|
|
||||||
function leaveMeeting() {
|
|
||||||
uni.showModal({
|
|
||||||
title: '离开会议',
|
|
||||||
content: '确定要离开当前会议吗?',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
isInMeeting.value = false
|
|
||||||
iframeUrl.value = ''
|
|
||||||
uni.showToast({
|
|
||||||
title: '已离开会议',
|
|
||||||
icon: 'success'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换静音
|
|
||||||
function toggleMute() {
|
|
||||||
isMuted.value = !isMuted.value
|
|
||||||
// TODO: 调用Jitsi API控制静音
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换视频
|
|
||||||
function toggleVideo() {
|
|
||||||
isVideoOff.value = !isVideoOff.value
|
|
||||||
// TODO: 调用Jitsi API控制视频
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回上一页
|
|
||||||
function goBack() {
|
|
||||||
if (isInMeeting.value) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '离开会议',
|
|
||||||
content: '返回将离开当前会议,确定吗?',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
uni.navigateBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
uni.navigateBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "./Meeting.scss";
|
|
||||||
</style>
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user