Compare commits
91 Commits
154 changed files with 35643 additions and 5262 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"git.ignoreLimitWarning": true |
|||
} |
|||
@ -1,6 +0,0 @@ |
|||
{ |
|||
"name": "cesium-map-object", |
|||
"lockfileVersion": 3, |
|||
"requires": true, |
|||
"packages": {} |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
package com.ruoyi.web.controller; |
|||
|
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
import com.ruoyi.common.core.controller.BaseController; |
|||
import com.ruoyi.common.core.domain.AjaxResult; |
|||
import com.alibaba.fastjson2.JSON; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
|
|||
/** |
|||
* 导弹发射参数(角度、距离)Redis 存取 |
|||
* key: missile:params:{roomId}:{routeId}:{platformId}, value: JSON |
|||
*/ |
|||
@RestController |
|||
@RequestMapping("/system/missile-params") |
|||
public class MissileParamsController extends BaseController { |
|||
|
|||
@Autowired |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:routes:query')") |
|||
@GetMapping |
|||
public AjaxResult get(Long roomId, Long routeId, Long platformId) { |
|||
if (roomId == null || routeId == null || platformId == null) { |
|||
return AjaxResult.error("参数不完整"); |
|||
} |
|||
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId; |
|||
Object val = redisTemplate.opsForValue().get(key); |
|||
if (val != null) { |
|||
return success(JSON.parseObject(val.toString())); |
|||
} |
|||
return success(); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:routes:edit')") |
|||
@PostMapping |
|||
public AjaxResult save(@RequestBody java.util.Map<String, Object> body) { |
|||
Object roomId = body.get("roomId"); |
|||
Object routeId = body.get("routeId"); |
|||
Object platformId = body.get("platformId"); |
|||
if (roomId == null || routeId == null || platformId == null) { |
|||
return AjaxResult.error("参数不完整"); |
|||
} |
|||
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId; |
|||
redisTemplate.opsForValue().set(key, JSON.toJSONString(body)); |
|||
return success(); |
|||
} |
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
package com.ruoyi.web.controller; |
|||
|
|||
import java.util.List; |
|||
|
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import com.ruoyi.common.core.controller.BaseController; |
|||
import com.ruoyi.common.core.domain.AjaxResult; |
|||
import com.ruoyi.common.core.page.TableDataInfo; |
|||
import com.ruoyi.system.domain.ObjectOperationLog; |
|||
import com.ruoyi.system.service.IObjectOperationLogService; |
|||
|
|||
/** |
|||
* 对象级操作日志(航线/航点/平台):分页查询、回滚 |
|||
*/ |
|||
@RestController |
|||
@RequestMapping("/system/object-log") |
|||
public class ObjectOperationLogController extends BaseController { |
|||
|
|||
@Autowired |
|||
private IObjectOperationLogService objectOperationLogService; |
|||
|
|||
/** |
|||
* 分页查询对象级操作日志(按房间) |
|||
*/ |
|||
@PreAuthorize("@ss.hasPermi('system:routes:list')") |
|||
@GetMapping("/list") |
|||
public TableDataInfo list( |
|||
@RequestParam(required = false) Long roomId, |
|||
@RequestParam(required = false) String operatorName, |
|||
@RequestParam(required = false) Integer operationType, |
|||
@RequestParam(required = false) String objectType) { |
|||
startPage(); |
|||
ObjectOperationLog query = new ObjectOperationLog(); |
|||
query.setRoomId(roomId); |
|||
query.setOperatorName(operatorName); |
|||
query.setOperationType(operationType); |
|||
query.setObjectType(objectType); |
|||
List<ObjectOperationLog> list = objectOperationLogService.selectPage(query); |
|||
return getDataTable(list); |
|||
} |
|||
|
|||
/** |
|||
* 回滚到指定操作(数据库 + Redis 同步) |
|||
*/ |
|||
@PreAuthorize("@ss.hasPermi('system:routes:edit')") |
|||
@PostMapping("/rollback") |
|||
public AjaxResult rollback(@RequestParam Long id) { |
|||
ObjectOperationLog origin = objectOperationLogService.selectById(id); |
|||
if (origin == null) { |
|||
return error("回滚失败:原始日志不存在"); |
|||
} |
|||
boolean ok = objectOperationLogService.rollback(id); |
|||
if (!ok) { |
|||
return error("回滚失败:无快照或对象类型不支持"); |
|||
} |
|||
|
|||
// 记录一条“回滚”操作日志,便于审计
|
|||
ObjectOperationLog rollbackLog = new ObjectOperationLog(); |
|||
rollbackLog.setRoomId(origin.getRoomId()); |
|||
rollbackLog.setOperatorId(getUserId()); |
|||
rollbackLog.setOperatorName(getUsername()); |
|||
rollbackLog.setOperationType(ObjectOperationLog.TYPE_ROLLBACK); |
|||
rollbackLog.setObjectType(origin.getObjectType()); |
|||
rollbackLog.setObjectId(origin.getObjectId()); |
|||
rollbackLog.setObjectName(origin.getObjectName()); |
|||
rollbackLog.setDetail("回滚操作:基于日志ID=" + origin.getId() + " 的" + toOpText(origin.getOperationType())); |
|||
// 简要记录被回滚日志的快照,方便排查(可选)
|
|||
rollbackLog.setSnapshotBefore(origin.getSnapshotBefore()); |
|||
rollbackLog.setSnapshotAfter(origin.getSnapshotAfter()); |
|||
objectOperationLogService.saveLog(rollbackLog); |
|||
|
|||
return success(); |
|||
} |
|||
|
|||
private String toOpText(Integer type) { |
|||
if (type == null) return ""; |
|||
switch (type) { |
|||
case ObjectOperationLog.TYPE_INSERT: |
|||
return "新增"; |
|||
case ObjectOperationLog.TYPE_UPDATE: |
|||
return "修改"; |
|||
case ObjectOperationLog.TYPE_DELETE: |
|||
return "删除"; |
|||
case ObjectOperationLog.TYPE_SELECT: |
|||
return "选择"; |
|||
case ObjectOperationLog.TYPE_ROLLBACK: |
|||
return "回滚"; |
|||
default: |
|||
return ""; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
package com.ruoyi.web.controller; |
|||
|
|||
import java.util.List; |
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.web.bind.annotation.DeleteMapping; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import com.ruoyi.common.core.controller.BaseController; |
|||
import com.ruoyi.common.core.domain.AjaxResult; |
|||
import com.ruoyi.common.core.domain.model.LoginUser; |
|||
import com.ruoyi.common.config.RuoYiConfig; |
|||
import com.ruoyi.common.utils.StringUtils; |
|||
import com.ruoyi.common.utils.file.FileUploadUtils; |
|||
import com.ruoyi.common.utils.file.FileUtils; |
|||
import com.ruoyi.common.utils.file.MimeTypeUtils; |
|||
import com.ruoyi.framework.config.ServerConfig; |
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
import com.ruoyi.system.service.IRoomUserProfileService; |
|||
import com.ruoyi.websocket.service.WhiteboardRoomService; |
|||
|
|||
/** |
|||
* 白板 Controller:房间维度的白板 CRUD,数据存 Redis |
|||
*/ |
|||
@RestController |
|||
@RequestMapping("/room") |
|||
public class WhiteboardController extends BaseController { |
|||
|
|||
@Autowired |
|||
private WhiteboardRoomService whiteboardRoomService; |
|||
|
|||
@Autowired |
|||
private IRoomUserProfileService roomUserProfileService; |
|||
|
|||
@Autowired |
|||
private ServerConfig serverConfig; |
|||
|
|||
/** 获取房间下所有白板列表 */ |
|||
@GetMapping("/{roomId}/whiteboards") |
|||
public AjaxResult list(@PathVariable Long roomId) { |
|||
List<Object> list = whiteboardRoomService.listWhiteboards(roomId); |
|||
return success(list); |
|||
} |
|||
|
|||
/** 获取单个白板详情 */ |
|||
@GetMapping("/{roomId}/whiteboard/{whiteboardId}") |
|||
public AjaxResult get(@PathVariable Long roomId, @PathVariable String whiteboardId) { |
|||
Object wb = whiteboardRoomService.getWhiteboard(roomId, whiteboardId); |
|||
if (wb == null) return error("白板不存在"); |
|||
return success(wb); |
|||
} |
|||
|
|||
/** 创建白板 */ |
|||
@PostMapping("/{roomId}/whiteboard") |
|||
public AjaxResult create(@PathVariable Long roomId, @RequestBody Object whiteboard) { |
|||
Object created = whiteboardRoomService.createWhiteboard(roomId, whiteboard); |
|||
return success(created); |
|||
} |
|||
|
|||
/** 更新白板 */ |
|||
@PutMapping("/{roomId}/whiteboard/{whiteboardId}") |
|||
public AjaxResult update(@PathVariable Long roomId, @PathVariable String whiteboardId, |
|||
@RequestBody Object whiteboard) { |
|||
boolean ok = whiteboardRoomService.updateWhiteboard(roomId, whiteboardId, whiteboard); |
|||
return ok ? success() : error("更新失败"); |
|||
} |
|||
|
|||
/** 删除白板 */ |
|||
@DeleteMapping("/{roomId}/whiteboard/{whiteboardId}") |
|||
public AjaxResult delete(@PathVariable Long roomId, @PathVariable String whiteboardId) { |
|||
boolean ok = whiteboardRoomService.deleteWhiteboard(roomId, whiteboardId); |
|||
return ok ? success() : error("删除失败"); |
|||
} |
|||
|
|||
/** 保存白板平台样式(Redis Key: whiteboard:scheme:{schemeId}:platform:{platformInstanceId}:style) */ |
|||
@PostMapping("/whiteboard/platform/style") |
|||
public AjaxResult savePlatformStyle(@RequestBody java.util.Map<String, Object> body) { |
|||
if (body == null) return error("参数不能为空"); |
|||
String schemeId = body.get("schemeId") == null ? null : String.valueOf(body.get("schemeId")); |
|||
String platformInstanceId = body.get("platformInstanceId") == null ? null : String.valueOf(body.get("platformInstanceId")); |
|||
Object style = body.get("style"); |
|||
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty() || style == null) { |
|||
return error("schemeId、platformInstanceId、style 不能为空"); |
|||
} |
|||
whiteboardRoomService.saveWhiteboardPlatformStyle(schemeId, platformInstanceId, style); |
|||
return success(); |
|||
} |
|||
|
|||
/** 获取白板平台样式 */ |
|||
@GetMapping("/whiteboard/platform/style") |
|||
public AjaxResult getPlatformStyle(@RequestParam String schemeId, @RequestParam String platformInstanceId) { |
|||
Object style = whiteboardRoomService.getWhiteboardPlatformStyle(schemeId, platformInstanceId); |
|||
return success(style); |
|||
} |
|||
|
|||
/** 删除白板平台样式 */ |
|||
@DeleteMapping("/whiteboard/platform/style") |
|||
public AjaxResult deletePlatformStyle(@RequestParam String schemeId, @RequestParam String platformInstanceId) { |
|||
whiteboardRoomService.deleteWhiteboardPlatformStyle(schemeId, platformInstanceId); |
|||
return success(); |
|||
} |
|||
|
|||
@GetMapping("/user/profile") |
|||
public AjaxResult getCurrentUserProfile() { |
|||
LoginUser loginUser = getLoginUser(); |
|||
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
return success(buildProfilePayload(loginUser, profile)); |
|||
} |
|||
|
|||
@PutMapping("/user/profile") |
|||
public AjaxResult updateCurrentUserProfile(@RequestBody Map<String, Object> body) { |
|||
LoginUser loginUser = getLoginUser(); |
|||
String displayName = body != null && body.get("displayName") != null ? String.valueOf(body.get("displayName")).trim() : null; |
|||
if (StringUtils.isEmpty(displayName)) { |
|||
return error("用户名不能为空"); |
|||
} |
|||
if (displayName.length() > 32) { |
|||
return error("用户名长度不能超过32个字符"); |
|||
} |
|||
RoomUserProfile profile = roomUserProfileService.updateDisplayName(loginUser.getUserId(), displayName, loginUser.getUsername()); |
|||
return success(buildProfilePayload(loginUser, profile)); |
|||
} |
|||
|
|||
@PostMapping("/user/profile/avatar") |
|||
public AjaxResult uploadCurrentUserAvatar(@RequestParam("avatarfile") MultipartFile file) throws Exception { |
|||
if (file == null || file.isEmpty()) { |
|||
return error("请选择头像图片"); |
|||
} |
|||
LoginUser loginUser = getLoginUser(); |
|||
RoomUserProfile oldProfile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
String oldAvatar = oldProfile != null ? oldProfile.getAvatar() : null; |
|||
|
|||
String avatar = FileUploadUtils.upload(RuoYiConfig.getRoomUserAvatarPath(), file, MimeTypeUtils.IMAGE_EXTENSION, true); |
|||
RoomUserProfile profile = roomUserProfileService.updateAvatar(loginUser.getUserId(), avatar, loginUser.getUsername()); |
|||
|
|||
// 仅删除本模块上传目录中的旧头像,避免误删系统头像或外链头像
|
|||
if (StringUtils.isNotEmpty(oldAvatar) && oldAvatar.startsWith("/room-user-avatar/")) { |
|||
FileUtils.deleteFile(RuoYiConfig.getProfile() + FileUtils.stripPrefix(oldAvatar)); |
|||
} |
|||
|
|||
AjaxResult ajax = success(buildProfilePayload(loginUser, profile)); |
|||
ajax.put("imgUrl", avatar); |
|||
ajax.put("fullImgUrl", avatar.startsWith("http") ? avatar : (serverConfig.getUrl() + avatar)); |
|||
return ajax; |
|||
} |
|||
|
|||
private Map<String, Object> buildProfilePayload(LoginUser loginUser, RoomUserProfile profile) { |
|||
Map<String, Object> payload = new HashMap<>(); |
|||
payload.put("userId", loginUser.getUserId()); |
|||
payload.put("userName", loginUser.getUsername()); |
|||
payload.put("displayName", profile != null ? profile.getDisplayName() : loginUser.getUsername()); |
|||
payload.put("avatar", profile != null ? profile.getAvatar() : null); |
|||
return payload; |
|||
} |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
package com.ruoyi.web.controller.system; |
|||
|
|||
import java.util.List; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.DeleteMapping; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
import com.ruoyi.common.annotation.Log; |
|||
import com.ruoyi.common.core.controller.BaseController; |
|||
import com.ruoyi.common.core.domain.AjaxResult; |
|||
import com.ruoyi.common.core.domain.entity.SysTimelineSegment; |
|||
import com.ruoyi.common.enums.BusinessType; |
|||
import com.ruoyi.system.service.ISysTimelineSegmentService; |
|||
|
|||
@RestController |
|||
@RequestMapping("/system/timeline") |
|||
public class SysTimelineSegmentController extends BaseController |
|||
{ |
|||
@Autowired |
|||
private ISysTimelineSegmentService segmentService; |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:list')") |
|||
@GetMapping("/list") |
|||
public AjaxResult list(SysTimelineSegment segment) |
|||
{ |
|||
List<SysTimelineSegment> list = segmentService.selectSegmentList(segment); |
|||
return success(list); |
|||
} |
|||
|
|||
@GetMapping("/listByRoomId/{roomId}") |
|||
public AjaxResult listByRoomId(@PathVariable Long roomId) |
|||
{ |
|||
List<SysTimelineSegment> list = segmentService.selectSegmentListByRoomId(roomId); |
|||
return success(list); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:query')") |
|||
@GetMapping(value = "/{segmentId}") |
|||
public AjaxResult getInfo(@PathVariable Long segmentId) |
|||
{ |
|||
return success(segmentService.selectSegmentById(segmentId)); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:add')") |
|||
@Log(title = "时间轴管理", businessType = BusinessType.INSERT) |
|||
@PostMapping |
|||
public AjaxResult add(@RequestBody SysTimelineSegment segment) |
|||
{ |
|||
return toAjax(segmentService.insertSegment(segment)); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:edit')") |
|||
@Log(title = "时间轴管理", businessType = BusinessType.UPDATE) |
|||
@PutMapping |
|||
public AjaxResult edit(@RequestBody SysTimelineSegment segment) |
|||
{ |
|||
return toAjax(segmentService.updateSegment(segment)); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:remove')") |
|||
@Log(title = "时间轴管理", businessType = BusinessType.DELETE) |
|||
@DeleteMapping("/{segmentId}") |
|||
public AjaxResult remove(@PathVariable Long segmentId) |
|||
{ |
|||
return toAjax(segmentService.deleteSegmentById(segmentId)); |
|||
} |
|||
|
|||
@PreAuthorize("@ss.hasPermi('system:timeline:remove')") |
|||
@Log(title = "时间轴管理", businessType = BusinessType.DELETE) |
|||
@DeleteMapping("/room/{roomId}") |
|||
public AjaxResult removeByRoomId(@PathVariable Long roomId) |
|||
{ |
|||
return toAjax(segmentService.deleteSegmentByRoomId(roomId)); |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
package com.ruoyi.websocket.config; |
|||
|
|||
import java.security.Principal; |
|||
import com.ruoyi.common.core.domain.model.LoginUser; |
|||
|
|||
/** |
|||
* 将 LoginUser 包装为 Principal,供 WebSocket 使用 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public class LoginUserPrincipal implements Principal { |
|||
|
|||
private final LoginUser loginUser; |
|||
|
|||
public LoginUserPrincipal(LoginUser loginUser) { |
|||
this.loginUser = loginUser; |
|||
} |
|||
|
|||
@Override |
|||
public String getName() { |
|||
return loginUser != null ? loginUser.getUsername() : null; |
|||
} |
|||
|
|||
public LoginUser getLoginUser() { |
|||
return loginUser; |
|||
} |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
package com.ruoyi.websocket.config; |
|||
|
|||
import java.util.Map; |
|||
import org.springframework.core.Ordered; |
|||
import org.springframework.core.annotation.Order; |
|||
import org.springframework.messaging.Message; |
|||
import org.springframework.messaging.MessageChannel; |
|||
import org.springframework.messaging.simp.stomp.StompCommand; |
|||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor; |
|||
import org.springframework.messaging.support.ChannelInterceptor; |
|||
import org.springframework.messaging.support.MessageHeaderAccessor; |
|||
import org.springframework.stereotype.Component; |
|||
import com.ruoyi.common.core.domain.model.LoginUser; |
|||
import com.ruoyi.websocket.config.LoginUserPrincipal; |
|||
|
|||
/** |
|||
* WebSocket 通道拦截器:将握手时的 loginUser 注入到消息头 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Component |
|||
@Order(Ordered.HIGHEST_PRECEDENCE + 99) |
|||
public class WebSocketChannelInterceptor implements ChannelInterceptor { |
|||
|
|||
@Override |
|||
public Message<?> preSend(Message<?> message, MessageChannel channel) { |
|||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); |
|||
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { |
|||
Map<String, Object> sessionAttrs = accessor.getSessionAttributes(); |
|||
if (sessionAttrs != null && sessionAttrs.containsKey("loginUser")) { |
|||
LoginUser loginUser = (LoginUser) sessionAttrs.get("loginUser"); |
|||
accessor.setUser(new LoginUserPrincipal(loginUser)); |
|||
} |
|||
} |
|||
return message; |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
package com.ruoyi.websocket.config; |
|||
|
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.core.Ordered; |
|||
import org.springframework.core.annotation.Order; |
|||
import org.springframework.messaging.simp.config.ChannelRegistration; |
|||
import org.springframework.messaging.simp.config.MessageBrokerRegistry; |
|||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; |
|||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry; |
|||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; |
|||
|
|||
/** |
|||
* WebSocket STOMP 配置 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Configuration |
|||
@EnableWebSocketMessageBroker |
|||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { |
|||
|
|||
@Autowired |
|||
private WebSocketHandshakeHandler webSocketHandshakeHandler; |
|||
|
|||
@Autowired |
|||
private WebSocketChannelInterceptor webSocketChannelInterceptor; |
|||
|
|||
@Override |
|||
public void configureMessageBroker(MessageBrokerRegistry config) { |
|||
config.enableSimpleBroker("/topic", "/queue"); |
|||
config.setApplicationDestinationPrefixes("/app"); |
|||
config.setUserDestinationPrefix("/user"); |
|||
} |
|||
|
|||
@Override |
|||
public void registerStompEndpoints(StompEndpointRegistry registry) { |
|||
registry.addEndpoint("/ws") |
|||
.setAllowedOriginPatterns("*") |
|||
.addInterceptors(webSocketHandshakeHandler) |
|||
.withSockJS(); |
|||
} |
|||
|
|||
@Override |
|||
public void configureClientInboundChannel(ChannelRegistration registration) { |
|||
registration.interceptors(webSocketChannelInterceptor); |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
package com.ruoyi.websocket.config; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.Enumeration; |
|||
import java.util.Map; |
|||
import javax.servlet.http.HttpServletRequest; |
|||
import javax.servlet.http.HttpServletRequestWrapper; |
|||
import org.springframework.http.server.ServerHttpRequest; |
|||
import org.springframework.http.server.ServerHttpResponse; |
|||
import org.springframework.http.server.ServletServerHttpRequest; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.socket.WebSocketHandler; |
|||
import org.springframework.web.socket.server.HandshakeInterceptor; |
|||
import com.ruoyi.common.constant.Constants; |
|||
import com.ruoyi.common.core.domain.model.LoginUser; |
|||
import com.ruoyi.common.utils.StringUtils; |
|||
import com.ruoyi.framework.web.service.TokenService; |
|||
|
|||
/** |
|||
* WebSocket 握手拦截器:校验 JWT Token(支持 query 参数 token 或 Authorization header) |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Component |
|||
public class WebSocketHandshakeHandler implements HandshakeInterceptor { |
|||
|
|||
private final TokenService tokenService; |
|||
|
|||
public WebSocketHandshakeHandler(TokenService tokenService) { |
|||
this.tokenService = tokenService; |
|||
} |
|||
|
|||
@Override |
|||
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, |
|||
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { |
|||
if (!(request instanceof ServletServerHttpRequest)) { |
|||
return false; |
|||
} |
|||
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; |
|||
HttpServletRequest req = servletRequest.getServletRequest(); |
|||
String token = req.getParameter("token"); |
|||
if (StringUtils.isEmpty(token)) { |
|||
String auth = req.getHeader("Authorization"); |
|||
if (StringUtils.isNotEmpty(auth) && auth.startsWith(Constants.TOKEN_PREFIX)) { |
|||
token = auth.substring(Constants.TOKEN_PREFIX.length()).trim(); |
|||
} |
|||
} |
|||
if (StringUtils.isEmpty(token)) { |
|||
return false; |
|||
} |
|||
final String tokenFinal = token; |
|||
HttpServletRequest wrappedReq = new HttpServletRequestWrapper(req) { |
|||
@Override |
|||
public String getHeader(String name) { |
|||
if ("Authorization".equalsIgnoreCase(name)) { |
|||
return Constants.TOKEN_PREFIX + tokenFinal; |
|||
} |
|||
return super.getHeader(name); |
|||
} |
|||
@Override |
|||
public Enumeration<String> getHeaders(String name) { |
|||
if ("Authorization".equalsIgnoreCase(name)) { |
|||
return Collections.enumeration(Collections.singletonList(Constants.TOKEN_PREFIX + tokenFinal)); |
|||
} |
|||
return super.getHeaders(name); |
|||
} |
|||
}; |
|||
LoginUser loginUser = tokenService.getLoginUser(wrappedReq); |
|||
if (loginUser == null) { |
|||
return false; |
|||
} |
|||
attributes.put("loginUser", loginUser); |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, |
|||
WebSocketHandler wsHandler, Exception exception) { |
|||
} |
|||
} |
|||
@ -0,0 +1,348 @@ |
|||
package com.ruoyi.websocket.controller; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.messaging.handler.annotation.DestinationVariable; |
|||
import org.springframework.messaging.handler.annotation.MessageMapping; |
|||
import org.springframework.messaging.handler.annotation.Payload; |
|||
import org.springframework.messaging.simp.SimpMessageHeaderAccessor; |
|||
import org.springframework.messaging.simp.SimpMessagingTemplate; |
|||
import org.springframework.stereotype.Controller; |
|||
import com.alibaba.fastjson2.JSON; |
|||
import com.ruoyi.common.core.domain.model.LoginUser; |
|||
import com.ruoyi.websocket.config.LoginUserPrincipal; |
|||
import com.ruoyi.common.utils.StringUtils; |
|||
import com.ruoyi.system.domain.Rooms; |
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
import com.ruoyi.system.service.IRoomsService; |
|||
import com.ruoyi.system.service.IRoomUserProfileService; |
|||
import com.ruoyi.websocket.dto.RoomMemberDTO; |
|||
import com.ruoyi.websocket.service.RoomChatService; |
|||
import com.ruoyi.websocket.service.RoomOnlineMemberBroadcastService; |
|||
import com.ruoyi.websocket.service.RoomWebSocketService; |
|||
|
|||
/** |
|||
* WebSocket 房间消息控制器 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Controller |
|||
public class RoomWebSocketController { |
|||
|
|||
@Autowired |
|||
private SimpMessagingTemplate messagingTemplate; |
|||
|
|||
@Autowired |
|||
private RoomWebSocketService roomWebSocketService; |
|||
|
|||
@Autowired |
|||
private RoomChatService roomChatService; |
|||
|
|||
@Autowired |
|||
private IRoomsService roomsService; |
|||
|
|||
@Autowired |
|||
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService; |
|||
|
|||
@Autowired |
|||
private IRoomUserProfileService roomUserProfileService; |
|||
|
|||
private static final String TYPE_JOIN = "JOIN"; |
|||
private static final String TYPE_LEAVE = "LEAVE"; |
|||
private static final String TYPE_PING = "PING"; |
|||
private static final String TYPE_CHAT = "CHAT"; |
|||
private static final String TYPE_PRIVATE_CHAT = "PRIVATE_CHAT"; |
|||
private static final String TYPE_CHAT_HISTORY = "CHAT_HISTORY"; |
|||
private static final String TYPE_PRIVATE_CHAT_HISTORY = "PRIVATE_CHAT_HISTORY"; |
|||
private static final String TYPE_PRIVATE_CHAT_HISTORY_REQUEST = "PRIVATE_CHAT_HISTORY_REQUEST"; |
|||
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT"; |
|||
private static final String TYPE_PONG = "PONG"; |
|||
private static final String TYPE_SYNC_ROUTE_VISIBILITY = "SYNC_ROUTE_VISIBILITY"; |
|||
private static final String TYPE_SYNC_WAYPOINTS = "SYNC_WAYPOINTS"; |
|||
private static final String TYPE_SYNC_PLATFORM_ICONS = "SYNC_PLATFORM_ICONS"; |
|||
private static final String TYPE_SYNC_ROOM_DRAWINGS = "SYNC_ROOM_DRAWINGS"; |
|||
private static final String TYPE_SYNC_PLATFORM_STYLES = "SYNC_PLATFORM_STYLES"; |
|||
/** 新用户加入时下发的房间状态(可见航线等) */ |
|||
private static final String TYPE_ROOM_STATE = "ROOM_STATE"; |
|||
/** 对象编辑锁定:某成员进入编辑,其他人看到锁定 */ |
|||
private static final String TYPE_OBJECT_EDIT_LOCK = "OBJECT_EDIT_LOCK"; |
|||
/** 对象编辑解锁 */ |
|||
private static final String TYPE_OBJECT_EDIT_UNLOCK = "OBJECT_EDIT_UNLOCK"; |
|||
|
|||
/** |
|||
* 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT、SYNC_* |
|||
* 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT、OBJECT_VIEW、OBJECT_EDIT_LOCK |
|||
*/ |
|||
@MessageMapping("/room/{roomId}") |
|||
public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload, |
|||
SimpMessageHeaderAccessor accessor) { |
|||
LoginUser loginUser = null; |
|||
if (accessor.getUser() instanceof LoginUserPrincipal) { |
|||
loginUser = ((LoginUserPrincipal) accessor.getUser()).getLoginUser(); |
|||
} |
|||
if (loginUser == null) return; |
|||
|
|||
Map<String, Object> body = parsePayload(payload); |
|||
if (body == null) return; |
|||
|
|||
String type = (String) body.get("type"); |
|||
String sessionId = accessor.getSessionId(); |
|||
if (sessionId == null) sessionId = UUID.randomUUID().toString(); |
|||
|
|||
if (TYPE_JOIN.equals(type)) { |
|||
handleJoin(roomId, sessionId, loginUser, body); |
|||
} else if (TYPE_LEAVE.equals(type)) { |
|||
handleLeave(roomId, sessionId, loginUser); |
|||
} else if (TYPE_PING.equals(type)) { |
|||
handlePing(roomId, sessionId, loginUser); |
|||
} else if (TYPE_CHAT.equals(type)) { |
|||
handleChat(roomId, sessionId, loginUser, body); |
|||
} else if (TYPE_PRIVATE_CHAT.equals(type)) { |
|||
handlePrivateChat(roomId, sessionId, loginUser, body); |
|||
} else if (TYPE_PRIVATE_CHAT_HISTORY_REQUEST.equals(type)) { |
|||
handlePrivateChatHistoryRequest(roomId, loginUser, body); |
|||
} else if (TYPE_SYNC_ROUTE_VISIBILITY.equals(type)) { |
|||
handleSyncRouteVisibility(roomId, body, sessionId); |
|||
} else if (TYPE_SYNC_WAYPOINTS.equals(type)) { |
|||
handleSyncWaypoints(roomId, body, sessionId); |
|||
} else if (TYPE_SYNC_PLATFORM_ICONS.equals(type)) { |
|||
handleSyncPlatformIcons(roomId, body, sessionId); |
|||
} else if (TYPE_SYNC_ROOM_DRAWINGS.equals(type)) { |
|||
handleSyncRoomDrawings(roomId, body, sessionId); |
|||
} else if (TYPE_SYNC_PLATFORM_STYLES.equals(type)) { |
|||
handleSyncPlatformStyles(roomId, body, sessionId); |
|||
} else if (TYPE_OBJECT_EDIT_LOCK.equals(type)) { |
|||
handleObjectEditLock(roomId, sessionId, loginUser, body); |
|||
} else if (TYPE_OBJECT_EDIT_UNLOCK.equals(type)) { |
|||
handleObjectEditUnlock(roomId, sessionId, loginUser, body); |
|||
} |
|||
} |
|||
|
|||
/** 广播:某成员锁定某对象进入编辑 */ |
|||
private void handleObjectEditLock(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) { |
|||
String objectType = body != null ? String.valueOf(body.get("objectType")) : null; |
|||
Object objectIdObj = body != null ? body.get("objectId") : null; |
|||
if (objectType == null || objectIdObj == null) return; |
|||
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
|
|||
Map<String, Object> editor = new HashMap<>(); |
|||
editor.put("userId", loginUser.getUserId()); |
|||
editor.put("userName", loginUser.getUsername()); |
|||
editor.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName()); |
|||
editor.put("sessionId", sessionId); |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_OBJECT_EDIT_LOCK); |
|||
msg.put("objectType", objectType); |
|||
msg.put("objectId", objectIdObj); |
|||
msg.put("editor", editor); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
/** 广播:某成员解锁某对象(结束编辑) */ |
|||
private void handleObjectEditUnlock(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) { |
|||
String objectType = body != null ? String.valueOf(body.get("objectType")) : null; |
|||
Object objectIdObj = body != null ? body.get("objectId") : null; |
|||
if (objectType == null || objectIdObj == null) return; |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_OBJECT_EDIT_UNLOCK); |
|||
msg.put("objectType", objectType); |
|||
msg.put("objectId", objectIdObj); |
|||
msg.put("sessionId", sessionId); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
private Map<String, Object> parsePayload(String payload) { |
|||
try { |
|||
return JSON.parseObject(payload, Map.class); |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private void handleJoin(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) { |
|||
Rooms room = roomsService.selectRoomsById(roomId); |
|||
if (room == null) return; |
|||
|
|||
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, body); |
|||
roomWebSocketService.joinRoom(roomId, sessionId, member); |
|||
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId); |
|||
|
|||
List<Object> chatHistory = roomChatService.getGroupChatHistory(roomId); |
|||
Map<String, Object> chatHistoryMsg = new HashMap<>(); |
|||
chatHistoryMsg.put("type", TYPE_CHAT_HISTORY); |
|||
chatHistoryMsg.put("messages", chatHistory); |
|||
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg); |
|||
|
|||
// 小房间内每人展示各自内容,新加入用户不同步他人的可见航线
|
|||
} |
|||
|
|||
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) { |
|||
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, null); |
|||
roomWebSocketService.leaveRoom(roomId, sessionId, loginUser.getUserId()); |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_MEMBER_LEFT); |
|||
msg.put("member", member); |
|||
msg.put("sessionId", sessionId); |
|||
|
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId); |
|||
} |
|||
|
|||
private void handlePing(Long roomId, String sessionId, LoginUser loginUser) { |
|||
roomWebSocketService.refreshSessionHeartbeat(sessionId); |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_PONG); |
|||
|
|||
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg); |
|||
} |
|||
|
|||
/** 群聊:广播给房间内所有人 */ |
|||
private void handleChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) { |
|||
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : ""; |
|||
if (content.isEmpty()) return; |
|||
|
|||
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
Map<String, Object> sender = new HashMap<>(); |
|||
sender.put("userId", loginUser.getUserId()); |
|||
sender.put("userName", loginUser.getUsername()); |
|||
sender.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName()); |
|||
sender.put("avatar", profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar()); |
|||
sender.put("sessionId", sessionId); |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_CHAT); |
|||
msg.put("sender", sender); |
|||
msg.put("content", content); |
|||
msg.put("timestamp", System.currentTimeMillis()); |
|||
|
|||
roomChatService.saveGroupChat(roomId, loginUser.getUserId(), msg); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
/** 私聊:发送给指定用户(仅限同房间成员) */ |
|||
private void handlePrivateChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) { |
|||
Object targetUserNameObj = body != null ? body.get("targetUserName") : null; |
|||
Object targetUserIdObj = body != null ? body.get("targetUserId") : null; |
|||
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : ""; |
|||
if (targetUserNameObj == null || content.isEmpty()) return; |
|||
|
|||
String targetUserName = String.valueOf(targetUserNameObj); |
|||
if (targetUserName.equals(loginUser.getUsername())) return; |
|||
|
|||
List<RoomMemberDTO> members = roomOnlineMemberBroadcastService.aggregateMembersForView(roomId); |
|||
boolean targetInRoom = members.stream().anyMatch(m -> targetUserName.equals(m.getUserName())); |
|||
if (!targetInRoom) return; |
|||
|
|||
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
Map<String, Object> sender = new HashMap<>(); |
|||
sender.put("userId", loginUser.getUserId()); |
|||
sender.put("userName", loginUser.getUsername()); |
|||
sender.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName()); |
|||
sender.put("avatar", profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar()); |
|||
sender.put("sessionId", sessionId); |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_PRIVATE_CHAT); |
|||
msg.put("sender", sender); |
|||
msg.put("targetUserId", targetUserIdObj); |
|||
msg.put("targetUserName", targetUserName); |
|||
msg.put("content", content); |
|||
msg.put("timestamp", System.currentTimeMillis()); |
|||
|
|||
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null; |
|||
if (targetUserId != null) { |
|||
roomChatService.savePrivateChat(loginUser.getUserId(), targetUserId, msg); |
|||
} |
|||
messagingTemplate.convertAndSendToUser(targetUserName, "/queue/private", msg); |
|||
} |
|||
|
|||
/** 私聊历史请求:返回与指定用户的聊天记录 */ |
|||
private void handlePrivateChatHistoryRequest(Long roomId, LoginUser loginUser, Map<String, Object> body) { |
|||
Object targetUserIdObj = body != null ? body.get("targetUserId") : null; |
|||
if (targetUserIdObj == null) return; |
|||
|
|||
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null; |
|||
if (targetUserId == null) return; |
|||
|
|||
List<RoomMemberDTO> members = roomOnlineMemberBroadcastService.aggregateMembersForView(roomId); |
|||
boolean targetInRoom = members.stream().anyMatch(m -> targetUserId.equals(m.getUserId())); |
|||
if (!targetInRoom) return; |
|||
|
|||
List<Object> history = roomChatService.getPrivateChatHistory(loginUser.getUserId(), targetUserId); |
|||
Map<String, Object> resp = new HashMap<>(); |
|||
resp.put("type", TYPE_PRIVATE_CHAT_HISTORY); |
|||
resp.put("targetUserId", targetUserId); |
|||
resp.put("messages", history); |
|||
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp); |
|||
} |
|||
|
|||
/** 航线显隐变更:小房间内每人展示各自内容,不再广播和持久化 */ |
|||
private void handleSyncRouteVisibility(Long roomId, Map<String, Object> body, String sessionId) { |
|||
// 小房间内每人展示各自内容,航线显隐不再同步给他人、不再持久化
|
|||
} |
|||
|
|||
/** 广播航点变更,供其他设备实时同步 */ |
|||
private void handleSyncWaypoints(Long roomId, Map<String, Object> body, String sessionId) { |
|||
if (body == null || !body.containsKey("routeId")) return; |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_SYNC_WAYPOINTS); |
|||
msg.put("routeId", body.get("routeId")); |
|||
msg.put("senderSessionId", sessionId); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
/** 广播平台图标变更,供其他设备实时同步 */ |
|||
private void handleSyncPlatformIcons(Long roomId, Map<String, Object> body, String sessionId) { |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_SYNC_PLATFORM_ICONS); |
|||
msg.put("senderSessionId", sessionId); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
/** 广播空域图形变更,供其他设备实时同步 */ |
|||
private void handleSyncRoomDrawings(Long roomId, Map<String, Object> body, String sessionId) { |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_SYNC_ROOM_DRAWINGS); |
|||
msg.put("senderSessionId", sessionId); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
/** 广播探测区/威力区样式变更,供其他设备实时同步 */ |
|||
private void handleSyncPlatformStyles(Long roomId, Map<String, Object> body, String sessionId) { |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_SYNC_PLATFORM_STYLES); |
|||
msg.put("senderSessionId", sessionId); |
|||
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); |
|||
} |
|||
|
|||
private RoomMemberDTO buildMember(LoginUser loginUser, String sessionId, Long roomId, Map<String, Object> body) { |
|||
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); |
|||
RoomMemberDTO dto = new RoomMemberDTO(); |
|||
dto.setUserId(loginUser.getUserId()); |
|||
dto.setUserName(loginUser.getUsername()); |
|||
dto.setNickName(profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName()); |
|||
dto.setAvatar(profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar()); |
|||
dto.setRoomId(roomId); |
|||
dto.setSessionId(sessionId); |
|||
dto.setDeviceId(body != null && body.containsKey("deviceId") ? String.valueOf(body.get("deviceId")) : "default"); |
|||
dto.setJoinedAt(System.currentTimeMillis()); |
|||
|
|||
Rooms room = roomsService.selectRoomsById(roomId); |
|||
if (room != null && loginUser.getUserId().equals(room.getOwnerId())) { |
|||
dto.setRole("owner"); |
|||
} else if (StringUtils.isNotEmpty(loginUser.getUser().getUserLevel())) { |
|||
dto.setRole(loginUser.getUser().getUserLevel()); |
|||
} else { |
|||
dto.setRole("member"); |
|||
} |
|||
return dto; |
|||
} |
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
package com.ruoyi.websocket.dto; |
|||
|
|||
import java.io.Serializable; |
|||
|
|||
/** |
|||
* WebSocket 房间成员信息 DTO(仅用于 Redis 序列化,非数据库实体) |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public class RoomMemberDTO implements Serializable { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** 用户ID */ |
|||
private Long userId; |
|||
/** |
|||
* 该成员会话所属的房间ID(用于前端按“当前房间”统计在线人数)。 |
|||
* 大房间聚合下发给子房间时,仍保留成员的原始房间来源。 |
|||
*/ |
|||
private Long roomId; |
|||
/** 用户账号 */ |
|||
private String userName; |
|||
/** 用户昵称 */ |
|||
private String nickName; |
|||
/** 头像地址 */ |
|||
private String avatar; |
|||
/** WebSocket 会话ID */ |
|||
private String sessionId; |
|||
/** 设备标识 */ |
|||
private String deviceId; |
|||
/** 加入时间戳 */ |
|||
private Long joinedAt; |
|||
/** 角色:owner=房主, admin=管理员, member=成员 */ |
|||
private String role; |
|||
|
|||
public Long getUserId() { |
|||
return userId; |
|||
} |
|||
|
|||
public void setUserId(Long userId) { |
|||
this.userId = userId; |
|||
} |
|||
|
|||
public Long getRoomId() { |
|||
return roomId; |
|||
} |
|||
|
|||
public void setRoomId(Long roomId) { |
|||
this.roomId = roomId; |
|||
} |
|||
|
|||
public String getUserName() { |
|||
return userName; |
|||
} |
|||
|
|||
public void setUserName(String userName) { |
|||
this.userName = userName; |
|||
} |
|||
|
|||
public String getNickName() { |
|||
return nickName; |
|||
} |
|||
|
|||
public void setNickName(String nickName) { |
|||
this.nickName = nickName; |
|||
} |
|||
|
|||
public String getAvatar() { |
|||
return avatar; |
|||
} |
|||
|
|||
public void setAvatar(String avatar) { |
|||
this.avatar = avatar; |
|||
} |
|||
|
|||
public String getSessionId() { |
|||
return sessionId; |
|||
} |
|||
|
|||
public void setSessionId(String sessionId) { |
|||
this.sessionId = sessionId; |
|||
} |
|||
|
|||
public String getDeviceId() { |
|||
return deviceId; |
|||
} |
|||
|
|||
public void setDeviceId(String deviceId) { |
|||
this.deviceId = deviceId; |
|||
} |
|||
|
|||
public Long getJoinedAt() { |
|||
return joinedAt; |
|||
} |
|||
|
|||
public void setJoinedAt(Long joinedAt) { |
|||
this.joinedAt = joinedAt; |
|||
} |
|||
|
|||
public String getRole() { |
|||
return role; |
|||
} |
|||
|
|||
public void setRole(String role) { |
|||
this.role = role; |
|||
} |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package com.ruoyi.websocket.dto; |
|||
|
|||
import java.io.Serializable; |
|||
|
|||
/** |
|||
* WebSocket 会话与房间关联信息(用于断开连接时清理) |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public class RoomSessionInfo implements Serializable { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** 房间ID */ |
|||
private Long roomId; |
|||
/** 成员信息 */ |
|||
private RoomMemberDTO member; |
|||
|
|||
public Long getRoomId() { |
|||
return roomId; |
|||
} |
|||
|
|||
public void setRoomId(Long roomId) { |
|||
this.roomId = roomId; |
|||
} |
|||
|
|||
public RoomMemberDTO getMember() { |
|||
return member; |
|||
} |
|||
|
|||
public void setMember(RoomMemberDTO member) { |
|||
this.member = member; |
|||
} |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
package com.ruoyi.websocket.listener; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.ApplicationListener; |
|||
import org.springframework.messaging.simp.SimpMessagingTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.socket.messaging.SessionDisconnectEvent; |
|||
import com.ruoyi.websocket.dto.RoomSessionInfo; |
|||
import com.ruoyi.websocket.service.RoomOnlineMemberBroadcastService; |
|||
import com.ruoyi.websocket.service.RoomWebSocketService; |
|||
|
|||
/** |
|||
* WebSocket 断开连接监听器 |
|||
* 当客户端断开(刷新、关闭标签页、网络异常等)时,自动从房间移除该会话,避免同一账号重复出现在在线列表中 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Component |
|||
public class WebSocketDisconnectListener implements ApplicationListener<SessionDisconnectEvent> { |
|||
|
|||
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT"; |
|||
|
|||
@Autowired |
|||
private RoomWebSocketService roomWebSocketService; |
|||
|
|||
@Autowired |
|||
private SimpMessagingTemplate messagingTemplate; |
|||
|
|||
@Autowired |
|||
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService; |
|||
|
|||
@Override |
|||
public void onApplicationEvent(SessionDisconnectEvent event) { |
|||
String sessionId = event.getSessionId(); |
|||
if (sessionId == null) return; |
|||
|
|||
RoomSessionInfo info = roomWebSocketService.leaveBySessionId(sessionId); |
|||
if (info == null) return; |
|||
|
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_MEMBER_LEFT); |
|||
msg.put("member", info.getMember()); |
|||
msg.put("sessionId", sessionId); |
|||
|
|||
String topic = "/topic/room/" + info.getRoomId(); |
|||
messagingTemplate.convertAndSend(topic, msg); |
|||
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(info.getRoomId()); |
|||
} |
|||
} |
|||
@ -0,0 +1,109 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import org.springframework.data.redis.core.ZSetOperations; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import com.alibaba.fastjson2.JSON; |
|||
|
|||
/** |
|||
* 房间聊天 Redis 持久化服务 |
|||
* 使用 Sorted Set 存储,score 为时间戳,超过 30 天自动清理 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Service |
|||
public class RoomChatService { |
|||
|
|||
private static final String ROOM_CHAT_PREFIX = "room:"; |
|||
private static final String ROOM_CHAT_USER_SUFFIX = ":user:"; |
|||
private static final String ROOM_CHAT_SUFFIX = ":chat"; |
|||
private static final String ROOM_CHAT_USERS_SUFFIX = ":chat:users"; |
|||
private static final String PRIVATE_CHAT_PREFIX = "chat:private:"; |
|||
private static final long EXPIRE_DAYS = 30; |
|||
private static final long EXPIRE_MS = EXPIRE_DAYS * 24 * 60 * 60 * 1000L; |
|||
|
|||
@Autowired |
|||
@Qualifier("stringObjectRedisTemplate") |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private String roomChatKey(Long roomId, Long userId) { |
|||
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USER_SUFFIX + userId + ROOM_CHAT_SUFFIX; |
|||
} |
|||
|
|||
private String roomChatUsersKey(Long roomId) { |
|||
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USERS_SUFFIX; |
|||
} |
|||
|
|||
private String privateChatKey(Long userId1, Long userId2) { |
|||
long a = userId1 != null ? userId1 : 0; |
|||
long b = userId2 != null ? userId2 : 0; |
|||
return PRIVATE_CHAT_PREFIX + Math.min(a, b) + ":" + Math.max(a, b); |
|||
} |
|||
|
|||
/** |
|||
* 保存群聊消息(按 roomId + userId 分 key 存储) |
|||
*/ |
|||
public void saveGroupChat(Long roomId, Long senderUserId, Object msg) { |
|||
if (senderUserId == null) return; |
|||
String key = roomChatKey(roomId, senderUserId); |
|||
long now = System.currentTimeMillis(); |
|||
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg); |
|||
redisTemplate.opsForZSet().add(key, member, now); |
|||
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS); |
|||
redisTemplate.opsForSet().add(roomChatUsersKey(roomId), String.valueOf(senderUserId)); |
|||
} |
|||
|
|||
/** |
|||
* 获取群聊历史(最近 30 天内,按时间升序,合并所有用户) |
|||
*/ |
|||
public List<Object> getGroupChatHistory(Long roomId) { |
|||
String usersKey = roomChatUsersKey(roomId); |
|||
Set<Object> userIds = redisTemplate.opsForSet().members(usersKey); |
|||
if (userIds == null || userIds.isEmpty()) return new ArrayList<>(); |
|||
|
|||
long min = System.currentTimeMillis() - EXPIRE_MS; |
|||
List<Object[]> merged = new ArrayList<>(); |
|||
for (Object uid : userIds) { |
|||
String key = roomChatKey(roomId, Long.valueOf(String.valueOf(uid))); |
|||
Set<ZSetOperations.TypedTuple<Object>> tuples = |
|||
redisTemplate.opsForZSet().rangeByScoreWithScores(key, min, Double.POSITIVE_INFINITY); |
|||
if (tuples != null) { |
|||
for (ZSetOperations.TypedTuple<Object> t : tuples) { |
|||
merged.add(new Object[] { t.getValue(), t.getScore() != null ? t.getScore() : 0.0 }); |
|||
} |
|||
} |
|||
} |
|||
merged.sort((a, b) -> Double.compare((Double) a[1], (Double) b[1])); |
|||
List<Object> result = new ArrayList<>(); |
|||
for (Object[] arr : merged) { |
|||
result.add(arr[0]); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
/** |
|||
* 保存私聊消息 |
|||
*/ |
|||
public void savePrivateChat(Long userId1, Long userId2, Object msg) { |
|||
String key = privateChatKey(userId1, userId2); |
|||
long now = System.currentTimeMillis(); |
|||
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg); |
|||
redisTemplate.opsForZSet().add(key, member, now); |
|||
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS); |
|||
} |
|||
|
|||
/** |
|||
* 获取私聊历史(最近 30 天内,按时间升序) |
|||
*/ |
|||
public List<Object> getPrivateChatHistory(Long userId1, Long userId2) { |
|||
String key = privateChatKey(userId1, userId2); |
|||
long min = System.currentTimeMillis() - EXPIRE_MS; |
|||
Set<Object> set = redisTemplate.opsForZSet().rangeByScore(key, min, Double.POSITIVE_INFINITY); |
|||
return set != null ? new ArrayList<>(set) : new ArrayList<>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.messaging.simp.SimpMessagingTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import com.ruoyi.system.domain.Rooms; |
|||
import com.ruoyi.system.service.IRoomsService; |
|||
import com.ruoyi.websocket.dto.RoomMemberDTO; |
|||
|
|||
/** |
|||
* 大房间与子房间在线成员联动:子房间展示本房+父房间;大房间展示本房+所有子房间。 |
|||
* 成员增删后向所有相关房间的订阅 topic 广播聚合后的 MEMBER_LIST。 |
|||
*/ |
|||
@Service |
|||
public class RoomOnlineMemberBroadcastService { |
|||
|
|||
private static final String TYPE_MEMBER_LIST = "MEMBER_LIST"; |
|||
|
|||
@Autowired |
|||
private RoomWebSocketService roomWebSocketService; |
|||
|
|||
@Autowired |
|||
private IRoomsService roomsService; |
|||
|
|||
@Autowired |
|||
private SimpMessagingTemplate messagingTemplate; |
|||
|
|||
/** |
|||
* 某「视角房间」下应展示的在线成员(已按 sessionId 去重)。 |
|||
*/ |
|||
public List<RoomMemberDTO> aggregateMembersForView(Long viewRoomId) { |
|||
if (viewRoomId == null) { |
|||
return new ArrayList<>(); |
|||
} |
|||
Rooms room = roomsService.selectRoomsById(viewRoomId); |
|||
if (room == null) { |
|||
return new ArrayList<>(); |
|||
} |
|||
LinkedHashMap<String, RoomMemberDTO> bySession = new LinkedHashMap<>(); |
|||
if (room.getParentId() != null) { |
|||
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId)); |
|||
putAllMembers(bySession, room.getParentId(), roomWebSocketService.getRoomMembers(room.getParentId())); |
|||
} else { |
|||
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId)); |
|||
Rooms query = new Rooms(); |
|||
query.setParentId(viewRoomId); |
|||
List<Rooms> children = roomsService.selectRoomsList(query); |
|||
if (children != null) { |
|||
for (Rooms child : children) { |
|||
if (child != null && child.getId() != null) { |
|||
putAllMembers(bySession, child.getId(), roomWebSocketService.getRoomMembers(child.getId())); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return new ArrayList<>(bySession.values()); |
|||
} |
|||
|
|||
private void putAllMembers(Map<String, RoomMemberDTO> bySession, Long sourceRoomId, List<RoomMemberDTO> list) { |
|||
if (list == null) { |
|||
return; |
|||
} |
|||
for (RoomMemberDTO m : list) { |
|||
if (m != null && m.getSessionId() != null) { |
|||
// 兼容历史数据:老成员可能没有 roomId 字段
|
|||
if (m.getRoomId() == null) { |
|||
m.setRoomId(sourceRoomId); |
|||
} |
|||
bySession.put(m.getSessionId(), m); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 在指定房间发生加入/离开后,刷新该房间、其父房间(若有)或所有子房间(若为大房间)的在线列表推送。 |
|||
*/ |
|||
public void broadcastAfterMembershipChange(Long changedRoomId) { |
|||
if (changedRoomId == null) { |
|||
return; |
|||
} |
|||
Rooms changed = roomsService.selectRoomsById(changedRoomId); |
|||
if (changed == null) { |
|||
return; |
|||
} |
|||
Set<Long> topicRoomIds = new HashSet<>(); |
|||
topicRoomIds.add(changedRoomId); |
|||
if (changed.getParentId() != null) { |
|||
topicRoomIds.add(changed.getParentId()); |
|||
} else { |
|||
Rooms query = new Rooms(); |
|||
query.setParentId(changedRoomId); |
|||
List<Rooms> children = roomsService.selectRoomsList(query); |
|||
if (children != null) { |
|||
for (Rooms child : children) { |
|||
if (child != null && child.getId() != null) { |
|||
topicRoomIds.add(child.getId()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
for (Long tid : topicRoomIds) { |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_MEMBER_LIST); |
|||
msg.put("members", aggregateMembersForView(tid)); |
|||
messagingTemplate.convertAndSend("/topic/room/" + tid, msg); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.HashSet; |
|||
import java.util.Set; |
|||
import java.util.concurrent.TimeUnit; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
/** |
|||
* 房间状态服务:持久化房间内可见航线等状态,供新加入用户同步 |
|||
*/ |
|||
@Service |
|||
public class RoomRoomStateService { |
|||
|
|||
private static final String ROOM_VISIBLE_ROUTES_PREFIX = "room:"; |
|||
private static final String ROOM_VISIBLE_ROUTES_SUFFIX = ":visibleRoutes"; |
|||
private static final int EXPIRE_HOURS = 24; |
|||
|
|||
@Autowired |
|||
@Qualifier("stringObjectRedisTemplate") |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private String visibleRoutesKey(Long roomId) { |
|||
return ROOM_VISIBLE_ROUTES_PREFIX + roomId + ROOM_VISIBLE_ROUTES_SUFFIX; |
|||
} |
|||
|
|||
/** 更新航线可见状态 */ |
|||
public void updateRouteVisibility(Long roomId, Long routeId, boolean visible) { |
|||
if (roomId == null || routeId == null) return; |
|||
String key = visibleRoutesKey(roomId); |
|||
if (visible) { |
|||
redisTemplate.opsForSet().add(key, routeId); |
|||
} else { |
|||
redisTemplate.opsForSet().remove(key, routeId); |
|||
} |
|||
redisTemplate.expire(key, EXPIRE_HOURS, TimeUnit.HOURS); |
|||
} |
|||
|
|||
/** 获取房间内当前可见的航线 ID 集合 */ |
|||
@SuppressWarnings("unchecked") |
|||
public Set<Long> getVisibleRouteIds(Long roomId) { |
|||
if (roomId == null) return new HashSet<>(); |
|||
String key = visibleRoutesKey(roomId); |
|||
Set<Object> raw = redisTemplate.opsForSet().members(key); |
|||
Set<Long> result = new HashSet<>(); |
|||
if (raw != null) { |
|||
for (Object o : raw) { |
|||
if (o instanceof Number) { |
|||
result.add(((Number) o).longValue()); |
|||
} else if (o != null) { |
|||
try { |
|||
result.add(Long.parseLong(o.toString())); |
|||
} catch (NumberFormatException ignored) {} |
|||
} |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
@ -0,0 +1,158 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.concurrent.TimeUnit; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import com.alibaba.fastjson2.JSON; |
|||
import com.ruoyi.common.core.redis.RedisCache; |
|||
import com.ruoyi.websocket.dto.RoomMemberDTO; |
|||
import com.ruoyi.websocket.dto.RoomSessionInfo; |
|||
|
|||
/** |
|||
* WebSocket 房间 Redis 管理服务 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
@Service |
|||
public class RoomWebSocketService { |
|||
|
|||
private static final String ROOM_MEMBERS_PREFIX = "room:"; |
|||
private static final String ROOM_MEMBERS_SUFFIX = ":members"; |
|||
private static final String USER_SESSIONS_PREFIX = "user:"; |
|||
private static final String USER_SESSIONS_SUFFIX = ":sessions"; |
|||
private static final String SESSION_PREFIX = "session:"; |
|||
private static final int SESSION_EXPIRE_MINUTES = 30; |
|||
|
|||
@Autowired |
|||
private RedisCache redisCache; |
|||
|
|||
@Autowired |
|||
@Qualifier("stringObjectRedisTemplate") |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private String roomMembersKey(Long roomId) { |
|||
return ROOM_MEMBERS_PREFIX + roomId + ROOM_MEMBERS_SUFFIX; |
|||
} |
|||
|
|||
private String userSessionsKey(Long userId) { |
|||
return USER_SESSIONS_PREFIX + userId + USER_SESSIONS_SUFFIX; |
|||
} |
|||
|
|||
private String sessionKey(String sessionId) { |
|||
return SESSION_PREFIX + sessionId; |
|||
} |
|||
|
|||
/** |
|||
* 用户加入房间。 |
|||
* 同一用户只保留最新会话,加入前会先移除该用户在房间内的所有旧会话,避免刷新/重连后重复显示。 |
|||
*/ |
|||
public void joinRoom(Long roomId, String sessionId, RoomMemberDTO member) { |
|||
removeStaleSessionsForUser(roomId, member.getUserId(), sessionId); |
|||
|
|||
String key = roomMembersKey(roomId); |
|||
redisCache.setCacheMapValue(key, sessionId, member); |
|||
redisCache.expire(key, 24, TimeUnit.HOURS); |
|||
|
|||
redisTemplate.opsForSet().add(userSessionsKey(member.getUserId()), sessionId); |
|||
|
|||
RoomSessionInfo sessionInfo = new RoomSessionInfo(); |
|||
sessionInfo.setRoomId(roomId); |
|||
sessionInfo.setMember(member); |
|||
redisCache.setCacheObject(sessionKey(sessionId), sessionInfo, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES); |
|||
} |
|||
|
|||
/** |
|||
* 移除同一用户在房间内的旧会话(历史残留,如服务重启前未收到断开事件;前端每次加载生成新 deviceId 导致无法按设备匹配) |
|||
*/ |
|||
private void removeStaleSessionsForUser(Long roomId, Long userId, String currentSessionId) { |
|||
String key = roomMembersKey(roomId); |
|||
Map<String, Object> map = redisCache.getCacheMap(key); |
|||
if (map == null || map.isEmpty()) return; |
|||
|
|||
List<String> toRemove = new ArrayList<>(); |
|||
for (Map.Entry<String, Object> e : map.entrySet()) { |
|||
String sid = e.getKey(); |
|||
if (sid.equals(currentSessionId)) continue; |
|||
Object val = e.getValue(); |
|||
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val |
|||
: JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class); |
|||
if (dto != null && userId.equals(dto.getUserId())) { |
|||
toRemove.add(sid); |
|||
} |
|||
} |
|||
for (String sid : toRemove) { |
|||
Object val = redisCache.getCacheMapValue(key, sid); |
|||
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val |
|||
: (val != null ? JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class) : null); |
|||
leaveRoom(roomId, sid, dto != null ? dto.getUserId() : null); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 用户离开房间 |
|||
*/ |
|||
public void leaveRoom(Long roomId, String sessionId, Long userId) { |
|||
String key = roomMembersKey(roomId); |
|||
redisCache.deleteCacheMapValue(key, sessionId); |
|||
|
|||
if (userId != null) { |
|||
redisTemplate.opsForSet().remove(userSessionsKey(userId), sessionId); |
|||
} |
|||
|
|||
redisCache.deleteObject(sessionKey(sessionId)); |
|||
} |
|||
|
|||
/** |
|||
* 获取房间成员列表 |
|||
*/ |
|||
public List<RoomMemberDTO> getRoomMembers(Long roomId) { |
|||
String key = roomMembersKey(roomId); |
|||
Map<String, Object> map = redisCache.getCacheMap(key); |
|||
List<RoomMemberDTO> list = new ArrayList<>(); |
|||
if (map != null && !map.isEmpty()) { |
|||
for (Object val : map.values()) { |
|||
if (val != null) { |
|||
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val : JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class); |
|||
if (dto != null) list.add(dto); |
|||
} |
|||
} |
|||
} |
|||
return list; |
|||
} |
|||
|
|||
/** |
|||
* 刷新会话心跳(延长过期时间) |
|||
*/ |
|||
public void refreshSessionHeartbeat(String sessionId) { |
|||
String key = sessionKey(sessionId); |
|||
Object val = redisCache.getCacheObject(key); |
|||
if (val != null) { |
|||
redisCache.setCacheObject(key, val, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 根据会话ID离开房间(用于连接断开时清理) |
|||
* |
|||
* @param sessionId 会话ID |
|||
* @return 若该会话在房间中,返回会话信息(含 roomId、member)用于广播;否则返回 null |
|||
*/ |
|||
public RoomSessionInfo leaveBySessionId(String sessionId) { |
|||
Object val = redisCache.getCacheObject(sessionKey(sessionId)); |
|||
if (val == null) return null; |
|||
|
|||
RoomSessionInfo info = val instanceof RoomSessionInfo ? (RoomSessionInfo) val |
|||
: JSON.parseObject(JSON.toJSONString(val), RoomSessionInfo.class); |
|||
if (info == null || info.getRoomId() == null || info.getMember() == null) return null; |
|||
|
|||
Long roomId = info.getRoomId(); |
|||
RoomMemberDTO member = info.getMember(); |
|||
leaveRoom(roomId, sessionId, member.getUserId()); |
|||
return info; |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.TimeUnit; |
|||
import com.alibaba.fastjson2.JSON; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
/** |
|||
* 白板房间服务:按房间维度将白板数据存储于 Redis |
|||
*/ |
|||
@Service |
|||
public class WhiteboardRoomService { |
|||
|
|||
private static final String ROOM_WHITEBOARDS_PREFIX = "room:"; |
|||
private static final String ROOM_WHITEBOARDS_SUFFIX = ":whiteboards"; |
|||
private static final String WHITEBOARD_SCHEME_PLATFORM_STYLE_PREFIX = "whiteboard:scheme:"; |
|||
private static final int EXPIRE_DAYS = 60; |
|||
|
|||
@Autowired |
|||
@Qualifier("stringObjectRedisTemplate") |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private String whiteboardsKey(Long roomId) { |
|||
return ROOM_WHITEBOARDS_PREFIX + roomId + ROOM_WHITEBOARDS_SUFFIX; |
|||
} |
|||
|
|||
private String whiteboardPlatformStyleKey(String schemeId, String platformInstanceId) { |
|||
return WHITEBOARD_SCHEME_PLATFORM_STYLE_PREFIX + schemeId + ":platform:" + platformInstanceId + ":style"; |
|||
} |
|||
|
|||
/** 获取房间下所有白板列表 */ |
|||
@SuppressWarnings("unchecked") |
|||
public List<Object> listWhiteboards(Long roomId) { |
|||
if (roomId == null) return new ArrayList<>(); |
|||
String key = whiteboardsKey(roomId); |
|||
Object raw = redisTemplate.opsForValue().get(key); |
|||
if (raw == null) return new ArrayList<>(); |
|||
if (raw instanceof List) return (List<Object>) raw; |
|||
if (raw instanceof String) { |
|||
try { |
|||
return JSON.parseArray((String) raw); |
|||
} catch (Exception e) { |
|||
return new ArrayList<>(); |
|||
} |
|||
} |
|||
return new ArrayList<>(); |
|||
} |
|||
|
|||
/** 获取单个白板详情 */ |
|||
public Object getWhiteboard(Long roomId, String whiteboardId) { |
|||
List<Object> list = listWhiteboards(roomId); |
|||
for (Object item : list) { |
|||
if (item instanceof java.util.Map) { |
|||
Object id = ((java.util.Map<?, ?>) item).get("id"); |
|||
if (whiteboardId.equals(String.valueOf(id))) return item; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
/** 创建白板 */ |
|||
public Object createWhiteboard(Long roomId, Object whiteboard) { |
|||
if (roomId == null || whiteboard == null) return null; |
|||
List<Object> list = listWhiteboards(roomId); |
|||
String id = UUID.randomUUID().toString(); |
|||
Object wb = whiteboard; |
|||
if (wb instanceof java.util.Map) { |
|||
((java.util.Map<String, Object>) wb).put("id", id); |
|||
if (!((java.util.Map<String, Object>) wb).containsKey("name")) { |
|||
((java.util.Map<String, Object>) wb).put("name", "草稿"); |
|||
} |
|||
if (!((java.util.Map<String, Object>) wb).containsKey("timeBlocks")) { |
|||
((java.util.Map<String, Object>) wb).put("timeBlocks", new ArrayList<String>()); |
|||
} |
|||
if (!((java.util.Map<String, Object>) wb).containsKey("contentByTime")) { |
|||
((java.util.Map<String, Object>) wb).put("contentByTime", new java.util.HashMap<String, Object>()); |
|||
} |
|||
} |
|||
list.add(wb); |
|||
saveWhiteboards(roomId, list); |
|||
return wb; |
|||
} |
|||
|
|||
/** 更新白板 */ |
|||
public boolean updateWhiteboard(Long roomId, String whiteboardId, Object whiteboard) { |
|||
if (roomId == null || whiteboardId == null || whiteboard == null) return false; |
|||
List<Object> list = listWhiteboards(roomId); |
|||
for (int i = 0; i < list.size(); i++) { |
|||
Object item = list.get(i); |
|||
if (item instanceof java.util.Map) { |
|||
Object id = ((java.util.Map<?, ?>) item).get("id"); |
|||
if (whiteboardId.equals(String.valueOf(id))) { |
|||
if (whiteboard instanceof java.util.Map) { |
|||
((java.util.Map<String, Object>) whiteboard).put("id", whiteboardId); |
|||
} |
|||
list.set(i, whiteboard); |
|||
saveWhiteboards(roomId, list); |
|||
return true; |
|||
} |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
/** 删除白板 */ |
|||
public boolean deleteWhiteboard(Long roomId, String whiteboardId) { |
|||
if (roomId == null || whiteboardId == null) return false; |
|||
List<Object> list = listWhiteboards(roomId); |
|||
boolean removed = list.removeIf(item -> { |
|||
if (item instanceof java.util.Map) { |
|||
Object id = ((java.util.Map<?, ?>) item).get("id"); |
|||
return whiteboardId.equals(String.valueOf(id)); |
|||
} |
|||
return false; |
|||
}); |
|||
if (removed) saveWhiteboards(roomId, list); |
|||
return removed; |
|||
} |
|||
|
|||
private void saveWhiteboards(Long roomId, List<Object> list) { |
|||
String key = whiteboardsKey(roomId); |
|||
redisTemplate.opsForValue().set(key, list, EXPIRE_DAYS, TimeUnit.DAYS); |
|||
} |
|||
|
|||
/** 保存白板平台样式(颜色、大小等) */ |
|||
public void saveWhiteboardPlatformStyle(String schemeId, String platformInstanceId, Object style) { |
|||
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty() || style == null) { |
|||
return; |
|||
} |
|||
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId); |
|||
redisTemplate.opsForValue().set(key, style, EXPIRE_DAYS, TimeUnit.DAYS); |
|||
} |
|||
|
|||
/** 获取白板平台样式(颜色、大小等) */ |
|||
public Object getWhiteboardPlatformStyle(String schemeId, String platformInstanceId) { |
|||
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty()) { |
|||
return null; |
|||
} |
|||
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId); |
|||
return redisTemplate.opsForValue().get(key); |
|||
} |
|||
|
|||
/** 删除白板平台样式(平台删除时可清理) */ |
|||
public void deleteWhiteboardPlatformStyle(String schemeId, String platformInstanceId) { |
|||
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty()) { |
|||
return; |
|||
} |
|||
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId); |
|||
redisTemplate.delete(key); |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
package com.ruoyi.common.core.domain.entity; |
|||
|
|||
import javax.validation.constraints.NotBlank; |
|||
import javax.validation.constraints.NotNull; |
|||
import javax.validation.constraints.Size; |
|||
import org.apache.commons.lang3.builder.ToStringBuilder; |
|||
import org.apache.commons.lang3.builder.ToStringStyle; |
|||
|
|||
public class SysTimelineSegment |
|||
{ |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
private Long segmentId; |
|||
|
|||
private Long roomId; |
|||
|
|||
@NotBlank(message = "时间点不能为空") |
|||
@Size(min = 0, max = 8, message = "时间点长度不能超过8个字符") |
|||
private String segmentTime; |
|||
|
|||
@NotBlank(message = "时间段名称不能为空") |
|||
@Size(min = 0, max = 50, message = "时间段名称长度不能超过50个字符") |
|||
private String segmentName; |
|||
|
|||
@Size(max = 500, message = "时间段描述长度不能超过500个字符") |
|||
private String segmentDesc; |
|||
|
|||
public Long getSegmentId() |
|||
{ |
|||
return segmentId; |
|||
} |
|||
|
|||
public void setSegmentId(Long segmentId) |
|||
{ |
|||
this.segmentId = segmentId; |
|||
} |
|||
|
|||
@NotNull(message = "房间ID不能为空") |
|||
public Long getRoomId() |
|||
{ |
|||
return roomId; |
|||
} |
|||
|
|||
public void setRoomId(Long roomId) |
|||
{ |
|||
this.roomId = roomId; |
|||
} |
|||
|
|||
public String getSegmentTime() |
|||
{ |
|||
return segmentTime; |
|||
} |
|||
|
|||
public void setSegmentTime(String segmentTime) |
|||
{ |
|||
this.segmentTime = segmentTime; |
|||
} |
|||
|
|||
public String getSegmentName() |
|||
{ |
|||
return segmentName; |
|||
} |
|||
|
|||
public void setSegmentName(String segmentName) |
|||
{ |
|||
this.segmentName = segmentName; |
|||
} |
|||
|
|||
public String getSegmentDesc() |
|||
{ |
|||
return segmentDesc; |
|||
} |
|||
|
|||
public void setSegmentDesc(String segmentDesc) |
|||
{ |
|||
this.segmentDesc = segmentDesc; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) |
|||
.append("segmentId", getSegmentId()) |
|||
.append("roomId", getRoomId()) |
|||
.append("segmentTime", getSegmentTime()) |
|||
.append("segmentName", getSegmentName()) |
|||
.append("segmentDesc", getSegmentDesc()) |
|||
.toString(); |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
package com.ruoyi.system.domain; |
|||
|
|||
import java.util.Date; |
|||
import com.fasterxml.jackson.annotation.JsonFormat; |
|||
import com.ruoyi.common.core.domain.BaseEntity; |
|||
|
|||
/** |
|||
* 对象级操作日志(航线、航点、平台等,支持回滚) |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public class ObjectOperationLog extends BaseEntity { |
|||
|
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** 操作类型:新增 */ |
|||
public static final int TYPE_INSERT = 1; |
|||
/** 操作类型:修改 */ |
|||
public static final int TYPE_UPDATE = 2; |
|||
/** 操作类型:删除 */ |
|||
public static final int TYPE_DELETE = 3; |
|||
/** 操作类型:选择 */ |
|||
public static final int TYPE_SELECT = 4; |
|||
/** 操作类型:回滚 */ |
|||
public static final int TYPE_ROLLBACK = 5; |
|||
|
|||
/** 对象类型:航线 */ |
|||
public static final String OBJ_ROUTE = "route"; |
|||
/** 对象类型:航点 */ |
|||
public static final String OBJ_WAYPOINT = "waypoint"; |
|||
/** 对象类型:平台 */ |
|||
public static final String OBJ_PLATFORM = "platform"; |
|||
|
|||
/** 主键 */ |
|||
private Long id; |
|||
/** 房间ID */ |
|||
private Long roomId; |
|||
/** 操作人用户ID */ |
|||
private Long operatorId; |
|||
/** 操作人姓名 */ |
|||
private String operatorName; |
|||
/** 操作类型 1新增 2修改 3删除 4选择 */ |
|||
private Integer operationType; |
|||
/** 操作对象类型 route/waypoint/platform */ |
|||
private String objectType; |
|||
/** 业务对象ID */ |
|||
private String objectId; |
|||
/** 对象显示名 */ |
|||
private String objectName; |
|||
/** 详细操作描述 */ |
|||
private String detail; |
|||
/** 操作前快照JSON,用于回滚 */ |
|||
private String snapshotBefore; |
|||
/** 操作后快照JSON */ |
|||
private String snapshotAfter; |
|||
/** 相对时间 K+00:00:00 */ |
|||
private String kTime; |
|||
/** 创建时间 */ |
|||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
|||
private Date createdAt; |
|||
|
|||
public Long getId() { return id; } |
|||
public void setId(Long id) { this.id = id; } |
|||
public Long getRoomId() { return roomId; } |
|||
public void setRoomId(Long roomId) { this.roomId = roomId; } |
|||
public Long getOperatorId() { return operatorId; } |
|||
public void setOperatorId(Long operatorId) { this.operatorId = operatorId; } |
|||
public String getOperatorName() { return operatorName; } |
|||
public void setOperatorName(String operatorName) { this.operatorName = operatorName; } |
|||
public Integer getOperationType() { return operationType; } |
|||
public void setOperationType(Integer operationType) { this.operationType = operationType; } |
|||
public String getObjectType() { return objectType; } |
|||
public void setObjectType(String objectType) { this.objectType = objectType; } |
|||
public String getObjectId() { return objectId; } |
|||
public void setObjectId(String objectId) { this.objectId = objectId; } |
|||
public String getObjectName() { return objectName; } |
|||
public void setObjectName(String objectName) { this.objectName = objectName; } |
|||
public String getDetail() { return detail; } |
|||
public void setDetail(String detail) { this.detail = detail; } |
|||
public String getSnapshotBefore() { return snapshotBefore; } |
|||
public void setSnapshotBefore(String snapshotBefore) { this.snapshotBefore = snapshotBefore; } |
|||
public String getSnapshotAfter() { return snapshotAfter; } |
|||
public void setSnapshotAfter(String snapshotAfter) { this.snapshotAfter = snapshotAfter; } |
|||
public String getkTime() { return kTime; } |
|||
public void setkTime(String kTime) { this.kTime = kTime; } |
|||
public Date getCreatedAt() { return createdAt; } |
|||
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
package com.ruoyi.system.domain; |
|||
|
|||
import com.ruoyi.common.core.domain.BaseEntity; |
|||
|
|||
/** |
|||
* 协同房间用户资料(显示名、头像) |
|||
*/ |
|||
public class RoomUserProfile extends BaseEntity { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
private Long id; |
|||
private Long userId; |
|||
private String displayName; |
|||
private String avatar; |
|||
|
|||
public Long getId() { |
|||
return id; |
|||
} |
|||
|
|||
public void setId(Long id) { |
|||
this.id = id; |
|||
} |
|||
|
|||
public Long getUserId() { |
|||
return userId; |
|||
} |
|||
|
|||
public void setUserId(Long userId) { |
|||
this.userId = userId; |
|||
} |
|||
|
|||
public String getDisplayName() { |
|||
return displayName; |
|||
} |
|||
|
|||
public void setDisplayName(String displayName) { |
|||
this.displayName = displayName; |
|||
} |
|||
|
|||
public String getAvatar() { |
|||
return avatar; |
|||
} |
|||
|
|||
public void setAvatar(String avatar) { |
|||
this.avatar = avatar; |
|||
} |
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
package com.ruoyi.system.domain; |
|||
|
|||
import java.io.Serializable; |
|||
|
|||
/** |
|||
* 航点显示样式(地图上名称字号/颜色、标记大小/颜色),对应表字段 display_style 的 JSON 结构。 |
|||
*/ |
|||
public class WaypointDisplayStyle implements Serializable { |
|||
|
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** 航点名称在地图上的字号,默认 16 */ |
|||
private Integer labelFontSize; |
|||
/** 航点名称在地图上的颜色,默认 #000000 */ |
|||
private String labelColor; |
|||
/** 航点圆点填充色,默认 #ffffff */ |
|||
private String color; |
|||
/** 航点圆点直径(像素),默认 12 */ |
|||
private Integer pixelSize; |
|||
/** 航点圆点边框颜色,默认 #000000 */ |
|||
private String outlineColor; |
|||
/** 航段模式:fixed_speed-定速(移动下一航点改下一航点相对K时), fixed_time-定时(移动本航点改上一航点速度), 空或null-普通 */ |
|||
private String segmentMode; |
|||
/** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时(分),与相对K时分离后可支持“定时到达+盘旋至相对K时出发” */ |
|||
private Double segmentTargetMinutes; |
|||
/** 定速目标(km/h):fixed_speed 时表示本航段使用的固定速度,用于重算下一航点相对K时 */ |
|||
private Double segmentTargetSpeed; |
|||
|
|||
public Integer getLabelFontSize() { |
|||
return labelFontSize; |
|||
} |
|||
|
|||
public void setLabelFontSize(Integer labelFontSize) { |
|||
this.labelFontSize = labelFontSize; |
|||
} |
|||
|
|||
public String getLabelColor() { |
|||
return labelColor; |
|||
} |
|||
|
|||
public void setLabelColor(String labelColor) { |
|||
this.labelColor = labelColor; |
|||
} |
|||
|
|||
public String getColor() { |
|||
return color; |
|||
} |
|||
|
|||
public void setColor(String color) { |
|||
this.color = color; |
|||
} |
|||
|
|||
public Integer getPixelSize() { |
|||
return pixelSize; |
|||
} |
|||
|
|||
public void setPixelSize(Integer pixelSize) { |
|||
this.pixelSize = pixelSize; |
|||
} |
|||
|
|||
public String getOutlineColor() { |
|||
return outlineColor; |
|||
} |
|||
|
|||
public void setOutlineColor(String outlineColor) { |
|||
this.outlineColor = outlineColor; |
|||
} |
|||
|
|||
public String getSegmentMode() { |
|||
return segmentMode; |
|||
} |
|||
|
|||
public void setSegmentMode(String segmentMode) { |
|||
this.segmentMode = segmentMode; |
|||
} |
|||
|
|||
public Double getSegmentTargetMinutes() { |
|||
return segmentTargetMinutes; |
|||
} |
|||
|
|||
public void setSegmentTargetMinutes(Double segmentTargetMinutes) { |
|||
this.segmentTargetMinutes = segmentTargetMinutes; |
|||
} |
|||
|
|||
public Double getSegmentTargetSpeed() { |
|||
return segmentTargetSpeed; |
|||
} |
|||
|
|||
public void setSegmentTargetSpeed(Double segmentTargetSpeed) { |
|||
this.segmentTargetSpeed = segmentTargetSpeed; |
|||
} |
|||
} |
|||
@ -0,0 +1,348 @@ |
|||
package com.ruoyi.system.domain.dto; |
|||
|
|||
import java.io.Serializable; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 平台样式 DTO |
|||
*/ |
|||
public class PlatformStyleDTO implements Serializable { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
private String roomId; |
|||
private Long routeId; |
|||
private Long platformId; |
|||
|
|||
/** 独立平台图标实例 ID(拖到地图上的每条记录的 id,非平台类型 id)。routeId=0 时用此字段作 Redis hash field,实现每个实例独立样式 */ |
|||
private Long platformIconInstanceId; |
|||
|
|||
/** 平台名称 */ |
|||
private String platformName; |
|||
|
|||
/** 标牌字体大小 */ |
|||
private Integer labelFontSize; |
|||
|
|||
/** 标牌字体颜色 */ |
|||
private String labelFontColor; |
|||
|
|||
/** 平台大小 */ |
|||
private Integer platformSize; |
|||
|
|||
/** 平台颜色 */ |
|||
private String platformColor; |
|||
|
|||
/** 多探测区配置(支持叠加与独立显隐) */ |
|||
private List<DetectionZoneDTO> detectionZones; |
|||
|
|||
/** 多威力区配置(支持叠加与独立显隐) */ |
|||
private List<PowerZoneDTO> powerZones; |
|||
|
|||
/** 探测区半径(千米),整圆 */ |
|||
private Double detectionZoneRadius; |
|||
/** 探测区填充颜色 */ |
|||
private String detectionZoneColor; |
|||
/** 探测区透明度 0-1 */ |
|||
private Double detectionZoneOpacity; |
|||
/** 探测区是否在地图上显示 */ |
|||
private Boolean detectionZoneVisible; |
|||
|
|||
/** 威力区半径(千米) */ |
|||
private Double powerZoneRadius; |
|||
/** 威力区扇形夹角(度),如 60 表示 60 度扇形 */ |
|||
private Double powerZoneAngle; |
|||
/** 威力区填充颜色 */ |
|||
private String powerZoneColor; |
|||
/** 威力区透明度 0-1 */ |
|||
private Double powerZoneOpacity; |
|||
/** 威力区是否在地图上显示 */ |
|||
private Boolean powerZoneVisible; |
|||
|
|||
public static class DetectionZoneDTO implements Serializable { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** zone 唯一 id(前端生成,后端原样保存) */ |
|||
private String zoneId; |
|||
|
|||
/** 探测区半径(千米) */ |
|||
private Double radiusKm; |
|||
|
|||
/** 探测区填充颜色(css rgba 或 hex) */ |
|||
private String color; |
|||
|
|||
/** 探测区透明度 0-1 */ |
|||
private Double opacity; |
|||
|
|||
/** 是否在地图上显示 */ |
|||
private Boolean visible; |
|||
|
|||
public String getZoneId() { |
|||
return zoneId; |
|||
} |
|||
|
|||
public void setZoneId(String zoneId) { |
|||
this.zoneId = zoneId; |
|||
} |
|||
|
|||
public Double getRadiusKm() { |
|||
return radiusKm; |
|||
} |
|||
|
|||
public void setRadiusKm(Double radiusKm) { |
|||
this.radiusKm = radiusKm; |
|||
} |
|||
|
|||
public String getColor() { |
|||
return color; |
|||
} |
|||
|
|||
public void setColor(String color) { |
|||
this.color = color; |
|||
} |
|||
|
|||
public Double getOpacity() { |
|||
return opacity; |
|||
} |
|||
|
|||
public void setOpacity(Double opacity) { |
|||
this.opacity = opacity; |
|||
} |
|||
|
|||
public Boolean getVisible() { |
|||
return visible; |
|||
} |
|||
|
|||
public void setVisible(Boolean visible) { |
|||
this.visible = visible; |
|||
} |
|||
} |
|||
|
|||
public static class PowerZoneDTO implements Serializable { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** zone 唯一 id(前端生成,后端原样保存) */ |
|||
private String zoneId; |
|||
|
|||
/** 威力区半径(千米) */ |
|||
private Double radiusKm; |
|||
|
|||
/** 威力区扇形夹角(度) */ |
|||
private Double angleDeg; |
|||
|
|||
/** 威力区填充颜色(css rgba 或 hex) */ |
|||
private String color; |
|||
|
|||
/** 威力区透明度 0-1 */ |
|||
private Double opacity; |
|||
|
|||
/** 是否在地图上显示 */ |
|||
private Boolean visible; |
|||
|
|||
public String getZoneId() { |
|||
return zoneId; |
|||
} |
|||
|
|||
public void setZoneId(String zoneId) { |
|||
this.zoneId = zoneId; |
|||
} |
|||
|
|||
public Double getRadiusKm() { |
|||
return radiusKm; |
|||
} |
|||
|
|||
public void setRadiusKm(Double radiusKm) { |
|||
this.radiusKm = radiusKm; |
|||
} |
|||
|
|||
public Double getAngleDeg() { |
|||
return angleDeg; |
|||
} |
|||
|
|||
public void setAngleDeg(Double angleDeg) { |
|||
this.angleDeg = angleDeg; |
|||
} |
|||
|
|||
public String getColor() { |
|||
return color; |
|||
} |
|||
|
|||
public void setColor(String color) { |
|||
this.color = color; |
|||
} |
|||
|
|||
public Double getOpacity() { |
|||
return opacity; |
|||
} |
|||
|
|||
public void setOpacity(Double opacity) { |
|||
this.opacity = opacity; |
|||
} |
|||
|
|||
public Boolean getVisible() { |
|||
return visible; |
|||
} |
|||
|
|||
public void setVisible(Boolean visible) { |
|||
this.visible = visible; |
|||
} |
|||
} |
|||
|
|||
public List<DetectionZoneDTO> getDetectionZones() { |
|||
return detectionZones; |
|||
} |
|||
|
|||
public void setDetectionZones(List<DetectionZoneDTO> detectionZones) { |
|||
this.detectionZones = detectionZones; |
|||
} |
|||
|
|||
public List<PowerZoneDTO> getPowerZones() { |
|||
return powerZones; |
|||
} |
|||
|
|||
public void setPowerZones(List<PowerZoneDTO> powerZones) { |
|||
this.powerZones = powerZones; |
|||
} |
|||
|
|||
public String getRoomId() { |
|||
return roomId; |
|||
} |
|||
|
|||
public void setRoomId(String roomId) { |
|||
this.roomId = roomId; |
|||
} |
|||
|
|||
public Long getRouteId() { |
|||
return routeId; |
|||
} |
|||
|
|||
public void setRouteId(Long routeId) { |
|||
this.routeId = routeId; |
|||
} |
|||
|
|||
public Long getPlatformId() { |
|||
return platformId; |
|||
} |
|||
|
|||
public void setPlatformId(Long platformId) { |
|||
this.platformId = platformId; |
|||
} |
|||
|
|||
public Long getPlatformIconInstanceId() { |
|||
return platformIconInstanceId; |
|||
} |
|||
|
|||
public void setPlatformIconInstanceId(Long platformIconInstanceId) { |
|||
this.platformIconInstanceId = platformIconInstanceId; |
|||
} |
|||
|
|||
public String getPlatformName() { |
|||
return platformName; |
|||
} |
|||
|
|||
public void setPlatformName(String platformName) { |
|||
this.platformName = platformName; |
|||
} |
|||
|
|||
public Integer getLabelFontSize() { |
|||
return labelFontSize; |
|||
} |
|||
|
|||
public void setLabelFontSize(Integer labelFontSize) { |
|||
this.labelFontSize = labelFontSize; |
|||
} |
|||
|
|||
public String getLabelFontColor() { |
|||
return labelFontColor; |
|||
} |
|||
|
|||
public void setLabelFontColor(String labelFontColor) { |
|||
this.labelFontColor = labelFontColor; |
|||
} |
|||
|
|||
public Integer getPlatformSize() { |
|||
return platformSize; |
|||
} |
|||
|
|||
public void setPlatformSize(Integer platformSize) { |
|||
this.platformSize = platformSize; |
|||
} |
|||
|
|||
public String getPlatformColor() { |
|||
return platformColor; |
|||
} |
|||
|
|||
public void setPlatformColor(String platformColor) { |
|||
this.platformColor = platformColor; |
|||
} |
|||
|
|||
public Double getPowerZoneRadius() { |
|||
return powerZoneRadius; |
|||
} |
|||
|
|||
public void setPowerZoneRadius(Double powerZoneRadius) { |
|||
this.powerZoneRadius = powerZoneRadius; |
|||
} |
|||
|
|||
public String getPowerZoneColor() { |
|||
return powerZoneColor; |
|||
} |
|||
|
|||
public void setPowerZoneColor(String powerZoneColor) { |
|||
this.powerZoneColor = powerZoneColor; |
|||
} |
|||
|
|||
public Double getDetectionZoneRadius() { |
|||
return detectionZoneRadius; |
|||
} |
|||
|
|||
public void setDetectionZoneRadius(Double detectionZoneRadius) { |
|||
this.detectionZoneRadius = detectionZoneRadius; |
|||
} |
|||
|
|||
public String getDetectionZoneColor() { |
|||
return detectionZoneColor; |
|||
} |
|||
|
|||
public void setDetectionZoneColor(String detectionZoneColor) { |
|||
this.detectionZoneColor = detectionZoneColor; |
|||
} |
|||
|
|||
public Double getDetectionZoneOpacity() { |
|||
return detectionZoneOpacity; |
|||
} |
|||
|
|||
public void setDetectionZoneOpacity(Double detectionZoneOpacity) { |
|||
this.detectionZoneOpacity = detectionZoneOpacity; |
|||
} |
|||
|
|||
public Boolean getDetectionZoneVisible() { |
|||
return detectionZoneVisible; |
|||
} |
|||
|
|||
public void setDetectionZoneVisible(Boolean detectionZoneVisible) { |
|||
this.detectionZoneVisible = detectionZoneVisible; |
|||
} |
|||
|
|||
public Double getPowerZoneAngle() { |
|||
return powerZoneAngle; |
|||
} |
|||
|
|||
public void setPowerZoneAngle(Double powerZoneAngle) { |
|||
this.powerZoneAngle = powerZoneAngle; |
|||
} |
|||
|
|||
public Double getPowerZoneOpacity() { |
|||
return powerZoneOpacity; |
|||
} |
|||
|
|||
public void setPowerZoneOpacity(Double powerZoneOpacity) { |
|||
this.powerZoneOpacity = powerZoneOpacity; |
|||
} |
|||
|
|||
public Boolean getPowerZoneVisible() { |
|||
return powerZoneVisible; |
|||
} |
|||
|
|||
public void setPowerZoneVisible(Boolean powerZoneVisible) { |
|||
this.powerZoneVisible = powerZoneVisible; |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.ruoyi.system.mapper; |
|||
|
|||
import java.util.List; |
|||
import com.ruoyi.system.domain.ObjectOperationLog; |
|||
|
|||
/** |
|||
* 对象级操作日志 Mapper |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public interface ObjectOperationLogMapper { |
|||
|
|||
int insert(ObjectOperationLog log); |
|||
|
|||
ObjectOperationLog selectById(Long id); |
|||
|
|||
List<ObjectOperationLog> selectPage(ObjectOperationLog query); |
|||
|
|||
int deleteById(Long id); |
|||
|
|||
/** 删除某条之后的所有日志(回滚时清理后续日志,可选策略) */ |
|||
int deleteByRoomIdAfterId(Long roomId, Long afterId); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.ruoyi.system.mapper; |
|||
|
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
|
|||
public interface RoomUserProfileMapper { |
|||
|
|||
RoomUserProfile selectByUserId(Long userId); |
|||
|
|||
int insertRoomUserProfile(RoomUserProfile profile); |
|||
|
|||
int updateRoomUserProfile(RoomUserProfile profile); |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package com.ruoyi.system.mapper; |
|||
|
|||
import java.util.List; |
|||
import org.apache.ibatis.annotations.Param; |
|||
import com.ruoyi.common.core.domain.entity.SysTimelineSegment; |
|||
|
|||
public interface SysTimelineSegmentMapper |
|||
{ |
|||
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment); |
|||
|
|||
public List<SysTimelineSegment> selectSegmentListByRoomId(@Param("roomId") Long roomId); |
|||
|
|||
public SysTimelineSegment selectSegmentById(Long segmentId); |
|||
|
|||
public int insertSegment(SysTimelineSegment segment); |
|||
|
|||
public int updateSegment(SysTimelineSegment segment); |
|||
|
|||
public int deleteSegmentById(Long segmentId); |
|||
|
|||
public int deleteSegmentByRoomId(@Param("roomId") Long roomId); |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package com.ruoyi.system.service; |
|||
|
|||
import java.util.List; |
|||
import com.ruoyi.system.domain.ObjectOperationLog; |
|||
|
|||
/** |
|||
* 对象级操作日志 服务接口 |
|||
* |
|||
* @author ruoyi |
|||
*/ |
|||
public interface IObjectOperationLogService { |
|||
|
|||
/** |
|||
* 记录一条操作日志(同时写库并推送到 Redis 缓存) |
|||
*/ |
|||
void saveLog(ObjectOperationLog log); |
|||
|
|||
/** |
|||
* 分页查询(优先从 Redis 取第一页近期数据以减轻数据库压力) |
|||
*/ |
|||
List<ObjectOperationLog> selectPage(ObjectOperationLog query); |
|||
|
|||
/** |
|||
* 根据ID查询(回滚时需要快照) |
|||
*/ |
|||
ObjectOperationLog selectById(Long id); |
|||
|
|||
/** |
|||
* 回滚到指定日志:用 snapshot_before 恢复数据,并同步 Redis 缓存 |
|||
* @return 是否成功 |
|||
*/ |
|||
boolean rollback(Long logId); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.ruoyi.system.service; |
|||
|
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
|
|||
public interface IRoomUserProfileService { |
|||
|
|||
RoomUserProfile getOrInitByUser(Long userId, String defaultUserName); |
|||
|
|||
RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName); |
|||
|
|||
RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName); |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.ruoyi.system.service; |
|||
|
|||
import java.util.List; |
|||
import com.ruoyi.common.core.domain.entity.SysTimelineSegment; |
|||
|
|||
public interface ISysTimelineSegmentService |
|||
{ |
|||
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment); |
|||
|
|||
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId); |
|||
|
|||
public SysTimelineSegment selectSegmentById(Long segmentId); |
|||
|
|||
public int insertSegment(SysTimelineSegment segment); |
|||
|
|||
public int updateSegment(SysTimelineSegment segment); |
|||
|
|||
public int deleteSegmentById(Long segmentId); |
|||
|
|||
public int deleteSegmentByRoomId(Long roomId); |
|||
} |
|||
@ -0,0 +1,219 @@ |
|||
package com.ruoyi.system.service.impl; |
|||
|
|||
import java.util.List; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import com.ruoyi.system.domain.PlatformLib; |
|||
import com.ruoyi.system.domain.RouteWaypoints; |
|||
import com.ruoyi.system.domain.Routes; |
|||
import com.ruoyi.system.mapper.PlatformLibMapper; |
|||
import com.ruoyi.system.mapper.RouteWaypointsMapper; |
|||
import com.ruoyi.system.mapper.RoutesMapper; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import com.alibaba.fastjson2.JSON; |
|||
import com.ruoyi.system.domain.ObjectOperationLog; |
|||
import com.ruoyi.system.mapper.ObjectOperationLogMapper; |
|||
import com.ruoyi.system.service.IObjectOperationLogService; |
|||
import com.ruoyi.system.service.IRouteWaypointsService; |
|||
|
|||
/** |
|||
* 对象级操作日志 服务实现:写库 + Redis 缓存,回滚时同步 DB 与 Redis |
|||
*/ |
|||
@Service |
|||
public class ObjectOperationLogServiceImpl implements IObjectOperationLogService { |
|||
|
|||
private static final String REDIS_KEY_PREFIX = "object_log:room:"; |
|||
private static final int REDIS_LIST_MAX = 500; |
|||
private static final long REDIS_EXPIRE_HOURS = 24; |
|||
|
|||
@Autowired |
|||
private ObjectOperationLogMapper objectOperationLogMapper; |
|||
|
|||
@Autowired |
|||
@Qualifier("stringObjectRedisTemplate") |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private RoutesMapper routesMapper; |
|||
|
|||
@Autowired |
|||
private RouteWaypointsMapper routeWaypointsMapper; |
|||
|
|||
@Autowired |
|||
private IRouteWaypointsService routeWaypointsService; |
|||
|
|||
@Autowired |
|||
private PlatformLibMapper platformLibMapper; |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void saveLog(ObjectOperationLog log) { |
|||
objectOperationLogMapper.insert(log); |
|||
if (log.getRoomId() != null) { |
|||
String key = REDIS_KEY_PREFIX + log.getRoomId(); |
|||
redisTemplate.opsForList().rightPush(key, log); |
|||
redisTemplate.opsForList().trim(key, -REDIS_LIST_MAX, -1); |
|||
redisTemplate.expire(key, REDIS_EXPIRE_HOURS, TimeUnit.HOURS); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public List<ObjectOperationLog> selectPage(ObjectOperationLog query) { |
|||
return objectOperationLogMapper.selectPage(query); |
|||
} |
|||
|
|||
@Override |
|||
public ObjectOperationLog selectById(Long id) { |
|||
return objectOperationLogMapper.selectById(id); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public boolean rollback(Long logId) { |
|||
ObjectOperationLog log = objectOperationLogMapper.selectById(logId); |
|||
if (log == null) return false; |
|||
Integer opType = log.getOperationType(); |
|||
if (opType == null) return false; |
|||
if (ObjectOperationLog.TYPE_INSERT == opType) { |
|||
return rollbackInsert(log); |
|||
} |
|||
if (ObjectOperationLog.TYPE_DELETE == opType) { |
|||
return rollbackDelete(log); |
|||
} |
|||
if (ObjectOperationLog.TYPE_UPDATE == opType && log.getSnapshotBefore() != null && !log.getSnapshotBefore().isEmpty()) { |
|||
return rollbackUpdate(log); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean rollbackUpdate(ObjectOperationLog log) { |
|||
String objectType = log.getObjectType(); |
|||
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteUpdate(log); |
|||
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointUpdate(log); |
|||
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) { |
|||
return rollbackPlatformUpdate(log); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean rollbackDelete(ObjectOperationLog log) { |
|||
if (log.getSnapshotBefore() == null || log.getSnapshotBefore().isEmpty()) return false; |
|||
String objectType = log.getObjectType(); |
|||
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteReinsert(log); |
|||
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointReinsert(log); |
|||
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) { |
|||
return rollbackPlatformReinsert(log); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean rollbackInsert(ObjectOperationLog log) { |
|||
if (log.getSnapshotAfter() == null || log.getSnapshotAfter().isEmpty()) return false; |
|||
String objectType = log.getObjectType(); |
|||
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) { |
|||
Routes r = JSON.parseObject(log.getSnapshotAfter(), Routes.class); |
|||
if (r != null && r.getId() != null) { |
|||
routeWaypointsService.deleteRouteWaypointsByRouteId(r.getId()); |
|||
routesMapper.deleteRoutesById(r.getId()); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
} |
|||
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) { |
|||
RouteWaypoints wp = JSON.parseObject(log.getSnapshotAfter(), RouteWaypoints.class); |
|||
if (wp != null && wp.getId() != null) { |
|||
routeWaypointsMapper.deleteRouteWaypointsById(wp.getId()); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
} |
|||
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) { |
|||
PlatformLib lib = JSON.parseObject(log.getSnapshotAfter(), PlatformLib.class); |
|||
if (lib != null && lib.getId() != null) { |
|||
platformLibMapper.deletePlatformLibById(lib.getId()); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean rollbackRouteUpdate(ObjectOperationLog log) { |
|||
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class); |
|||
if (before == null || before.getId() == null) return false; |
|||
routeWaypointsService.deleteRouteWaypointsByRouteId(before.getId()); |
|||
routesMapper.updateRoutes(before); |
|||
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) { |
|||
for (RouteWaypoints wp : before.getWaypoints()) { |
|||
wp.setRouteId(before.getId()); |
|||
routeWaypointsMapper.insertRouteWaypoints(wp); |
|||
} |
|||
} |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private boolean rollbackWaypointUpdate(ObjectOperationLog log) { |
|||
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class); |
|||
if (before == null || before.getId() == null) return false; |
|||
routeWaypointsMapper.updateRouteWaypoints(before); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private boolean rollbackRouteReinsert(ObjectOperationLog log) { |
|||
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class); |
|||
if (before == null) return false; |
|||
before.setId(null); |
|||
routesMapper.insertRoutes(before); |
|||
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) { |
|||
for (RouteWaypoints wp : before.getWaypoints()) { |
|||
wp.setId(null); |
|||
wp.setRouteId(before.getId()); |
|||
routeWaypointsMapper.insertRouteWaypoints(wp); |
|||
} |
|||
} |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private boolean rollbackWaypointReinsert(ObjectOperationLog log) { |
|||
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class); |
|||
if (before == null) return false; |
|||
before.setId(null); |
|||
routeWaypointsMapper.insertRouteWaypoints(before); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private boolean rollbackPlatformUpdate(ObjectOperationLog log) { |
|||
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class); |
|||
if (before == null || before.getId() == null) return false; |
|||
platformLibMapper.updatePlatformLib(before); |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private boolean rollbackPlatformReinsert(ObjectOperationLog log) { |
|||
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class); |
|||
if (before == null || before.getId() == null) return false; |
|||
// 如果该 ID 已经存在(例如之前已手动恢复过),则视为已回滚成功,避免主键冲突
|
|||
PlatformLib existed = platformLibMapper.selectPlatformLibById(before.getId()); |
|||
if (existed == null) { |
|||
platformLibMapper.insertPlatformLibWithId(before); |
|||
} |
|||
invalidateRoomCache(log.getRoomId()); |
|||
return true; |
|||
} |
|||
|
|||
private void invalidateRoomCache(Long roomId) { |
|||
if (roomId == null) return; |
|||
String key = REDIS_KEY_PREFIX + roomId; |
|||
redisTemplate.delete(key); |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
package com.ruoyi.system.service.impl; |
|||
|
|||
import java.util.Date; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Service; |
|||
import com.ruoyi.common.utils.StringUtils; |
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
import com.ruoyi.system.mapper.RoomUserProfileMapper; |
|||
import com.ruoyi.system.service.IRoomUserProfileService; |
|||
|
|||
@Service |
|||
public class RoomUserProfileServiceImpl implements IRoomUserProfileService { |
|||
|
|||
public static final String DEFAULT_AVATAR = |
|||
"https://cube.elemecdn.com/0/88dd03f9bf287d08f58fbcf58fddbf4a8c6/avatar.png"; |
|||
|
|||
@Autowired |
|||
private RoomUserProfileMapper roomUserProfileMapper; |
|||
|
|||
@Override |
|||
public RoomUserProfile getOrInitByUser(Long userId, String defaultUserName) { |
|||
if (userId == null) { |
|||
return null; |
|||
} |
|||
RoomUserProfile profile = roomUserProfileMapper.selectByUserId(userId); |
|||
if (profile != null) { |
|||
return profile; |
|||
} |
|||
RoomUserProfile created = new RoomUserProfile(); |
|||
created.setUserId(userId); |
|||
created.setDisplayName(StringUtils.isNotEmpty(defaultUserName) ? defaultUserName : "用户" + userId); |
|||
created.setAvatar(DEFAULT_AVATAR); |
|||
Date now = new Date(); |
|||
created.setCreateTime(now); |
|||
created.setUpdateTime(now); |
|||
roomUserProfileMapper.insertRoomUserProfile(created); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
|
|||
@Override |
|||
public RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName) { |
|||
RoomUserProfile current = getOrInitByUser(userId, defaultUserName); |
|||
if (current == null) { |
|||
return null; |
|||
} |
|||
current.setDisplayName(StringUtils.isNotEmpty(displayName) ? displayName : current.getDisplayName()); |
|||
current.setUpdateTime(new Date()); |
|||
roomUserProfileMapper.updateRoomUserProfile(current); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
|
|||
@Override |
|||
public RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName) { |
|||
RoomUserProfile current = getOrInitByUser(userId, defaultUserName); |
|||
if (current == null) { |
|||
return null; |
|||
} |
|||
current.setAvatar(StringUtils.isNotEmpty(avatar) ? avatar : current.getAvatar()); |
|||
current.setUpdateTime(new Date()); |
|||
roomUserProfileMapper.updateRoomUserProfile(current); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
package com.ruoyi.system.service.impl; |
|||
|
|||
import java.util.List; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Service; |
|||
import com.ruoyi.common.core.domain.entity.SysTimelineSegment; |
|||
import com.ruoyi.system.mapper.SysTimelineSegmentMapper; |
|||
import com.ruoyi.system.service.ISysTimelineSegmentService; |
|||
|
|||
@Service |
|||
public class SysTimelineSegmentServiceImpl implements ISysTimelineSegmentService |
|||
{ |
|||
@Autowired |
|||
private SysTimelineSegmentMapper segmentMapper; |
|||
|
|||
@Override |
|||
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment) |
|||
{ |
|||
return segmentMapper.selectSegmentList(segment); |
|||
} |
|||
|
|||
@Override |
|||
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId) |
|||
{ |
|||
return segmentMapper.selectSegmentListByRoomId(roomId); |
|||
} |
|||
|
|||
@Override |
|||
public SysTimelineSegment selectSegmentById(Long segmentId) |
|||
{ |
|||
return segmentMapper.selectSegmentById(segmentId); |
|||
} |
|||
|
|||
@Override |
|||
public int insertSegment(SysTimelineSegment segment) |
|||
{ |
|||
return segmentMapper.insertSegment(segment); |
|||
} |
|||
|
|||
@Override |
|||
public int updateSegment(SysTimelineSegment segment) |
|||
{ |
|||
return segmentMapper.updateSegment(segment); |
|||
} |
|||
|
|||
@Override |
|||
public int deleteSegmentById(Long segmentId) |
|||
{ |
|||
return segmentMapper.deleteSegmentById(segmentId); |
|||
} |
|||
|
|||
@Override |
|||
public int deleteSegmentByRoomId(Long roomId) |
|||
{ |
|||
return segmentMapper.deleteSegmentByRoomId(roomId); |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
package com.ruoyi.system.typehandler; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.ruoyi.system.domain.WaypointDisplayStyle; |
|||
import org.apache.ibatis.type.BaseTypeHandler; |
|||
import org.apache.ibatis.type.JdbcType; |
|||
import org.apache.ibatis.type.MappedTypes; |
|||
|
|||
import java.sql.CallableStatement; |
|||
import java.sql.PreparedStatement; |
|||
import java.sql.ResultSet; |
|||
import java.sql.SQLException; |
|||
|
|||
/** |
|||
* MyBatis 类型处理器:display_style 列(TEXT/JSON)与 WaypointDisplayStyle 互转。 |
|||
*/ |
|||
@MappedTypes(WaypointDisplayStyle.class) |
|||
public class WaypointDisplayStyleTypeHandler extends BaseTypeHandler<WaypointDisplayStyle> { |
|||
|
|||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
|||
|
|||
@Override |
|||
public void setNonNullParameter(PreparedStatement ps, int i, WaypointDisplayStyle parameter, JdbcType jdbcType) throws SQLException { |
|||
try { |
|||
ps.setString(i, MAPPER.writeValueAsString(parameter)); |
|||
} catch (Exception e) { |
|||
throw new SQLException("WaypointDisplayStyle serialize error", e); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public WaypointDisplayStyle getNullableResult(ResultSet rs, String columnName) throws SQLException { |
|||
String json = rs.getString(columnName); |
|||
return parse(json); |
|||
} |
|||
|
|||
@Override |
|||
public WaypointDisplayStyle getNullableResult(ResultSet rs, int columnIndex) throws SQLException { |
|||
String json = rs.getString(columnIndex); |
|||
return parse(json); |
|||
} |
|||
|
|||
@Override |
|||
public WaypointDisplayStyle getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { |
|||
String json = cs.getString(columnIndex); |
|||
return parse(json); |
|||
} |
|||
|
|||
private static WaypointDisplayStyle parse(String json) { |
|||
if (json == null || json.trim().isEmpty()) { |
|||
return null; |
|||
} |
|||
try { |
|||
return MAPPER.readValue(json, WaypointDisplayStyle.class); |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
|||
<mapper namespace="com.ruoyi.system.mapper.ObjectOperationLogMapper"> |
|||
|
|||
<resultMap type="ObjectOperationLog" id="ObjectOperationLogResult"> |
|||
<id property="id" column="id" /> |
|||
<result property="roomId" column="room_id" /> |
|||
<result property="operatorId" column="operator_id" /> |
|||
<result property="operatorName" column="operator_name" /> |
|||
<result property="operationType" column="operation_type"/> |
|||
<result property="objectType" column="object_type" /> |
|||
<result property="objectId" column="object_id" /> |
|||
<result property="objectName" column="object_name" /> |
|||
<result property="detail" column="detail" /> |
|||
<result property="snapshotBefore" column="snapshot_before"/> |
|||
<result property="snapshotAfter" column="snapshot_after" /> |
|||
<result property="kTime" column="k_time" /> |
|||
<result property="createdAt" column="created_at" /> |
|||
</resultMap> |
|||
|
|||
<sql id="selectVo"> |
|||
select id, room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name, |
|||
detail, snapshot_before, snapshot_after, k_time, created_at |
|||
from object_operation_log |
|||
</sql> |
|||
|
|||
<insert id="insert" parameterType="ObjectOperationLog" useGeneratedKeys="true" keyProperty="id"> |
|||
insert into object_operation_log (room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name, detail, snapshot_before, snapshot_after, k_time) |
|||
values (#{roomId}, #{operatorId}, #{operatorName}, #{operationType}, #{objectType}, #{objectId}, #{objectName}, #{detail}, #{snapshotBefore}, #{snapshotAfter}, #{kTime}) |
|||
</insert> |
|||
|
|||
<select id="selectById" resultMap="ObjectOperationLogResult"> |
|||
<include refid="selectVo"/> where id = #{id} |
|||
</select> |
|||
|
|||
<select id="selectPage" parameterType="ObjectOperationLog" resultMap="ObjectOperationLogResult"> |
|||
<include refid="selectVo"/> |
|||
<where> |
|||
<if test="roomId != null"> and room_id = #{roomId} </if> |
|||
<if test="operatorName != null and operatorName != ''"> and operator_name like concat('%', #{operatorName}, '%') </if> |
|||
<if test="operationType != null"> and operation_type = #{operationType} </if> |
|||
<if test="objectType != null and objectType != ''"> and object_type = #{objectType} </if> |
|||
<if test="params != null and params.beginTime != null and params.beginTime != ''"> and created_at >= #{params.beginTime} </if> |
|||
<if test="params != null and params.endTime != null and params.endTime != ''"> and created_at <= #{params.endTime} </if> |
|||
</where> |
|||
order by created_at desc |
|||
</select> |
|||
|
|||
<delete id="deleteById" parameterType="Long"> |
|||
delete from object_operation_log where id = #{id} |
|||
</delete> |
|||
|
|||
<delete id="deleteByRoomIdAfterId"> |
|||
delete from object_operation_log where room_id = #{roomId} and id > #{afterId} |
|||
</delete> |
|||
</mapper> |
|||
@ -0,0 +1,54 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<!DOCTYPE mapper |
|||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
|||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
|||
<mapper namespace="com.ruoyi.system.mapper.RoomUserProfileMapper"> |
|||
|
|||
<resultMap type="com.ruoyi.system.domain.RoomUserProfile" id="RoomUserProfileResult"> |
|||
<result property="id" column="id"/> |
|||
<result property="userId" column="user_id"/> |
|||
<result property="displayName" column="display_name"/> |
|||
<result property="avatar" column="avatar"/> |
|||
<result property="createTime" column="create_time"/> |
|||
<result property="updateTime" column="update_time"/> |
|||
</resultMap> |
|||
|
|||
<sql id="selectRoomUserProfileVo"> |
|||
select id, user_id, display_name, avatar, create_time, update_time |
|||
from room_user_profile |
|||
</sql> |
|||
|
|||
<select id="selectByUserId" parameterType="Long" resultMap="RoomUserProfileResult"> |
|||
<include refid="selectRoomUserProfileVo"/> |
|||
where user_id = #{userId} |
|||
limit 1 |
|||
</select> |
|||
|
|||
<insert id="insertRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile" useGeneratedKeys="true" keyProperty="id"> |
|||
insert into room_user_profile |
|||
<trim prefix="(" suffix=")" suffixOverrides=","> |
|||
<if test="userId != null">user_id,</if> |
|||
<if test="displayName != null">display_name,</if> |
|||
<if test="avatar != null">avatar,</if> |
|||
<if test="createTime != null">create_time,</if> |
|||
<if test="updateTime != null">update_time,</if> |
|||
</trim> |
|||
<trim prefix="values (" suffix=")" suffixOverrides=","> |
|||
<if test="userId != null">#{userId},</if> |
|||
<if test="displayName != null">#{displayName},</if> |
|||
<if test="avatar != null">#{avatar},</if> |
|||
<if test="createTime != null">#{createTime},</if> |
|||
<if test="updateTime != null">#{updateTime},</if> |
|||
</trim> |
|||
</insert> |
|||
|
|||
<update id="updateRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile"> |
|||
update room_user_profile |
|||
<trim prefix="SET" suffixOverrides=","> |
|||
<if test="displayName != null">display_name = #{displayName},</if> |
|||
<if test="avatar != null">avatar = #{avatar},</if> |
|||
<if test="updateTime != null">update_time = #{updateTime},</if> |
|||
</trim> |
|||
where user_id = #{userId} |
|||
</update> |
|||
</mapper> |
|||
@ -0,0 +1,77 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<!DOCTYPE mapper |
|||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
|||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
|||
<mapper namespace="com.ruoyi.system.mapper.SysTimelineSegmentMapper"> |
|||
|
|||
<resultMap type="SysTimelineSegment" id="SysTimelineSegmentResult"> |
|||
<id property="segmentId" column="segment_id" /> |
|||
<result property="roomId" column="room_id" /> |
|||
<result property="segmentTime" column="segment_time" /> |
|||
<result property="segmentName" column="segment_name" /> |
|||
<result property="segmentDesc" column="segment_desc" /> |
|||
</resultMap> |
|||
|
|||
<sql id="selectSegmentVo"> |
|||
select s.segment_id, s.room_id, s.segment_time, s.segment_name, s.segment_desc |
|||
from sys_timeline_segment s |
|||
</sql> |
|||
|
|||
<select id="selectSegmentList" parameterType="SysTimelineSegment" resultMap="SysTimelineSegmentResult"> |
|||
<include refid="selectSegmentVo"/> |
|||
where 1=1 |
|||
<if test="roomId != null and roomId != 0"> |
|||
AND room_id = #{roomId} |
|||
</if> |
|||
<if test="segmentName != null and segmentName != ''"> |
|||
AND segment_name like concat('%', #{segmentName}, '%') |
|||
</if> |
|||
order by s.segment_time |
|||
</select> |
|||
|
|||
<select id="selectSegmentListByRoomId" parameterType="Long" resultMap="SysTimelineSegmentResult"> |
|||
<include refid="selectSegmentVo"/> |
|||
where room_id = #{roomId} |
|||
order by s.segment_time |
|||
</select> |
|||
|
|||
<select id="selectSegmentById" parameterType="Long" resultMap="SysTimelineSegmentResult"> |
|||
<include refid="selectSegmentVo"/> |
|||
where segment_id = #{segmentId} |
|||
</select> |
|||
|
|||
<insert id="insertSegment" parameterType="SysTimelineSegment"> |
|||
insert into sys_timeline_segment( |
|||
room_id, |
|||
segment_time, |
|||
segment_name, |
|||
<if test="segmentDesc != null and segmentDesc != ''">segment_desc,</if> |
|||
segment_id |
|||
)values( |
|||
#{roomId}, |
|||
#{segmentTime}, |
|||
#{segmentName}, |
|||
<if test="segmentDesc != null and segmentDesc != ''">#{segmentDesc},</if> |
|||
#{segmentId} |
|||
) |
|||
</insert> |
|||
|
|||
<update id="updateSegment" parameterType="SysTimelineSegment"> |
|||
update sys_timeline_segment |
|||
<set> |
|||
<if test="segmentTime != null and segmentTime != ''">segment_time = #{segmentTime},</if> |
|||
<if test="segmentName != null and segmentName != ''">segment_name = #{segmentName},</if> |
|||
<if test="segmentDesc != null">segment_desc = #{segmentDesc},</if> |
|||
</set> |
|||
where segment_id = #{segmentId} |
|||
</update> |
|||
|
|||
<delete id="deleteSegmentById" parameterType="Long"> |
|||
delete from sys_timeline_segment where segment_id = #{segmentId} |
|||
</delete> |
|||
|
|||
<delete id="deleteSegmentByRoomId" parameterType="Long"> |
|||
delete from sys_timeline_segment where room_id = #{roomId} |
|||
</delete> |
|||
|
|||
</mapper> |
|||
@ -0,0 +1,691 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>数据卡</title> |
|||
<link rel="stylesheet" href="./element-ui.css"> |
|||
<style> |
|||
* { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
|||
background: #fafafa; |
|||
} |
|||
|
|||
#app { |
|||
width: 100%; |
|||
height: 100vh; |
|||
} |
|||
|
|||
.data-card-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100vh; |
|||
background: #fafafa; |
|||
} |
|||
|
|||
.data-card-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 0 32px; |
|||
height: 56px; |
|||
background: #fff; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.header-left { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.back-btn { |
|||
padding: 8px 12px; |
|||
font-size: 14px; |
|||
color: #666; |
|||
cursor: pointer; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.back-btn:hover { |
|||
color: #409EFF; |
|||
} |
|||
|
|||
.data-card-title { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
|
|||
.header-right { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.data-card-toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
padding: 16px 32px; |
|||
background: #fff; |
|||
border-bottom: 1px solid #e8e8e8; |
|||
} |
|||
|
|||
.toolbar-group { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.toolbar-label { |
|||
font-size: 14px; |
|||
color: #666; |
|||
} |
|||
|
|||
.data-card-content { |
|||
flex: 1; |
|||
padding: 24px 32px; |
|||
overflow: auto; |
|||
} |
|||
|
|||
.table-container { |
|||
background: #fff; |
|||
border-radius: 8px; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|||
padding: 24px; |
|||
display: inline-block; |
|||
} |
|||
|
|||
.data-table { |
|||
border-collapse: collapse; |
|||
table-layout: fixed; |
|||
} |
|||
|
|||
.data-table td { |
|||
border: 1px solid #ddd; |
|||
min-width: 60px; |
|||
height: 30px; |
|||
position: relative; |
|||
cursor: cell; |
|||
transition: background-color 0.2s; |
|||
} |
|||
|
|||
.data-table td:hover { |
|||
background-color: #f5f7fa; |
|||
} |
|||
|
|||
.data-table td.selected { |
|||
background-color: #ecf5ff; |
|||
border-color: #409EFF; |
|||
} |
|||
|
|||
.resize-handle-col { |
|||
position: absolute; |
|||
right: 0; |
|||
top: 0; |
|||
width: 5px; |
|||
height: 100%; |
|||
cursor: col-resize; |
|||
background: transparent; |
|||
z-index: 10; |
|||
} |
|||
|
|||
.resize-handle-col:hover { |
|||
background: #409EFF; |
|||
} |
|||
|
|||
.resize-handle-row { |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 5px; |
|||
cursor: row-resize; |
|||
background: transparent; |
|||
z-index: 10; |
|||
} |
|||
|
|||
.resize-handle-row:hover { |
|||
background: #409EFF; |
|||
} |
|||
|
|||
.cell-input { |
|||
width: 100%; |
|||
height: 100%; |
|||
border: none; |
|||
outline: none; |
|||
padding: 4px 8px; |
|||
font-size: 14px; |
|||
background: transparent; |
|||
} |
|||
|
|||
.color-picker-panel { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 4px; |
|||
width: 180px; |
|||
} |
|||
|
|||
.color-option { |
|||
width: 24px; |
|||
height: 24px; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
border: 2px solid transparent; |
|||
} |
|||
|
|||
.color-option:hover { |
|||
border-color: #409EFF; |
|||
} |
|||
|
|||
.color-option.selected { |
|||
border-color: #409EFF; |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div id="app"> |
|||
<div class="data-card-container"> |
|||
<div class="data-card-header"> |
|||
<div class="header-left"> |
|||
<div class="back-btn" @click="goBack"> |
|||
<i class="el-icon-arrow-left"></i> |
|||
返回 |
|||
</div> |
|||
<div class="data-card-title">数据卡</div> |
|||
</div> |
|||
<div class="header-right"> |
|||
<el-button type="primary" size="small" @click="exportImage"> |
|||
<i class="el-icon-picture-outline"></i> |
|||
导出图片 |
|||
</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="data-card-toolbar"> |
|||
<div class="toolbar-group"> |
|||
<span class="toolbar-label">行数:</span> |
|||
<el-input-number v-model="rows" :min="1" :max="50" size="small" @change="updateTableSize"></el-input-number> |
|||
</div> |
|||
<div class="toolbar-group"> |
|||
<span class="toolbar-label">列数:</span> |
|||
<el-input-number v-model="cols" :min="1" :max="20" size="small" @change="updateTableSize"></el-input-number> |
|||
</div> |
|||
<el-divider direction="vertical"></el-divider> |
|||
<div class="toolbar-group"> |
|||
<span class="toolbar-label">单元格背景色:</span> |
|||
<el-popover |
|||
placement="bottom" |
|||
width="200" |
|||
trigger="click" |
|||
v-model="colorPopoverVisible"> |
|||
<div class="color-picker-panel"> |
|||
<div |
|||
v-for="color in colors" |
|||
:key="color" |
|||
class="color-option" |
|||
:style="{ backgroundColor: color }" |
|||
:class="{ selected: selectedColor === color }" |
|||
@click="selectColor(color)"> |
|||
</div> |
|||
</div> |
|||
<el-button slot="reference" size="small"> |
|||
<span :style="{ display: 'inline-block', width: '16px', height: '16px', backgroundColor: selectedColor, marginRight: '8px', verticalAlign: 'middle', borderRadius: '2px' }"></span> |
|||
选择颜色 |
|||
</el-button> |
|||
</el-popover> |
|||
</div> |
|||
<el-divider direction="vertical"></el-divider> |
|||
<div class="toolbar-group"> |
|||
<el-button size="small" @click="undo">撤回</el-button> |
|||
<el-button size="small" @click="mergeCells">合并单元格</el-button> |
|||
<el-button type="primary" size="small" @click="saveTable">保存</el-button> |
|||
<el-button type="danger" size="small" @click="clearCell">清空选中</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="data-card-content"> |
|||
<div class="table-container" ref="tableContainer"> |
|||
<table class="data-table" ref="dataTable"> |
|||
<tbody> |
|||
<tr v-for="(row, rowIndex) in tableData" :key="rowIndex"> |
|||
<td |
|||
v-for="(cell, colIndex) in row" |
|||
:key="colIndex" |
|||
:class="{ selected: selectedCells.some(c => c.row === rowIndex && c.col === colIndex) }" |
|||
:rowspan="getRowspan(rowIndex, colIndex)" |
|||
:colspan="getColspan(rowIndex, colIndex)" |
|||
:style="getCellStyle(rowIndex, colIndex)" |
|||
@click="editCell(rowIndex, colIndex)" |
|||
@mousedown="startSelect($event, rowIndex, colIndex)" |
|||
@mouseover="continueSelect($event, rowIndex, colIndex)" |
|||
@mouseup="endSelect"> |
|||
<div class="resize-handle-col" @mousedown.stop="startResizeCol($event, colIndex)"></div> |
|||
<div class="resize-handle-row" @mousedown.stop="startResizeRow($event, rowIndex)"></div> |
|||
<span v-if="!editingCell || editingCell.row !== rowIndex || editingCell.col !== colIndex">{{ cell.content }}</span> |
|||
<input |
|||
v-else |
|||
class="cell-input" |
|||
v-model="tableData[rowIndex][colIndex].content" |
|||
@blur="finishEditing" |
|||
@keyup.enter="finishEditing" |
|||
ref="cellInput" |
|||
autofocus> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script src="https://unpkg.com/vue@2.6.12/dist/vue.js"></script> |
|||
<script src="https://unpkg.com/element-ui/lib/index.js"></script> |
|||
<script src="https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js"></script> |
|||
<script> |
|||
new Vue({ |
|||
el: '#app', |
|||
data() { |
|||
return { |
|||
rows: 10, |
|||
cols: 8, |
|||
tableData: [], |
|||
cellWidths: [], |
|||
cellHeights: [], |
|||
selectedCells: [], |
|||
isSelecting: false, |
|||
startCell: null, |
|||
editingCell: null, |
|||
selectedColor: '#ffffff', |
|||
colorPopoverVisible: false, |
|||
colors: [ |
|||
'#ffffff', '#f5f7fa', '#e6a23c', '#67c23a', '#409eff', |
|||
'#909399', '#f0f9eb', '#fdf6ec', '#ecf5ff', '#f4f4f5', |
|||
'#ff6b6b', '#51cf66', '#339af0', '#cc5de8', '#ff922b', |
|||
'#ffd43b', '#20c997', '#22b8cf', '#748ffc', '#be4bdb' |
|||
], |
|||
resizingCol: null, |
|||
resizingRow: null, |
|||
startX: 0, |
|||
startY: 0, |
|||
startWidth: 0, |
|||
startHeight: 0, |
|||
mergedCells: [], |
|||
history: [], |
|||
historyIndex: -1 |
|||
}; |
|||
}, |
|||
mounted() { |
|||
this.loadTable(); |
|||
if (this.tableData.length === 0) { |
|||
this.generateTable(); |
|||
// 初始化历史记录 |
|||
this.saveState(); |
|||
} |
|||
document.addEventListener('mouseup', this.endSelect); |
|||
document.addEventListener('mousemove', this.handleResize); |
|||
document.addEventListener('mouseup', this.endResize); |
|||
}, |
|||
beforeDestroy() { |
|||
document.removeEventListener('mouseup', this.endSelect); |
|||
document.removeEventListener('mousemove', this.handleResize); |
|||
document.removeEventListener('mouseup', this.endResize); |
|||
}, |
|||
methods: { |
|||
goBack() { |
|||
window.close(); |
|||
}, |
|||
generateTable() { |
|||
this.tableData = []; |
|||
this.cellWidths = []; |
|||
this.cellHeights = []; |
|||
this.mergedCells = []; |
|||
for (let i = 0; i < this.rows; i++) { |
|||
const row = []; |
|||
for (let j = 0; j < this.cols; j++) { |
|||
row.push({ content: '', bgColor: '#ffffff' }); |
|||
} |
|||
this.tableData.push(row); |
|||
this.cellHeights.push(30); |
|||
} |
|||
for (let j = 0; j < this.cols; j++) { |
|||
this.cellWidths.push(100); |
|||
} |
|||
this.selectedCells = []; |
|||
// 重置历史记录 |
|||
this.history = []; |
|||
this.historyIndex = -1; |
|||
}, |
|||
|
|||
updateTableSize() { |
|||
const oldRows = this.tableData.length; |
|||
const oldCols = oldRows > 0 ? this.tableData[0].length : 0; |
|||
|
|||
if (this.rows !== oldRows || this.cols !== oldCols) { |
|||
// 保存当前状态 |
|||
this.saveState(); |
|||
|
|||
// 处理行的增减 |
|||
if (this.rows > oldRows) { |
|||
// 增加行 |
|||
for (let i = oldRows; i < this.rows; i++) { |
|||
const row = []; |
|||
for (let j = 0; j < this.cols; j++) { |
|||
row.push({ content: '', bgColor: '#ffffff' }); |
|||
} |
|||
this.tableData.push(row); |
|||
this.cellHeights.push(30); |
|||
} |
|||
} else if (this.rows < oldRows) { |
|||
// 减少行 |
|||
this.tableData.splice(this.rows); |
|||
this.cellHeights.splice(this.rows); |
|||
} |
|||
|
|||
// 处理列的增减 |
|||
if (this.cols > oldCols) { |
|||
// 增加列 |
|||
for (let i = 0; i < this.tableData.length; i++) { |
|||
for (let j = oldCols; j < this.cols; j++) { |
|||
this.tableData[i].push({ content: '', bgColor: '#ffffff' }); |
|||
} |
|||
} |
|||
for (let j = oldCols; j < this.cols; j++) { |
|||
this.cellWidths.push(100); |
|||
} |
|||
} else if (this.cols < oldCols) { |
|||
// 减少列 |
|||
for (let i = 0; i < this.tableData.length; i++) { |
|||
this.tableData[i].splice(this.cols); |
|||
} |
|||
this.cellWidths.splice(this.cols); |
|||
} |
|||
} |
|||
}, |
|||
|
|||
mergeCells() { |
|||
if (this.selectedCells.length < 2) { |
|||
this.$message.warning('请至少选择两个单元格进行合并'); |
|||
return; |
|||
} |
|||
|
|||
// 保存当前状态 |
|||
this.saveState(); |
|||
|
|||
// 计算合并区域的最小和最大行、列 |
|||
const minRow = Math.min(...this.selectedCells.map(c => c.row)); |
|||
const maxRow = Math.max(...this.selectedCells.map(c => c.row)); |
|||
const minCol = Math.min(...this.selectedCells.map(c => c.col)); |
|||
const maxCol = Math.max(...this.selectedCells.map(c => c.col)); |
|||
|
|||
// 获取左上角单元格的内容和背景色 |
|||
const firstCell = this.tableData[minRow][minCol]; |
|||
const content = firstCell.content; |
|||
const bgColor = firstCell.bgColor; |
|||
|
|||
// 清除其他单元格的内容和背景色 |
|||
for (let r = minRow; r <= maxRow; r++) { |
|||
for (let c = minCol; c <= maxCol; c++) { |
|||
if (r !== minRow || c !== minCol) { |
|||
this.tableData[r][c].content = ''; |
|||
this.tableData[r][c].bgColor = '#ffffff'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 记录合并信息 |
|||
this.mergedCells.push({ |
|||
startRow: minRow, |
|||
startCol: minCol, |
|||
endRow: maxRow, |
|||
endCol: maxCol |
|||
}); |
|||
|
|||
this.$message.success('单元格合并成功'); |
|||
}, |
|||
|
|||
saveTable() { |
|||
const tableData = { |
|||
rows: this.rows, |
|||
cols: this.cols, |
|||
tableData: this.tableData, |
|||
cellWidths: this.cellWidths, |
|||
cellHeights: this.cellHeights, |
|||
mergedCells: this.mergedCells |
|||
}; |
|||
localStorage.setItem('dataCardTable', JSON.stringify(tableData)); |
|||
this.$message.success('表格保存成功!'); |
|||
}, |
|||
|
|||
loadTable() { |
|||
const savedData = localStorage.getItem('dataCardTable'); |
|||
if (savedData) { |
|||
try { |
|||
const data = JSON.parse(savedData); |
|||
this.rows = data.rows; |
|||
this.cols = data.cols; |
|||
this.tableData = data.tableData; |
|||
this.cellWidths = data.cellWidths; |
|||
this.cellHeights = data.cellHeights; |
|||
this.mergedCells = data.mergedCells || []; |
|||
this.$message.success('表格加载成功!'); |
|||
// 初始化历史记录 |
|||
this.saveState(); |
|||
} catch (e) { |
|||
console.error('加载表格失败:', e); |
|||
} |
|||
} |
|||
}, |
|||
|
|||
saveState() { |
|||
// 保存当前状态到历史记录 |
|||
const state = { |
|||
rows: this.rows, |
|||
cols: this.cols, |
|||
tableData: JSON.parse(JSON.stringify(this.tableData)), |
|||
cellWidths: [...this.cellWidths], |
|||
cellHeights: [...this.cellHeights], |
|||
mergedCells: JSON.parse(JSON.stringify(this.mergedCells)) |
|||
}; |
|||
|
|||
// 如果当前不是在历史记录的最后,删除后面的历史记录 |
|||
if (this.historyIndex < this.history.length - 1) { |
|||
this.history = this.history.slice(0, this.historyIndex + 1); |
|||
} |
|||
|
|||
// 添加新状态到历史记录 |
|||
this.history.push(state); |
|||
|
|||
// 限制历史记录长度,防止内存占用过大 |
|||
if (this.history.length > 50) { |
|||
this.history.shift(); |
|||
// 调整历史记录索引 |
|||
if (this.historyIndex > 0) { |
|||
this.historyIndex--; |
|||
} |
|||
} else { |
|||
this.historyIndex++; |
|||
} |
|||
}, |
|||
|
|||
undo() { |
|||
if (this.historyIndex > 0) { |
|||
this.historyIndex--; |
|||
const state = this.history[this.historyIndex]; |
|||
this.rows = state.rows; |
|||
this.cols = state.cols; |
|||
this.tableData = state.tableData; |
|||
this.cellWidths = state.cellWidths; |
|||
this.cellHeights = state.cellHeights; |
|||
this.mergedCells = state.mergedCells; |
|||
this.$message.success('操作已撤回'); |
|||
} else { |
|||
this.$message.info('没有可撤回的操作'); |
|||
} |
|||
}, |
|||
|
|||
startSelect(event, row, col) { |
|||
if (event.button !== 0) return; |
|||
// 只有在按下鼠标并移动时才开始选择 |
|||
this.isSelecting = true; |
|||
this.startCell = { row, col }; |
|||
this.selectedCells = [{ row, col }]; |
|||
}, |
|||
continueSelect(event, row, col) { |
|||
if (!this.isSelecting || !this.startCell) return; |
|||
const minRow = Math.min(this.startCell.row, row); |
|||
const maxRow = Math.max(this.startCell.row, row); |
|||
const minCol = Math.min(this.startCell.col, col); |
|||
const maxCol = Math.max(this.startCell.col, col); |
|||
this.selectedCells = []; |
|||
for (let r = minRow; r <= maxRow; r++) { |
|||
for (let c = minCol; c <= maxCol; c++) { |
|||
this.selectedCells.push({ row: r, col: c }); |
|||
} |
|||
} |
|||
}, |
|||
endSelect() { |
|||
this.isSelecting = false; |
|||
}, |
|||
editCell(row, col) { |
|||
// 停止任何正在进行的选择 |
|||
this.isSelecting = false; |
|||
this.selectedCells = [{ row, col }]; |
|||
|
|||
// 进入编辑模式 |
|||
this.editingCell = { row, col }; |
|||
this.$nextTick(() => { |
|||
const cellInputs = this.$refs.cellInput; |
|||
if (cellInputs) { |
|||
// 确保输入框获得焦点 |
|||
if (Array.isArray(cellInputs)) { |
|||
cellInputs.forEach(input => { |
|||
if (input) input.focus(); |
|||
}); |
|||
} else if (cellInputs) { |
|||
cellInputs.focus(); |
|||
} |
|||
} |
|||
}); |
|||
}, |
|||
finishEditing() { |
|||
// 保存当前状态 |
|||
this.saveState(); |
|||
this.editingCell = null; |
|||
}, |
|||
selectColor(color) { |
|||
if (this.selectedCells.length === 0) return; |
|||
|
|||
// 保存当前状态 |
|||
this.saveState(); |
|||
|
|||
this.selectedColor = color; |
|||
this.colorPopoverVisible = false; |
|||
this.selectedCells.forEach(cell => { |
|||
this.tableData[cell.row][cell.col].bgColor = color; |
|||
}); |
|||
}, |
|||
clearCell() { |
|||
if (this.selectedCells.length === 0) return; |
|||
|
|||
// 保存当前状态 |
|||
this.saveState(); |
|||
|
|||
this.selectedCells.forEach(cell => { |
|||
this.tableData[cell.row][cell.col].content = ''; |
|||
this.tableData[cell.row][cell.col].bgColor = '#ffffff'; |
|||
}); |
|||
}, |
|||
startResizeCol(event, col) { |
|||
this.resizingCol = col; |
|||
this.startX = event.clientX; |
|||
this.startWidth = this.cellWidths[col]; |
|||
}, |
|||
startResizeRow(event, row) { |
|||
this.resizingRow = row; |
|||
this.startY = event.clientY; |
|||
this.startHeight = this.cellHeights[row]; |
|||
}, |
|||
handleResize(event) { |
|||
if (this.resizingCol !== null) { |
|||
const deltaX = event.clientX - this.startX; |
|||
const newWidth = Math.max(60, this.startWidth + deltaX); |
|||
this.$set(this.cellWidths, this.resizingCol, newWidth); |
|||
} |
|||
if (this.resizingRow !== null) { |
|||
const deltaY = event.clientY - this.startY; |
|||
const newHeight = Math.max(30, this.startHeight + deltaY); |
|||
this.$set(this.cellHeights, this.resizingRow, newHeight); |
|||
} |
|||
}, |
|||
endResize() { |
|||
this.resizingCol = null; |
|||
this.resizingRow = null; |
|||
}, |
|||
getRowspan(row, col) { |
|||
// 检查当前单元格是否是合并单元格的起始单元格 |
|||
const mergedCell = this.mergedCells.find(mc => mc.startRow === row && mc.startCol === col); |
|||
return mergedCell ? mergedCell.endRow - mergedCell.startRow + 1 : 1; |
|||
}, |
|||
|
|||
getColspan(row, col) { |
|||
// 检查当前单元格是否是合并单元格的起始单元格 |
|||
const mergedCell = this.mergedCells.find(mc => mc.startRow === row && mc.startCol === col); |
|||
return mergedCell ? mergedCell.endCol - mergedCell.startCol + 1 : 1; |
|||
}, |
|||
|
|||
getCellStyle(row, col) { |
|||
// 检查当前单元格是否是合并单元格的一部分 |
|||
const mergedCell = this.mergedCells.find(mc => |
|||
row >= mc.startRow && row <= mc.endRow && |
|||
col >= mc.startCol && col <= mc.endCol |
|||
); |
|||
|
|||
// 如果是合并单元格的非起始单元格,隐藏它 |
|||
if (mergedCell && (row !== mergedCell.startRow || col !== mergedCell.startCol)) { |
|||
return { |
|||
display: 'none', |
|||
width: this.cellWidths[col] + 'px', |
|||
height: this.cellHeights[row] + 'px' |
|||
}; |
|||
} |
|||
|
|||
// 正常单元格的样式 |
|||
return { |
|||
width: this.cellWidths[col] + 'px', |
|||
height: this.cellHeights[row] + 'px', |
|||
backgroundColor: this.tableData[row][col].bgColor || '#fff' |
|||
}; |
|||
}, |
|||
|
|||
exportImage() { |
|||
const container = this.$refs.tableContainer; |
|||
html2canvas(container, { |
|||
backgroundColor: '#ffffff', |
|||
scale: 2, |
|||
useCORS: true |
|||
}).then(canvas => { |
|||
const link = document.createElement('a'); |
|||
link.download = '数据卡.png'; |
|||
link.href = canvas.toDataURL('image/png'); |
|||
link.click(); |
|||
this.$message.success('图片导出成功!'); |
|||
}).catch(err => { |
|||
this.$message.error('图片导出失败:' + err.message); |
|||
}); |
|||
} |
|||
} |
|||
}); |
|||
</script> |
|||
</body> |
|||
</html> |
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 28 KiB |
@ -1,630 +0,0 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>六步法编辑器</title> |
|||
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"> |
|||
<style> |
|||
* { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
|||
background: #fafafa; |
|||
} |
|||
|
|||
#app { |
|||
width: 100%; |
|||
height: 100vh; |
|||
} |
|||
|
|||
.step-editor-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100vh; |
|||
background: #fafafa; |
|||
} |
|||
|
|||
.editor-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 0 32px; |
|||
height: 56px; |
|||
background: #fff; |
|||
border-bottom: 1px solid #e8e8e8; |
|||
} |
|||
|
|||
.header-left { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.back-btn { |
|||
padding: 8px 12px; |
|||
font-size: 14px; |
|||
color: #666; |
|||
} |
|||
|
|||
.back-btn:hover { |
|||
color: #409EFF; |
|||
} |
|||
|
|||
.step-title { |
|||
font-size: 16px; |
|||
font-weight: 500; |
|||
color: #333; |
|||
} |
|||
|
|||
.save-btn { |
|||
padding: 8px 24px; |
|||
font-size: 14px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.editor-toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0; |
|||
padding: 0 32px; |
|||
height: 44px; |
|||
background: #fff; |
|||
border-bottom: 1px solid #e8e8e8; |
|||
} |
|||
|
|||
.toolbar-group { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.toolbar-btn { |
|||
width: 36px; |
|||
height: 36px; |
|||
padding: 0; |
|||
border-radius: 4px; |
|||
color: #666; |
|||
font-size: 16px; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.toolbar-btn:hover { |
|||
background: #f5f5f5; |
|||
color: #409EFF; |
|||
} |
|||
|
|||
.format-icon { |
|||
font-size: 16px; |
|||
font-weight: 700; |
|||
font-family: Arial, sans-serif; |
|||
font-style: normal; |
|||
} |
|||
|
|||
.format-icon.italic { |
|||
font-style: italic; |
|||
} |
|||
|
|||
.format-icon.underline { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.format-icon.strike { |
|||
text-decoration: line-through; |
|||
} |
|||
|
|||
.toolbar-divider { |
|||
width: 1px; |
|||
height: 24px; |
|||
background: #e8e8e8; |
|||
margin: 0 16px; |
|||
} |
|||
|
|||
.word-count { |
|||
font-size: 12px; |
|||
color: #999; |
|||
padding: 0 12px; |
|||
min-width: 60px; |
|||
text-align: right; |
|||
} |
|||
|
|||
.editor-content { |
|||
flex: 1; |
|||
overflow: hidden; |
|||
padding: 32px; |
|||
display: flex; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.editor-area { |
|||
width: 100%; |
|||
max-width: 840px; |
|||
height: 100%; |
|||
background: #fff; |
|||
border-radius: 8px; |
|||
padding: 40px; |
|||
overflow-y: auto; |
|||
outline: none; |
|||
font-size: 16px; |
|||
line-height: 1.8; |
|||
color: #333; |
|||
} |
|||
|
|||
.editor-area:empty:before { |
|||
content: attr(data-placeholder); |
|||
color: #c0c4cc; |
|||
pointer-events: none; |
|||
} |
|||
|
|||
.editor-area p { |
|||
margin: 16px 0; |
|||
} |
|||
|
|||
.editor-area img { |
|||
max-width: 100%; |
|||
height: auto; |
|||
margin: 10px 0; |
|||
} |
|||
|
|||
.editor-area a { |
|||
color: #409EFF; |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.editor-area ul, |
|||
.editor-area ol { |
|||
margin: 16px 0; |
|||
padding-left: 32px; |
|||
} |
|||
|
|||
.editor-area li { |
|||
margin: 8px 0; |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div id="app"> |
|||
<div class="step-editor-container"> |
|||
<div class="editor-header"> |
|||
<div class="header-left"> |
|||
<el-button type="text" icon="el-icon-back" @click="goBack" class="back-btn"> |
|||
关闭 |
|||
</el-button> |
|||
<span class="step-title">{{ stepTitle }}</span> |
|||
</div> |
|||
<div class="header-right"> |
|||
<el-button type="primary" @click="saveContent" class="save-btn"> |
|||
<i class="el-icon-check"></i> |
|||
保存 |
|||
</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="editor-toolbar"> |
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="撤销" placement="top"> |
|||
<el-button type="text" @click="formatText('undo')" class="toolbar-btn"> |
|||
<i class="el-icon-refresh-left"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="重做" placement="top"> |
|||
<el-button type="text" @click="formatText('redo')" class="toolbar-btn"> |
|||
<i class="el-icon-refresh-right"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="导入文档" placement="top"> |
|||
<el-button type="text" @click="importDocument" class="toolbar-btn"> |
|||
<i class="el-icon-document"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="导入图片" placement="top"> |
|||
<el-button type="text" @click="importImage" class="toolbar-btn"> |
|||
<i class="el-icon-picture"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="插入链接" placement="top"> |
|||
<el-button type="text" @click="insertLink" class="toolbar-btn"> |
|||
<i class="el-icon-link"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="加粗" placement="top"> |
|||
<el-button type="text" @click="formatText('bold')" class="toolbar-btn"> |
|||
<span class="format-icon">B</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="斜体" placement="top"> |
|||
<el-button type="text" @click="formatText('italic')" class="toolbar-btn"> |
|||
<span class="format-icon italic">I</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="下划线" placement="top"> |
|||
<el-button type="text" @click="formatText('underline')" class="toolbar-btn"> |
|||
<span class="format-icon underline">U</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="删除线" placement="top"> |
|||
<el-button type="text" @click="formatText('strikeThrough')" class="toolbar-btn"> |
|||
<span class="format-icon strike">S</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="上标" placement="top"> |
|||
<el-button type="text" @click="formatText('superscript')" class="toolbar-btn"> |
|||
<span class="format-icon">x²</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="下标" placement="top"> |
|||
<el-button type="text" @click="formatText('subscript')" class="toolbar-btn"> |
|||
<span class="format-icon">x₂</span> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="左对齐" placement="top"> |
|||
<el-button type="text" @click="formatText('justifyLeft')" class="toolbar-btn"> |
|||
<i class="el-icon-s-unfold"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="居中对齐" placement="top"> |
|||
<el-button type="text" @click="formatText('justifyCenter')" class="toolbar-btn"> |
|||
<i class="el-icon-s-grid"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="右对齐" placement="top"> |
|||
<el-button type="text" @click="formatText('justifyRight')" class="toolbar-btn"> |
|||
<i class="el-icon-s-fold"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="两端对齐" placement="top"> |
|||
<el-button type="text" @click="formatText('justifyFull')" class="toolbar-btn"> |
|||
<i class="el-icon-menu"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="无序列表" placement="top"> |
|||
<el-button type="text" @click="formatText('insertUnorderedList')" class="toolbar-btn"> |
|||
<i class="el-icon-plus"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="有序列表" placement="top"> |
|||
<el-button type="text" @click="formatText('insertOrderedList')" class="toolbar-btn"> |
|||
<i class="el-icon-sort"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="减少缩进" placement="top"> |
|||
<el-button type="text" @click="formatText('outdent')" class="toolbar-btn"> |
|||
<i class="el-icon-back"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<el-tooltip content="增加缩进" placement="top"> |
|||
<el-button type="text" @click="formatText('indent')" class="toolbar-btn"> |
|||
<i class="el-icon-right"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
</div> |
|||
|
|||
<div class="toolbar-divider"></div> |
|||
|
|||
<div class="toolbar-group"> |
|||
<el-tooltip content="全屏" placement="top"> |
|||
<el-button type="text" @click="toggleFullscreen" class="toolbar-btn"> |
|||
<i :class="isFullscreen ? 'el-icon-crop' : 'el-icon-full-screen'"></i> |
|||
</el-button> |
|||
</el-tooltip> |
|||
<div class="word-count"> |
|||
{{ wordCount }} 字 |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="editor-content"> |
|||
<div |
|||
ref="editor" |
|||
class="editor-area" |
|||
contenteditable="true" |
|||
:data-placeholder="placeholder" |
|||
@input="onInput" |
|||
@keydown="onKeydown" |
|||
@focus="onFocus" |
|||
@blur="onBlur" |
|||
> |
|||
</div> |
|||
</div> |
|||
|
|||
<input |
|||
ref="fileInput" |
|||
type="file" |
|||
style="display: none" |
|||
@change="handleFileChange" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<script src="https://unpkg.com/vue@2.6.12/dist/vue.js"></script> |
|||
<script src="https://unpkg.com/element-ui/lib/index.js"></script> |
|||
<script src="https://unpkg.com/mammoth@1.6.0/mammoth.browser.min.js"></script> |
|||
<script> |
|||
new Vue({ |
|||
el: '#app', |
|||
data() { |
|||
return { |
|||
stepTitle: '', |
|||
stepIndex: 0, |
|||
content: '', |
|||
placeholder: '在此输入内容...', |
|||
uploadType: '', |
|||
savedContent: {}, |
|||
wordCount: 0, |
|||
isFullscreen: false |
|||
} |
|||
}, |
|||
created() { |
|||
const urlParams = new URLSearchParams(window.location.search) |
|||
this.stepTitle = urlParams.get('title') || '编辑器' |
|||
this.stepIndex = parseInt(urlParams.get('index')) || 0 |
|||
this.loadSavedContent() |
|||
}, |
|||
methods: { |
|||
goBack() { |
|||
window.close() |
|||
}, |
|||
formatText(command) { |
|||
document.execCommand(command, false, null) |
|||
this.$refs.editor.focus() |
|||
}, |
|||
importDocument() { |
|||
this.uploadType = 'document' |
|||
this.$refs.fileInput.accept = '.txt,.md,.docx' |
|||
this.$refs.fileInput.click() |
|||
}, |
|||
importImage() { |
|||
this.uploadType = 'image' |
|||
this.$refs.fileInput.accept = 'image/*' |
|||
this.$refs.fileInput.click() |
|||
}, |
|||
handleFileChange(event) { |
|||
const file = event.target.files[0] |
|||
if (!file) return |
|||
|
|||
if (this.uploadType === 'document') { |
|||
this.handleDocumentUpload(file) |
|||
} else if (this.uploadType === 'image') { |
|||
this.handleImageUpload(file) |
|||
} |
|||
|
|||
event.target.value = '' |
|||
}, |
|||
handleDocumentUpload(file) { |
|||
const fileName = file.name.toLowerCase() |
|||
const fileExt = fileName.substring(fileName.lastIndexOf('.')) |
|||
|
|||
if (fileExt === '.docx') { |
|||
this.handleDocxUpload(file) |
|||
} else if (fileExt === '.txt' || fileExt === '.md') { |
|||
this.handleTextFileUpload(file) |
|||
} else { |
|||
this.$message.warning('不支持的文件格式,请使用 .txt、.md 或 .docx 文件') |
|||
} |
|||
}, |
|||
handleDocxUpload(file) { |
|||
this.$message.warning('正在解析 Word 文档...') |
|||
const reader = new FileReader() |
|||
reader.onload = (e) => { |
|||
try { |
|||
const arrayBuffer = e.target.result |
|||
this.parseDocx(arrayBuffer) |
|||
} catch (error) { |
|||
console.error('解析 Word 文档失败:', error) |
|||
this.$message.error('解析 Word 文档失败,请将文档另存为 .txt 或 .md 格式后重试') |
|||
} |
|||
} |
|||
reader.readAsArrayBuffer(file) |
|||
}, |
|||
async parseDocx(arrayBuffer) { |
|||
try { |
|||
const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer }) |
|||
const text = result.value |
|||
this.insertText(text) |
|||
this.$message.success('Word 文档导入成功') |
|||
} catch (error) { |
|||
console.error('mammoth.js 解析失败:', error) |
|||
this.$message.error('解析 Word 文档失败') |
|||
} |
|||
}, |
|||
handleTextFileUpload(file) { |
|||
const reader = new FileReader() |
|||
reader.onload = (e) => { |
|||
let text = e.target.result |
|||
|
|||
if (!text || text.trim() === '') { |
|||
this.$message.warning('文档内容为空') |
|||
return |
|||
} |
|||
|
|||
if (this.isGarbled(text)) { |
|||
this.tryGBKEncoding(file) |
|||
} else { |
|||
this.insertText(text) |
|||
this.$message.success('文档导入成功') |
|||
} |
|||
} |
|||
reader.readAsText(file, 'UTF-8') |
|||
}, |
|||
isGarbled(text) { |
|||
const garbageChars = text.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g) |
|||
return garbageChars && garbageChars.length > text.length * 0.3 |
|||
}, |
|||
tryGBKEncoding(file) { |
|||
const reader = new FileReader() |
|||
reader.onload = (e) => { |
|||
const text = e.target.result |
|||
if (this.isGarbled(text)) { |
|||
this.$message.error('文档编码无法识别,请确保文档使用 UTF-8 或 GBK 编码') |
|||
} else { |
|||
this.insertText(text) |
|||
this.$message.success('文档导入成功') |
|||
} |
|||
} |
|||
reader.readAsText(file, 'GBK') |
|||
}, |
|||
handleImageUpload(file) { |
|||
const reader = new FileReader() |
|||
reader.onload = (e) => { |
|||
const img = `<img src="${e.target.result}" style="max-width: 100%; height: auto; margin: 10px 0;" />` |
|||
this.insertHTML(img) |
|||
this.$message.success('图片导入成功') |
|||
} |
|||
reader.readAsDataURL(file) |
|||
}, |
|||
insertLink() { |
|||
this.$prompt('请输入链接地址', '插入链接', { |
|||
confirmButtonText: '确定', |
|||
cancelButtonText: '取消' |
|||
}).then(({ value }) => { |
|||
if (value) { |
|||
const link = `<a href="${value}" target="_blank" style="color: #409EFF; text-decoration: underline;">${value}</a>` |
|||
this.insertHTML(link) |
|||
} |
|||
}) |
|||
}, |
|||
insertText(text) { |
|||
const editor = this.$refs.editor |
|||
const selection = window.getSelection() |
|||
|
|||
if (selection.rangeCount > 0) { |
|||
const range = selection.getRangeAt(0) |
|||
const textNode = document.createTextNode(text) |
|||
range.insertNode(textNode) |
|||
} else { |
|||
if (editor.innerHTML.trim() === '') { |
|||
editor.innerHTML = `<p>${text}</p>` |
|||
} else { |
|||
editor.innerHTML += `<p>${text}</p>` |
|||
} |
|||
} |
|||
|
|||
this.$nextTick(() => { |
|||
this.updateWordCount() |
|||
}) |
|||
}, |
|||
insertHTML(html) { |
|||
const editor = this.$refs.editor |
|||
const selection = window.getSelection() |
|||
|
|||
if (selection.rangeCount > 0) { |
|||
const range = selection.getRangeAt(0) |
|||
const div = document.createElement('div') |
|||
div.innerHTML = html |
|||
range.insertNode(div) |
|||
} else { |
|||
if (editor.innerHTML.trim() === '') { |
|||
editor.innerHTML = `<p>${html}</p>` |
|||
} else { |
|||
editor.innerHTML += `<p>${html}</p>` |
|||
} |
|||
} |
|||
|
|||
this.$nextTick(() => { |
|||
this.updateWordCount() |
|||
}) |
|||
}, |
|||
onInput() { |
|||
this.content = this.$refs.editor.innerHTML |
|||
this.updateWordCount() |
|||
}, |
|||
updateWordCount() { |
|||
const text = this.$refs.editor.innerText || '' |
|||
this.wordCount = text.replace(/\s+/g, '').length |
|||
}, |
|||
toggleFullscreen() { |
|||
this.isFullscreen = !this.isFullscreen |
|||
const container = document.querySelector('.step-editor-container') |
|||
if (this.isFullscreen) { |
|||
if (container.requestFullscreen) { |
|||
container.requestFullscreen() |
|||
} else if (container.webkitRequestFullscreen) { |
|||
container.webkitRequestFullscreen() |
|||
} else if (container.msRequestFullscreen) { |
|||
container.msRequestFullscreen() |
|||
} |
|||
} else { |
|||
if (document.exitFullscreen) { |
|||
document.exitFullscreen() |
|||
} else if (document.webkitExitFullscreen) { |
|||
document.webkitExitFullscreen() |
|||
} else if (document.msExitFullscreen) { |
|||
document.msExitFullscreen() |
|||
} |
|||
} |
|||
}, |
|||
onFocus() { |
|||
const editor = this.$refs.editor |
|||
if (editor.innerText.trim() === '' && editor.innerHTML.trim() === '') { |
|||
editor.innerHTML = '' |
|||
} |
|||
}, |
|||
onBlur() { |
|||
const editor = this.$refs.editor |
|||
if (editor.innerHTML.trim() === '' || editor.innerText.trim() === '') { |
|||
editor.innerHTML = '' |
|||
} |
|||
}, |
|||
onKeydown(event) { |
|||
if (event.ctrlKey || event.metaKey) { |
|||
if (event.key === 's') { |
|||
event.preventDefault() |
|||
this.saveContent() |
|||
} |
|||
} |
|||
}, |
|||
saveContent() { |
|||
this.content = this.$refs.editor.innerHTML |
|||
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}') |
|||
savedData[`step_${this.stepIndex}`] = { |
|||
content: this.content, |
|||
updateTime: new Date().toISOString() |
|||
} |
|||
localStorage.setItem('stepEditorContent', JSON.stringify(savedData)) |
|||
this.$message.success('保存成功') |
|||
}, |
|||
loadSavedContent() { |
|||
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}') |
|||
const stepData = savedData[`step_${this.stepIndex}`] |
|||
if (stepData && stepData.content) { |
|||
this.$nextTick(() => { |
|||
this.$refs.editor.innerHTML = stepData.content |
|||
this.content = stepData.content |
|||
this.updateWordCount() |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
</script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,23 @@ |
|||
import request from '@/utils/request' |
|||
|
|||
/** |
|||
* 对象级操作日志分页查询(按房间、操作人、类型等) |
|||
*/ |
|||
export function listObjectLog(params) { |
|||
return request({ |
|||
url: '/system/object-log/list', |
|||
method: 'get', |
|||
params |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 回滚到指定操作(数据库 + Redis 同步) |
|||
*/ |
|||
export function rollbackObjectLog(id) { |
|||
return request({ |
|||
url: '/system/object-log/rollback', |
|||
method: 'post', |
|||
params: { id } |
|||
}) |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
import request from '@/utils/request' |
|||
|
|||
export function listTimelineSegments(query) { |
|||
return request({ |
|||
url: '/system/timeline/list', |
|||
method: 'get', |
|||
params: query |
|||
}) |
|||
} |
|||
|
|||
export function getTimelineSegmentsByRoomId(roomId) { |
|||
return request({ |
|||
url: '/system/timeline/listByRoomId/' + roomId, |
|||
method: 'get' |
|||
}) |
|||
} |
|||
|
|||
export function getTimelineSegment(id) { |
|||
return request({ |
|||
url: '/system/timeline/' + id, |
|||
method: 'get' |
|||
}) |
|||
} |
|||
|
|||
export function addTimelineSegment(data) { |
|||
return request({ |
|||
url: '/system/timeline', |
|||
method: 'post', |
|||
data: data |
|||
}) |
|||
} |
|||
|
|||
export function updateTimelineSegment(data) { |
|||
return request({ |
|||
url: '/system/timeline', |
|||
method: 'put', |
|||
data: data |
|||
}) |
|||
} |
|||
|
|||
export function delTimelineSegment(id) { |
|||
return request({ |
|||
url: '/system/timeline/' + id, |
|||
method: 'delete' |
|||
}) |
|||
} |
|||
|
|||
export function delTimelineSegmentsByRoomId(roomId) { |
|||
return request({ |
|||
url: '/system/timeline/room/' + roomId, |
|||
method: 'delete' |
|||
}) |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
import request from '@/utils/request' |
|||
|
|||
/** 获取房间下所有白板列表 */ |
|||
export function listWhiteboards(roomId) { |
|||
return request({ |
|||
url: `/room/${roomId}/whiteboards`, |
|||
method: 'get' |
|||
}) |
|||
} |
|||
|
|||
/** 获取单个白板详情 */ |
|||
export function getWhiteboard(roomId, whiteboardId) { |
|||
return request({ |
|||
url: `/room/${roomId}/whiteboard/${whiteboardId}`, |
|||
method: 'get' |
|||
}) |
|||
} |
|||
|
|||
/** 创建白板 */ |
|||
export function createWhiteboard(roomId, data) { |
|||
return request({ |
|||
url: `/room/${roomId}/whiteboard`, |
|||
method: 'post', |
|||
data: data || {} |
|||
}) |
|||
} |
|||
|
|||
/** 更新白板 */ |
|||
export function updateWhiteboard(roomId, whiteboardId, data) { |
|||
return request({ |
|||
url: `/room/${roomId}/whiteboard/${whiteboardId}`, |
|||
method: 'put', |
|||
data: data || {} |
|||
}) |
|||
} |
|||
|
|||
/** 删除白板 */ |
|||
export function deleteWhiteboard(roomId, whiteboardId) { |
|||
return request({ |
|||
url: `/room/${roomId}/whiteboard/${whiteboardId}`, |
|||
method: 'delete' |
|||
}) |
|||
} |
|||
|
|||
/** 保存白板平台样式(Redis key: whiteboard:scheme:{schemeId}:platform:{platformInstanceId}:style) */ |
|||
export function saveWhiteboardPlatformStyle(data) { |
|||
return request({ |
|||
url: '/room/whiteboard/platform/style', |
|||
method: 'post', |
|||
data: data || {} |
|||
}) |
|||
} |
|||
|
|||
/** 获取白板平台样式 */ |
|||
export function getWhiteboardPlatformStyle(params) { |
|||
return request({ |
|||
url: '/room/whiteboard/platform/style', |
|||
method: 'get', |
|||
params: params || {} |
|||
}) |
|||
} |
|||
|
|||
/** 删除白板平台样式 */ |
|||
export function deleteWhiteboardPlatformStyle(params) { |
|||
return request({ |
|||
url: '/room/whiteboard/platform/style', |
|||
method: 'delete', |
|||
params: params || {} |
|||
}) |
|||
} |
|||
|
|||
/** 获取当前用户协同资料(显示名、头像) */ |
|||
export function getRoomUserProfile() { |
|||
return request({ |
|||
url: '/room/user/profile', |
|||
method: 'get' |
|||
}) |
|||
} |
|||
|
|||
/** 更新当前用户协同显示名 */ |
|||
export function updateRoomUserProfile(data) { |
|||
return request({ |
|||
url: '/room/user/profile', |
|||
method: 'put', |
|||
data: data || {} |
|||
}) |
|||
} |
|||
|
|||
/** 上传当前用户协同头像(独立目录) */ |
|||
export function uploadRoomUserAvatar(file) { |
|||
const formData = new FormData() |
|||
formData.append('avatarfile', file) |
|||
return request({ |
|||
url: '/room/user/profile/avatar', |
|||
method: 'post', |
|||
data: formData |
|||
}) |
|||
} |
|||
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 378 B |
@ -0,0 +1,181 @@ |
|||
/** |
|||
* 全局弹窗可拖动(覆盖项目内所有弹窗) |
|||
* 1. 弹窗出现时直接给标题栏绑定拖动(MutationObserver + 延迟扫描) |
|||
* 2. document 捕获阶段 mousedown 兜底,确保点击标题栏一定能拖 |
|||
* 支持:el-dialog、el-message-box($prompt/confirm/alert)、自定义 .dialog-content |
|||
*/ |
|||
let inited = false |
|||
|
|||
function startDrag(e, container) { |
|||
if (!container) return |
|||
e.preventDefault() |
|||
e.stopPropagation() |
|||
const prevUserSelect = document.body.style.userSelect |
|||
document.body.style.userSelect = 'none' |
|||
let startX = e.clientX |
|||
let startY = e.clientY |
|||
const rect = container.getBoundingClientRect() |
|||
let left = rect.left |
|||
let top = rect.top |
|||
container.style.position = 'fixed' |
|||
container.style.left = left + 'px' |
|||
container.style.top = top + 'px' |
|||
container.style.margin = '0' |
|||
container.style.transform = 'none' |
|||
/* 锁定宽高,避免拖拽标题栏时弹窗尺寸被改变(只做位移) */ |
|||
container.style.width = rect.width + 'px' |
|||
container.style.height = rect.height + 'px' |
|||
container.style.boxSizing = 'border-box' |
|||
const onMouseMove = (e2) => { |
|||
e2.preventDefault() |
|||
const dx = e2.clientX - startX |
|||
const dy = e2.clientY - startY |
|||
left += dx |
|||
top += dy |
|||
startX = e2.clientX |
|||
startY = e2.clientY |
|||
container.style.left = left + 'px' |
|||
container.style.top = top + 'px' |
|||
} |
|||
const onMouseUp = () => { |
|||
document.body.style.userSelect = prevUserSelect |
|||
document.removeEventListener('mousemove', onMouseMove, true) |
|||
document.removeEventListener('mouseup', onMouseUp, true) |
|||
} |
|||
document.addEventListener('mousemove', onMouseMove, true) |
|||
document.addEventListener('mouseup', onMouseUp, true) |
|||
} |
|||
|
|||
/** el-dialog 仅由此处理:document 捕获阶段拦截标题栏 mousedown,避免双重绑定导致乱动/不动 */ |
|||
function onDocumentMouseDown(e) { |
|||
if (e.button !== 0) return |
|||
if (e.target.closest('.el-dialog__headerbtn') || e.target.closest('.el-message-box__headerbtn') || e.target.closest('.close-btn')) return |
|||
/* 右下角改尺寸手柄:不触发拖拽,只做缩放 */ |
|||
if (e.target.closest('.gantt-dialog-resize-handle')) return |
|||
const elHeader = e.target.closest('.el-dialog__header') |
|||
if (elHeader) { |
|||
const wrapper = elHeader.closest('.el-dialog__wrapper') |
|||
if (wrapper) { |
|||
startDrag(e, wrapper) |
|||
return |
|||
} |
|||
} |
|||
const msgHeader = e.target.closest('.el-message-box__header') |
|||
if (msgHeader) { |
|||
const wrapper = msgHeader.closest('.el-message-box__wrapper') |
|||
if (wrapper) { |
|||
startDrag(e, wrapper) |
|||
return |
|||
} |
|||
} |
|||
const customHeader = e.target.closest('.dialog-header') |
|||
if (customHeader && !customHeader.closest('.el-dialog__wrapper')) { |
|||
const content = customHeader.closest('.dialog-content') |
|||
if (content) { |
|||
startDrag(e, content) |
|||
} |
|||
} |
|||
} |
|||
|
|||
function bindMessageBox(wrapper) { |
|||
if (wrapper.getAttribute('data-dialog-drag-bound') === '1') return |
|||
const header = wrapper.querySelector('.el-message-box__header') |
|||
if (!header) return |
|||
wrapper.setAttribute('data-dialog-drag-bound', '1') |
|||
header.style.cursor = 'move' |
|||
header.addEventListener('mousedown', (e) => { |
|||
if (e.button !== 0 || e.target.closest('.el-message-box__headerbtn')) return |
|||
startDrag(e, wrapper) |
|||
}) |
|||
} |
|||
|
|||
function bindCustomDialog(content) { |
|||
if (content.getAttribute('data-dialog-drag-bound') === '1') return |
|||
const header = content.querySelector('.dialog-header') |
|||
if (!header) return |
|||
content.setAttribute('data-dialog-drag-bound', '1') |
|||
header.style.cursor = 'move' |
|||
header.addEventListener('mousedown', (e) => { |
|||
if (e.button !== 0 || e.target.closest('.close-btn')) return |
|||
startDrag(e, content) |
|||
}) |
|||
} |
|||
|
|||
function scanAndBind() { |
|||
if (typeof document === 'undefined' || !document.body) return |
|||
document.querySelectorAll('.el-message-box__wrapper').forEach(bindMessageBox) |
|||
document.querySelectorAll('.dialog-content').forEach((content) => { |
|||
if (!content.closest('.el-dialog__wrapper') && content.querySelector('.dialog-header')) { |
|||
bindCustomDialog(content) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** 弹窗关闭时清除我们设置的 left/top,避免下次打开从错误位置“瞬移”到中间 */ |
|||
function resetClosedDialogPositions() { |
|||
if (typeof document === 'undefined' || !document.body) return |
|||
document.querySelectorAll('.el-dialog__wrapper').forEach((wrapper) => { |
|||
const style = window.getComputedStyle(wrapper) |
|||
if (style.display === 'none' || style.visibility === 'hidden') { |
|||
wrapper.style.left = '' |
|||
wrapper.style.top = '' |
|||
wrapper.style.position = '' |
|||
wrapper.style.margin = '' |
|||
wrapper.style.transform = '' |
|||
} |
|||
}) |
|||
document.querySelectorAll('.el-message-box__wrapper').forEach((wrapper) => { |
|||
const style = window.getComputedStyle(wrapper) |
|||
if (style.display === 'none' || style.visibility === 'hidden') { |
|||
wrapper.style.left = '' |
|||
wrapper.style.top = '' |
|||
wrapper.style.position = '' |
|||
wrapper.style.margin = '' |
|||
wrapper.style.transform = '' |
|||
} |
|||
}) |
|||
} |
|||
|
|||
function initDialogDrag(Vue) { |
|||
if (typeof document === 'undefined' || !document.body || inited) return |
|||
inited = true |
|||
|
|||
const style = document.createElement('style') |
|||
style.textContent = [ |
|||
'.el-dialog__header { cursor: move !important; }', |
|||
'.el-dialog__header .el-dialog__headerbtn { cursor: pointer !important; }', |
|||
'.el-message-box__header { cursor: move !important; }', |
|||
'.el-message-box__header .el-message-box__headerbtn { cursor: pointer !important; }', |
|||
'.dialog-header { cursor: move !important; }', |
|||
'.dialog-header .close-btn { cursor: pointer !important; }' |
|||
].join(' ') |
|||
document.head.appendChild(style) |
|||
|
|||
scanAndBind() |
|||
|
|||
document.addEventListener('mousedown', onDocumentMouseDown, true) |
|||
|
|||
const scheduleScan = () => { |
|||
resetClosedDialogPositions() |
|||
if (Vue && typeof Vue.nextTick === 'function') Vue.nextTick(scanAndBind) |
|||
else scanAndBind() |
|||
setTimeout(scanAndBind, 0) |
|||
setTimeout(scanAndBind, 50) |
|||
setTimeout(scanAndBind, 150) |
|||
setTimeout(scanAndBind, 400) |
|||
} |
|||
const observer = new MutationObserver(scheduleScan) |
|||
observer.observe(document.body, { childList: true, subtree: true }) |
|||
|
|||
if (Vue && typeof Vue.nextTick === 'function') Vue.nextTick(scanAndBind) |
|||
setTimeout(scanAndBind, 200) |
|||
setTimeout(scanAndBind, 500) |
|||
setTimeout(scanAndBind, 1200) |
|||
} |
|||
|
|||
export default { |
|||
install(Vue) { |
|||
if (typeof document === 'undefined' || !document.body) return |
|||
initDialogDrag(Vue) |
|||
} |
|||
} |
|||
@ -0,0 +1,569 @@ |
|||
/** |
|||
* 冲突检测工具:时间冲突、空间冲突 |
|||
* - 时间:航线时间窗重叠、无法按时到达、提前到达、盘旋时间不足、资源占用缓冲 |
|||
* - 空间:航迹最小间隔、摆放平台距离过小、禁限区入侵(考虑高度带)、Safety Column 间距 |
|||
*/ |
|||
|
|||
/** 冲突类型 */ |
|||
export const CONFLICT_TYPE = { |
|||
TIME: 'time', |
|||
SPACE: 'space' |
|||
} |
|||
|
|||
/** 默认冲突配置(可由界面配置覆盖) */ |
|||
export const defaultConflictConfig = { |
|||
// 时间
|
|||
timeWindowOverlapMinutes: 0, // 时间窗重叠判定:两航线时间窗重叠即报(0=任意重叠)
|
|||
resourceBufferMinutes: 0, // 资源占用前后缓冲(分钟)
|
|||
/** 到达时间容差(秒):早于或晚于计划在此秒数内不报冲突,超出才报 */ |
|||
timeToleranceSeconds: 5, |
|||
/** 速度容差(km/h):与所需/建议速度差在此范围内不报冲突,超出才报 */ |
|||
speedToleranceKmh: 20, |
|||
// 空间
|
|||
minTrackSeparationMeters: 5000, // 推演中两平台航迹最小间隔(米)
|
|||
minPlatformPlacementMeters: 3000, // 摆放平台图标最小间距(米)
|
|||
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
|
|||
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
|
|||
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
|
|||
/** 隐藏圆柱体(Safety Column):水平半径 R、垂直高度差阈值 H、同点先后时间阈值 ΔT(秒) */ |
|||
safetyColumnRadiusMeters: 10000, // R:单柱水平半径(米)
|
|||
safetyColumnHeightMeters: 300, // H:两机高度差小于此值且水平重叠时视为垂直方向不安全(米)
|
|||
safetyColumnTimeThresholdSeconds: 60, // ΔT:先后经过同一位置的时间间隔小于此值报时间类冲突(秒)
|
|||
/** 空间重叠采样步长(分钟),默认 0.5 即 30 秒 */ |
|||
safetyColumnSpatialStepMinutes: 0.5, |
|||
/** 同点先后判定:时间维采样步长(分钟),默认 5 秒 */ |
|||
safetyColumnTemporalStepMinutes: 5 / 60, |
|||
/** 判定「同一物理位置」的网格:经纬度量化(度),约百米量级 */ |
|||
safetyColumnTemporalGridLngDeg: 0.001, |
|||
safetyColumnTemporalGridLatDeg: 0.001, |
|||
safetyColumnTemporalGridAltMeters: 100 |
|||
} |
|||
|
|||
/** |
|||
* 计算两点地表距离(米),近似球面 |
|||
*/ |
|||
export function distanceMeters(lng1, lat1, alt1, lng2, lat2, alt2) { |
|||
const R = 6371000 // 地球半径米
|
|||
const toRad = x => (x * Math.PI) / 180 |
|||
const φ1 = toRad(lat1) |
|||
const φ2 = toRad(lat2) |
|||
const Δφ = toRad(lat2 - lat1) |
|||
const Δλ = toRad(lng2 - lng1) |
|||
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2 |
|||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) |
|||
const horizontal = R * c |
|||
const dAlt = (alt2 != null ? Number(alt2) : 0) - (alt1 != null ? Number(alt1) : 0) |
|||
return Math.sqrt(horizontal * horizontal + dAlt * dAlt) |
|||
} |
|||
|
|||
/** |
|||
* 两段时间段是否重叠 [s1,e1] 与 [s2,e2](分钟数) |
|||
*/ |
|||
export function timeRangesOverlap(s1, e1, s2, e2, bufferMinutes = 0) { |
|||
const a1 = s1 - bufferMinutes |
|||
const b1 = e1 + bufferMinutes |
|||
const a2 = s2 - bufferMinutes |
|||
const b2 = e2 + bufferMinutes |
|||
return !(b1 < a2 || b2 < a1) |
|||
} |
|||
|
|||
/** |
|||
* 时间冲突:航线时间窗重叠 |
|||
* routes: [{ id, name, waypoints: [{ startTime }] }],waypoints 需含 startTime(如 K+00:10) |
|||
* waypointStartTimeToMinutes: (startTimeStr) => number |
|||
*/ |
|||
export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, config = {}) { |
|||
const buffer = config.resourceBufferMinutes != null ? config.resourceBufferMinutes : defaultConflictConfig.resourceBufferMinutes |
|||
const list = [] |
|||
const timeRanges = routes.map(r => { |
|||
if (!r.waypoints || r.waypoints.length === 0) return { routeId: r.id, routeName: r.name || `航线${r.id}`, min: 0, max: 0 } |
|||
const minutes = r.waypoints.map(w => waypointStartTimeToMinutes(w.startTime)).filter(m => Number.isFinite(m)) |
|||
const min = minutes.length ? Math.min(...minutes) : 0 |
|||
const max = minutes.length ? Math.max(...minutes) : 0 |
|||
return { routeId: r.id, routeName: r.name || `航线${r.id}`, min, max } |
|||
}) |
|||
for (let i = 0; i < timeRanges.length; i++) { |
|||
for (let j = i + 1; j < timeRanges.length; j++) { |
|||
const a = timeRanges[i] |
|||
const b = timeRanges[j] |
|||
if (a.min === a.max && b.min === b.max) continue |
|||
if (timeRangesOverlap(a.min, a.max, b.min, b.max, buffer)) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.TIME, |
|||
subType: 'time_window_overlap', |
|||
title: '航线时间窗重叠', |
|||
routeIds: [a.routeId, b.routeId], |
|||
routeNames: [a.routeName, b.routeName], |
|||
time: `${formatKLabel(a.min)} ~ ${formatKLabel(a.max)} 与 ${formatKLabel(b.min)} ~ ${formatKLabel(b.max)} 重叠`, |
|||
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
export function formatMinutes(m) { |
|||
const h = Math.floor(Math.abs(m) / 60) |
|||
const min = Math.floor(Math.abs(m) % 60) |
|||
const sign = m >= 0 ? '+' : '-' |
|||
return `${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}` |
|||
} |
|||
|
|||
/** 展示用完整 K 时标签:K+00:10(formatMinutes 已含 ±,勿再拼接「K+」前缀以免出现 K++) */ |
|||
export function formatKLabel(m) { |
|||
return `K${formatMinutes(m)}` |
|||
} |
|||
|
|||
/** |
|||
* 地表水平距离(米),不含高度 |
|||
*/ |
|||
export function horizontalDistanceMeters(lng1, lat1, lng2, lat2) { |
|||
const R = 6371000 |
|||
const toRad = x => (x * Math.PI) / 180 |
|||
const φ1 = toRad(lat1) |
|||
const φ2 = toRad(lat2) |
|||
const Δφ = toRad(lat2 - lat1) |
|||
const Δλ = toRad(lng2 - lng1) |
|||
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2 |
|||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) |
|||
return R * c |
|||
} |
|||
|
|||
/** |
|||
* Safety Column 空间冲突:同一时刻 t,水平距离 < 2R 且 |Δ高度| < H |
|||
* getSpeedKmhAtMinutes:可选,(routeId, minutesFromK) => number,用于给出可执行的航速建议 |
|||
*/ |
|||
export function detectSafetyColumnSpatial( |
|||
routeIds, |
|||
minMinutes, |
|||
maxMinutes, |
|||
getPositionAtMinutesFromK, |
|||
config = {}, |
|||
routeNamesById = {}, |
|||
canPairFn = null, |
|||
getSpeedKmhAtMinutes = null |
|||
) { |
|||
const R = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters |
|||
const H = config.safetyColumnHeightMeters != null ? Number(config.safetyColumnHeightMeters) : defaultConflictConfig.safetyColumnHeightMeters |
|||
const step = config.safetyColumnSpatialStepMinutes != null ? Number(config.safetyColumnSpatialStepMinutes) : defaultConflictConfig.safetyColumnSpatialStepMinutes |
|||
const list = [] |
|||
const twoR = 2 * R |
|||
const sampleTimes = [] |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t) |
|||
for (let i = 0; i < routeIds.length; i++) { |
|||
for (let j = i + 1; j < routeIds.length; j++) { |
|||
const ridA = routeIds[i] |
|||
const ridB = routeIds[j] |
|||
if (canPairFn && !canPairFn(ridA, ridB)) continue |
|||
for (const t of sampleTimes) { |
|||
const posA = getPositionAtMinutesFromK(ridA, t) |
|||
const posB = getPositionAtMinutesFromK(ridB, t) |
|||
if (!posA || !posB || posA.lng == null || posB.lng == null) continue |
|||
const horiz = horizontalDistanceMeters(posA.lng, posA.lat, posB.lng, posB.lat) |
|||
const altA = posA.alt != null ? Number(posA.alt) : 0 |
|||
const altB = posB.alt != null ? Number(posB.alt) : 0 |
|||
const dAlt = Math.abs(altA - altB) |
|||
if (horiz < twoR && dAlt < H) { |
|||
const needVertSep = Math.max(0, H - dAlt) |
|||
const nameA = routeNamesById[ridA] || `航线${ridA}` |
|||
const nameB = routeNamesById[ridB] || `航线${ridB}` |
|||
const vA = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridA, t) : null |
|||
const vB = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridB, t) : null |
|||
const vNumA = vA != null && Number.isFinite(vA) && vA > 0 ? vA : null |
|||
const vNumB = vB != null && Number.isFinite(vB) && vB > 0 ? vB : null |
|||
let vRef = 800 |
|||
if (vNumA != null && vNumB != null) vRef = (vNumA + vNumB) / 2 |
|||
else if (vNumA != null) vRef = vNumA |
|||
else if (vNumB != null) vRef = vNumB |
|||
const horizDeficitM = Math.max(0, twoR - horiz) |
|||
const reductionKmh = Math.min(150, Math.max(35, Math.round((horizDeficitM / 1000) * 10))) |
|||
const vSuggest = Math.max(120, Math.round(vRef - reductionKmh)) |
|||
const kShiftMin = 1 |
|||
const kLater = formatKLabel(t + kShiftMin) |
|||
const kEarlier = formatKLabel(t - kShiftMin) |
|||
const speedLine = |
|||
vNumA != null && vNumB != null |
|||
? `② 按当前推演航段速度(「${nameA}」约 ${Math.round(vNumA)} km/h、「${nameB}」约 ${Math.round(vNumB)} km/h),建议将其中一机在本段调至约 ${vSuggest} km/h(约比两机均值低 ${Math.round(vRef - vSuggest)} km/h),使通过该位置的时刻与另一机至少错开约 1 分钟。` |
|||
: `② 按参考航段速度约 ${Math.round(vRef)} km/h 计,建议将其中一机在本段调至约 ${vSuggest} km/h(约降低 ${Math.round(vRef - vSuggest)} km/h),使通过该冲突区域的先后间隔至少约 1 分钟。` |
|||
const kTimeLine = |
|||
`③ 将其中一条航线在冲突时刻附近航点的相对 K 时整体推迟或提前至少 ${kShiftMin} 分钟:例如由 ${formatKLabel(t)} 调至 ${kLater} 或更晚,或调至 ${kEarlier} 或更早。` |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'safety_column_spatial', |
|||
title: '飞机间距不足', |
|||
routeIds: [ridA, ridB], |
|||
routeNames: [nameA, nameB], |
|||
time: `约 ${formatKLabel(t)}`, |
|||
position: `经度 ${((posA.lng + posB.lng) / 2).toFixed(5)}°, 纬度 ${((posA.lat + posB.lat) / 2).toFixed(5)}°, 高度约 ${((altA + altB) / 2).toFixed(0)} m`, |
|||
suggestion: |
|||
`两机在相同时刻占位过近:水平间距约 ${(horiz / 1000).toFixed(2)} km,高度差约 ${dAlt.toFixed(0)} m。` + |
|||
`① 将其中一条航线在冲突航段附近的高度上调或下调,使两机垂直间隔至少再拉开约 ${Math.ceil(needVertSep)} m(择一执行即可)。` + |
|||
speedLine + |
|||
kTimeLine, |
|||
severity: 'high', |
|||
positionLng: (posA.lng + posB.lng) / 2, |
|||
positionLat: (posA.lat + posB.lat) / 2, |
|||
positionAlt: (altA + altB) / 2, |
|||
minutesFromK: t, |
|||
safetyColumnHorizM: horiz, |
|||
safetyColumnDAltM: dAlt |
|||
}) |
|||
break |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 隐藏圆柱体 — 时间冲突:不同航线先后经过同一物理位置,时间间隔 < ΔT(秒) |
|||
* 使用网格量化「同一位置」,采样步长应小于 ΔT 以避免漏检。 |
|||
*/ |
|||
export function detectSafetyColumnTemporal( |
|||
routeIds, |
|||
minMinutes, |
|||
maxMinutes, |
|||
getPositionAtMinutesFromK, |
|||
config = {}, |
|||
routeNamesById = {} |
|||
) { |
|||
const deltaSec = config.safetyColumnTimeThresholdSeconds != null |
|||
? Number(config.safetyColumnTimeThresholdSeconds) |
|||
: defaultConflictConfig.safetyColumnTimeThresholdSeconds |
|||
const step = config.safetyColumnTemporalStepMinutes != null |
|||
? Number(config.safetyColumnTemporalStepMinutes) |
|||
: defaultConflictConfig.safetyColumnTemporalStepMinutes |
|||
const gLng = config.safetyColumnTemporalGridLngDeg != null |
|||
? Number(config.safetyColumnTemporalGridLngDeg) |
|||
: defaultConflictConfig.safetyColumnTemporalGridLngDeg |
|||
const gLat = config.safetyColumnTemporalGridLatDeg != null |
|||
? Number(config.safetyColumnTemporalGridLatDeg) |
|||
: defaultConflictConfig.safetyColumnTemporalGridLatDeg |
|||
const gAlt = config.safetyColumnTemporalGridAltMeters != null |
|||
? Number(config.safetyColumnTemporalGridAltMeters) |
|||
: defaultConflictConfig.safetyColumnTemporalGridAltMeters |
|||
|
|||
const cellMap = new Map() |
|||
|
|||
for (const rid of routeIds) { |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) { |
|||
const pos = getPositionAtMinutesFromK(rid, t) |
|||
if (!pos || pos.lng == null || pos.lat == null) continue |
|||
const lng = Number(pos.lng) |
|||
const lat = Number(pos.lat) |
|||
const altN = Number(pos.alt) || 0 |
|||
const ix = gLng > 0 ? Math.floor(lng / gLng) : 0 |
|||
const iy = gLat > 0 ? Math.floor(lat / gLat) : 0 |
|||
const iz = gAlt > 0 ? Math.floor(altN / gAlt) : 0 |
|||
const key = `${ix}_${iy}_${iz}` |
|||
let arr = cellMap.get(key) |
|||
if (!arr) { |
|||
arr = [] |
|||
cellMap.set(key, arr) |
|||
} |
|||
if (arr.length < 200) { |
|||
arr.push({ routeId: rid, tMinutes: t, lng: pos.lng, lat: pos.lat, alt: Number(pos.alt) || 0 }) |
|||
} |
|||
} |
|||
} |
|||
|
|||
const reported = new Set() |
|||
const list = [] |
|||
|
|||
for (const [, samples] of cellMap) { |
|||
if (samples.length < 2) continue |
|||
samples.sort((a, b) => a.tMinutes - b.tMinutes) |
|||
for (let a = 0; a < samples.length; a++) { |
|||
for (let b = a + 1; b < samples.length; b++) { |
|||
const sa = samples[a] |
|||
const sb = samples[b] |
|||
if (sa.routeId === sb.routeId) continue |
|||
const dtSec = Math.abs(sb.tMinutes - sa.tMinutes) * 60 |
|||
if (dtSec <= 1e-6 || dtSec >= deltaSec) continue |
|||
const idA = String(sa.routeId) |
|||
const idB = String(sb.routeId) |
|||
const rLo = idA < idB ? idA : idB |
|||
const rHi = idA < idB ? idB : idA |
|||
const t1 = Math.min(sa.tMinutes, sb.tMinutes) |
|||
const t2 = Math.max(sa.tMinutes, sb.tMinutes) |
|||
const pairKey = `${rLo}_${rHi}_${t1.toFixed(5)}_${t2.toFixed(5)}` |
|||
if (reported.has(pairKey)) continue |
|||
reported.add(pairKey) |
|||
|
|||
const nameA = routeNamesById[sa.routeId] || `航线${sa.routeId}` |
|||
const nameB = routeNamesById[sb.routeId] || `航线${sb.routeId}` |
|||
const tEarly = sa.tMinutes <= sb.tMinutes ? sa : sb |
|||
const tLate = sa.tMinutes <= sb.tMinutes ? sb : sa |
|||
const needDelaySec = Math.ceil(deltaSec - dtSec) |
|||
const midLng = (tEarly.lng + tLate.lng) / 2 |
|||
const midLat = (tEarly.lat + tLate.lat) / 2 |
|||
const midAlt = (tEarly.alt + tLate.alt) / 2 |
|||
|
|||
list.push({ |
|||
type: CONFLICT_TYPE.TIME, |
|||
subType: 'safety_column_temporal', |
|||
title: '先后经过同点时间过近', |
|||
routeIds: [sa.routeId, sb.routeId], |
|||
routeNames: [nameA, nameB], |
|||
time: `先后约 ${Math.round(dtSec)} s(约 ${formatKLabel(tEarly.tMinutes)} 与 ${formatKLabel(tLate.tMinutes)})`, |
|||
position: `经度 ${midLng.toFixed(5)}°, 纬度 ${midLat.toFixed(5)}°, 高度约 ${midAlt.toFixed(0)} m`, |
|||
suggestion: |
|||
`两机先后经过同一空域位置,时间间隔仅约 ${Math.round(dtSec)} s,低于安全先后间隔要求。` + |
|||
`① 将其中一条航线相关航点的相对 K 时整体前移或后移,使先后到达间隔至少再拉开约 ${needDelaySec} s 以上。` + |
|||
`② 微调其中一机在邻近航段的飞行速度,使到达该位置的时刻错开。` + |
|||
`③ 若仍受航路结构限制,可为其中一机分配不同高度层,通过垂直间隔避免同点先后过近。`, |
|||
severity: 'high', |
|||
positionLng: midLng, |
|||
positionLat: midLat, |
|||
positionAlt: midAlt, |
|||
minutesFromK: tEarly.tMinutes, |
|||
safetyColumnTemporalDtSec: dtSec |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:推演航迹最小间隔 |
|||
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null |
|||
* routeIds: 参与推演的航线 id 列表 |
|||
* minMinutes, maxMinutes: 推演时间范围(相对 K 分钟) |
|||
*/ |
|||
export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, config = {}) { |
|||
const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters |
|||
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes |
|||
const list = [] |
|||
const sampleTimes = [] |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t) |
|||
for (let i = 0; i < routeIds.length; i++) { |
|||
for (let j = i + 1; j < routeIds.length; j++) { |
|||
const ridA = routeIds[i] |
|||
const ridB = routeIds[j] |
|||
for (const t of sampleTimes) { |
|||
const posA = getPositionAtMinutesFromK(ridA, t) |
|||
const posB = getPositionAtMinutesFromK(ridB, t) |
|||
if (!posA || !posB || posA.lng == null || posB.lng == null) continue |
|||
const d = distanceMeters(posA.lng, posA.lat, posA.alt, posB.lng, posB.lat, posB.alt) |
|||
if (d < minSep) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'track_separation', |
|||
title: '航迹间隔过小', |
|||
routeIds: [ridA, ridB], |
|||
time: `约 ${formatKLabel(t)}`, |
|||
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`, |
|||
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`, |
|||
severity: 'high', |
|||
positionLng: (posA.lng + posB.lng) / 2, |
|||
positionLat: (posA.lat + posB.lat) / 2, |
|||
positionAlt: (posA.alt + posB.alt) / 2, |
|||
minutesFromK: t, |
|||
distanceMeters: d |
|||
}) |
|||
break // 同一对航线只报一次(取首次发生时刻)
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:摆放平台图标距离过小 |
|||
* platformIcons: [{ id, lng, lat, name? }] |
|||
*/ |
|||
export function detectPlatformPlacementTooClose(platformIcons, config = {}) { |
|||
const minDist = config.minPlatformPlacementMeters != null ? config.minPlatformPlacementMeters : defaultConflictConfig.minPlatformPlacementMeters |
|||
const list = [] |
|||
for (let i = 0; i < platformIcons.length; i++) { |
|||
for (let j = i + 1; j < platformIcons.length; j++) { |
|||
const a = platformIcons[i] |
|||
const b = platformIcons[j] |
|||
const lng1 = Number(a.lng) |
|||
const lat1 = Number(a.lat) |
|||
const lng2 = Number(b.lng) |
|||
const lat2 = Number(b.lat) |
|||
if (!Number.isFinite(lng1) || !Number.isFinite(lat1) || !Number.isFinite(lng2) || !Number.isFinite(lat2)) continue |
|||
const d = distanceMeters(lng1, lat1, 0, lng2, lat2, 0) |
|||
if (d < minDist) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'platform_placement', |
|||
title: '平台摆放距离过小', |
|||
routeIds: [], |
|||
routeNames: [a.name || `平台${a.id}`, b.name || `平台${b.id}`], |
|||
position: `经度 ${((lng1 + lng2) / 2).toFixed(5)}°, 纬度 ${((lat1 + lat2) / 2).toFixed(5)}°`, |
|||
suggestion: `两平台距离约 ${(d / 1000).toFixed(1)} km,小于最小摆放间隔 ${minDist / 1000} km。建议移动其中一方或增大最小间隔配置。`, |
|||
severity: 'medium', |
|||
positionLng: (lng1 + lng2) / 2, |
|||
positionLat: (lat1 + lat2) / 2, |
|||
positionAlt: 0, |
|||
distanceMeters: d |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 点是否在多边形内(二维,不考虑高度) |
|||
* points: [{ lng, lat }] 或 [{ x, y }],顺时针或逆时针 |
|||
*/ |
|||
export function pointInPolygon(lng, lat, points) { |
|||
if (!points || points.length < 3) return false |
|||
const x = Number(lng) |
|||
const y = Number(lat) |
|||
let inside = false |
|||
const n = points.length |
|||
for (let i = 0, j = n - 1; i < n; j = i++) { |
|||
const pi = points[i] |
|||
const pj = points[j] |
|||
const xi = pi.lng != null ? Number(pi.lng) : Number(pi.x) |
|||
const yi = pi.lat != null ? Number(pi.lat) : Number(pi.y) |
|||
const xj = pj.lng != null ? Number(pj.lng) : Number(pj.x) |
|||
const yj = pj.lat != null ? Number(pj.lat) : Number(pj.y) |
|||
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside |
|||
} |
|||
return inside |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:禁限区入侵(考虑高度带) |
|||
* 每条航线在推演时间内采样位置,若落入禁限区多边形且高度在 [altMin, altMax] 内则报 |
|||
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } |
|||
* restrictedZones: [{ name, points: [{lng,lat}], altMin?, altMax? }],points 可从 frontend_drawings 中 type 为 polygon 且 name 含禁限关键词的实体解析 |
|||
*/ |
|||
export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, restrictedZones, config = {}) { |
|||
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes |
|||
const useAlt = config.useRestrictedAltitudeBand !== false |
|||
const list = [] |
|||
if (!restrictedZones || restrictedZones.length === 0) return list |
|||
for (const rid of routeIds) { |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) { |
|||
const pos = getPositionAtMinutesFromK(rid, t) |
|||
if (!pos || pos.lng == null) continue |
|||
const alt = pos.alt != null ? Number(pos.alt) : 0 |
|||
for (const zone of restrictedZones) { |
|||
const points = zone.points || zone.positions |
|||
if (!points || points.length < 3) continue |
|||
const inPoly = pointInPolygon(pos.lng, pos.lat, points) |
|||
if (!inPoly) continue |
|||
const altMin = zone.altMin != null ? Number(zone.altMin) : -Infinity |
|||
const altMax = zone.altMax != null ? Number(zone.altMax) : Infinity |
|||
const inAlt = !useAlt || (alt >= altMin && alt <= altMax) |
|||
if (inAlt) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'restricted_zone', |
|||
title: '禁限区入侵', |
|||
routeIds: [rid], |
|||
zoneName: zone.name || '禁限区', |
|||
time: `约 ${formatKLabel(t)}`, |
|||
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`, |
|||
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`, |
|||
severity: 'high', |
|||
positionLng: pos.lng, |
|||
positionLat: pos.lat, |
|||
positionAlt: alt, |
|||
minutesFromK: t |
|||
}) |
|||
break // 该航线该时刻只报一个区
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 从 frontend_drawings 的 entities 中解析禁限区(polygon/rectangle/circle/sector) |
|||
* entity: { type, label/name, points/positions, data: { altMin, altMax } } |
|||
*/ |
|||
export function parseRestrictedZonesFromDrawings(entities, keywords = defaultConflictConfig.restrictedZoneNameKeywords) { |
|||
const zones = [] |
|||
if (!entities || !Array.isArray(entities)) return zones |
|||
const nameMatches = (name) => { |
|||
const n = (name || '').toString() |
|||
return keywords.some(kw => n.includes(kw)) |
|||
} |
|||
for (const e of entities) { |
|||
const name = (e.label || e.name || (e.data && e.data.name) || '').toString() |
|||
if (!nameMatches(name)) continue |
|||
let points = e.points || (e.data && e.data.points) || (e.positions && e.positions.map(p => { |
|||
if (p.lng != null) return { lng: p.lng, lat: p.lat } |
|||
if (p.x != null) return { lng: p.x, lat: p.y } |
|||
return null |
|||
}).filter(Boolean)) |
|||
if (!points && e.data) { |
|||
if (e.type === 'circle' && e.data.center) { |
|||
const c = e.data.center |
|||
const r = (e.data.radius || 0) / 111320 // 约米转度
|
|||
const lng = c.lng != null ? c.lng : c.x |
|||
const lat = c.lat != null ? c.lat : c.y |
|||
const n = 32 |
|||
points = [] |
|||
for (let i = 0; i <= n; i++) { |
|||
const angle = (i / n) * 2 * Math.PI |
|||
points.push({ lng: lng + r * Math.cos(angle), lat: lat + r * Math.sin(angle) }) |
|||
} |
|||
} else if (e.type === 'rectangle' && (e.data.bounds || e.data.coordinates || (e.data.points && e.data.points.length >= 2))) { |
|||
const b = e.data.bounds || e.data.coordinates || {} |
|||
const west = b.west ?? b.minLng |
|||
const south = b.south ?? b.minLat |
|||
const east = b.east ?? b.maxLng |
|||
const north = b.north ?? b.maxLat |
|||
if (e.data.points && e.data.points.length >= 2) { |
|||
points = e.data.points |
|||
} else if (west != null && south != null && east != null && north != null) { |
|||
points = [ |
|||
{ lng: west, lat: south }, |
|||
{ lng: east, lat: south }, |
|||
{ lng: east, lat: north }, |
|||
{ lng: west, lat: north } |
|||
] |
|||
} |
|||
} |
|||
} |
|||
if (!points || points.length < 3) continue |
|||
const altMin = e.data && e.data.altMin != null ? e.data.altMin : undefined |
|||
const altMax = e.data && e.data.altMax != null ? e.data.altMax : undefined |
|||
zones.push({ name, points, altMin, altMax }) |
|||
} |
|||
return zones |
|||
} |
|||
|
|||
/** |
|||
* 合并并规范化冲突列表,统一 id、severity、category 等供 UI 展示 |
|||
*/ |
|||
export function normalizeConflictList(conflictsByType, startId = 1) { |
|||
const list = [] |
|||
let id = startId |
|||
for (const c of conflictsByType) { |
|||
list.push({ |
|||
id: id++, |
|||
type: c.type || CONFLICT_TYPE.TIME, |
|||
subType: c.subType || '', |
|||
title: c.title || '冲突', |
|||
routeName: c.routeNames && c.routeNames.length ? c.routeNames.join('、') : c.routeName, |
|||
routeIds: c.routeIds || [], |
|||
fromWaypoint: c.fromWaypoint, |
|||
toWaypoint: c.toWaypoint, |
|||
time: c.time, |
|||
position: c.position, |
|||
suggestion: c.suggestion, |
|||
severity: c.severity || 'high', |
|||
positionLng: c.positionLng, |
|||
positionLat: c.positionLat, |
|||
positionAlt: c.positionAlt, |
|||
minutesFromK: c.minutesFromK, |
|||
holdCenter: c.holdCenter, |
|||
...c |
|||
}) |
|||
} |
|||
return list |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
/** |
|||
* 解析背景图/图片 URL |
|||
* - data:image/... (base64) 或 http(s):// 开头:原样返回
|
|||
* - 否则视为若依 profile 路径,拼接 base API |
|||
*/ |
|||
export function resolveImageUrl(img) { |
|||
if (!img) return '' |
|||
if (img.startsWith('data:') || img.startsWith('http://') || img.startsWith('https://')) { |
|||
return img |
|||
} |
|||
const base = process.env.VUE_APP_BASE_API || '' |
|||
const path = img.startsWith('/') ? img : '/' + img |
|||
return base + path |
|||
} |
|||
@ -0,0 +1,130 @@ |
|||
/** |
|||
* 与推演时间轴相关的“取点”工具函数。 |
|||
* 该文件用于在主线程/Worker 共享一套插值逻辑,避免在大数据量冲突检测时重复实现。 |
|||
*/ |
|||
|
|||
/** 两点间近似距离(米),含高度差 */ |
|||
export function segmentDistanceMeters(a, b) { |
|||
const R = 6371000 |
|||
const lat1 = (Number(a.lat) * Math.PI) / 180 |
|||
const lat2 = (Number(b.lat) * Math.PI) / 180 |
|||
const dlat = ((Number(b.lat) - Number(a.lat)) * Math.PI) / 180 |
|||
const dlng = ((Number(b.lng) - Number(a.lng)) * Math.PI) / 180 |
|||
const sinDLat = Math.sin(dlat / 2) |
|||
const sinDLng = Math.sin(dlng / 2) |
|||
const aa = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng |
|||
const c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa)) |
|||
const horizontal = R * c |
|||
const dalt = (Number(b.alt) || 0) - (Number(a.alt) || 0) |
|||
return Math.sqrt(horizontal * horizontal + dalt * dalt) |
|||
} |
|||
|
|||
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */ |
|||
export function getPositionAlongPathSlice(pathSlice, t) { |
|||
if (!pathSlice || pathSlice.length === 0) return null |
|||
if (pathSlice.length === 1 || t <= 0) return pathSlice[0] |
|||
if (t >= 1) return pathSlice[pathSlice.length - 1] |
|||
|
|||
let totalLen = 0 |
|||
const lengths = [0] |
|||
for (let i = 1; i < pathSlice.length; i++) { |
|||
totalLen += segmentDistanceMeters(pathSlice[i - 1], pathSlice[i]) |
|||
lengths.push(totalLen) |
|||
} |
|||
const targetDist = t * totalLen |
|||
let idx = 0 |
|||
while (idx < lengths.length - 1 && lengths[idx + 1] < targetDist) idx++ |
|||
|
|||
const p0 = pathSlice[idx] |
|||
const p1 = pathSlice[idx + 1] |
|||
const segLen = lengths[idx + 1] - lengths[idx] |
|||
const segT = segLen > 0 ? (targetDist - lengths[idx]) / segLen : 0 |
|||
return { |
|||
lng: Number(p0.lng) + (Number(p1.lng) - Number(p0.lng)) * segT, |
|||
lat: Number(p0.lat) + (Number(p1.lat) - Number(p0.lat)) * segT, |
|||
alt: (Number(p0.alt) || 0) + ((Number(p1.alt) || 0) - (Number(p0.alt) || 0)) * segT |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold。 |
|||
* segments: buildRouteTimeline 产物(在主线程构建后传入 worker 使用) |
|||
*/ |
|||
export function getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) { |
|||
if (!segments || segments.length === 0) return null |
|||
if (minutesFromK <= segments[0].startTime) return segments[0].startPos |
|||
|
|||
const last = segments[segments.length - 1] |
|||
if (minutesFromK >= last.endTime) { |
|||
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) { |
|||
return path[segmentEndIndices[last.legIndex]] |
|||
} |
|||
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1] |
|||
return last.endPos |
|||
} |
|||
|
|||
for (let i = 0; i < segments.length; i++) { |
|||
const s = segments[i] |
|||
if (minutesFromK < s.endTime) { |
|||
const denom = (s.endTime - s.startTime) |
|||
const t = denom > 0 ? Math.max(0, Math.min(1, (minutesFromK - s.startTime) / denom)) : 0 |
|||
|
|||
if (s.type === 'wait') { |
|||
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|||
const endIdx = segmentEndIndices[s.legIndex] |
|||
if (path[endIdx]) return path[endIdx] |
|||
} |
|||
return s.startPos |
|||
} |
|||
|
|||
if (s.type === 'hold' && s.holdPath && s.holdPath.length) { |
|||
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) { |
|||
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60) |
|||
const n = Math.max(0, s.holdN != null ? s.holdN : 0) |
|||
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength |
|||
if (totalFly <= 0) return s.startPos |
|||
if (distM >= totalFly) return s.endPos |
|||
if (distM < n * s.holdLoopLength) { |
|||
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength |
|||
const tPath = distOnLoop / s.holdLoopLength |
|||
return getPositionAlongPathSlice(s.holdClosedLoopPath, tPath) |
|||
} |
|||
const distToExit = distM - n * s.holdLoopLength |
|||
const exitDist = s.holdExitDistanceOnLoop || 1 |
|||
const tPath = Math.min(1, distToExit / exitDist) |
|||
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) { |
|||
return getPositionAlongPathSlice(s.holdEntryToExitPath, tPath) |
|||
} |
|||
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength) |
|||
} |
|||
if (s.holdGeometricDist != null && s.holdGeometricDist > 0 && s.speedKmh != null) { |
|||
const speedMpMin = (s.speedKmh * 1000) / 60 |
|||
const distM = (minutesFromK - s.startTime) * speedMpMin |
|||
if (distM >= s.holdGeometricDist) return s.endPos |
|||
const tPath = Math.max(0, Math.min(1, distM / s.holdGeometricDist)) |
|||
return getPositionAlongPathSlice(s.holdPath, tPath) |
|||
} |
|||
return getPositionAlongPathSlice(s.holdPath, t) |
|||
} |
|||
|
|||
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) { |
|||
return getPositionAlongPathSlice(s.pathSlice, t) |
|||
} |
|||
|
|||
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { |
|||
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1] |
|||
const endIdx = segmentEndIndices[s.legIndex] |
|||
const pathSlice = path.slice(startIdx, endIdx + 1) |
|||
if (pathSlice.length > 0) return getPositionAlongPathSlice(pathSlice, t) |
|||
} |
|||
|
|||
return { |
|||
lng: Number(s.startPos.lng) + (Number(s.endPos.lng) - Number(s.startPos.lng)) * t, |
|||
lat: Number(s.startPos.lat) + (Number(s.endPos.lat) - Number(s.startPos.lat)) * t, |
|||
alt: (Number(s.startPos.alt) || 0) + ((Number(s.endPos.alt) || 0) - (Number(s.startPos.alt) || 0)) * t |
|||
} |
|||
} |
|||
} |
|||
|
|||
return last.endPos |
|||
} |
|||
@ -0,0 +1,334 @@ |
|||
/** |
|||
* WebSocket 房间连接服务(SockJS + STOMP) |
|||
*/ |
|||
import SockJS from 'sockjs-client' |
|||
import { Client } from '@stomp/stompjs' |
|||
import { getToken } from '@/utils/auth' |
|||
|
|||
const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api' |
|||
|
|||
/** |
|||
* 创建房间 WebSocket 连接 |
|||
* @param {Object} options |
|||
* @param {string|number} options.roomId - 房间 ID |
|||
* @param {Function} options.onMembers - 收到成员列表回调 (members) => {} |
|||
* @param {Function} options.onMemberJoined - 成员加入回调 (member) => {} |
|||
* @param {Function} options.onMemberLeft - 成员离开回调 (member, sessionId) => {} |
|||
* @param {Function} options.onChatMessage - 群聊消息回调 (msg) => {} |
|||
* @param {Function} options.onPrivateChat - 私聊消息回调 (msg) => {} |
|||
* @param {Function} options.onChatHistory - 群聊历史回调 (messages) => {} |
|||
* @param {Function} options.onPrivateChatHistory - 私聊历史回调 (targetUserId, messages) => {} |
|||
* @param {Function} options.onSyncRouteVisibility - 航线显隐同步回调 (routeId, visible, senderUserId) => {} |
|||
* @param {Function} options.onSyncWaypoints - 航点变更同步回调 (routeId, senderUserId) => {} |
|||
* @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {} |
|||
* @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {} |
|||
* @param {Function} options.onSyncPlatformStyles - 探测区/威力区样式变更同步回调 (senderUserId) => {} |
|||
* @param {Function} options.onRoomState - 新加入时收到的房间状态 (visibleRouteIds: number[]) => {} |
|||
* @param {Function} options.onObjectEditLock - 对象被某成员编辑锁定 (msg: { objectType, objectId, editor }) => {} |
|||
* @param {Function} options.onObjectEditUnlock - 对象编辑解锁 (msg: { objectType, objectId, sessionId }) => {} |
|||
* @param {Function} options.onConnected - 连接成功回调 |
|||
* @param {Function} options.onDisconnected - 断开回调 |
|||
* @param {Function} options.onError - 错误回调 |
|||
* @param {string} [options.deviceId] - 设备标识 |
|||
*/ |
|||
export function createRoomWebSocket(options) { |
|||
const { |
|||
roomId, |
|||
onMembers, |
|||
onMemberJoined, |
|||
onMemberLeft, |
|||
onChatMessage, |
|||
onPrivateChat, |
|||
onChatHistory, |
|||
onPrivateChatHistory, |
|||
onSyncRouteVisibility, |
|||
onSyncWaypoints, |
|||
onSyncPlatformIcons, |
|||
onSyncRoomDrawings, |
|||
onSyncPlatformStyles, |
|||
onRoomState, |
|||
onObjectEditLock, |
|||
onObjectEditUnlock, |
|||
onConnected, |
|||
onDisconnected, |
|||
onError, |
|||
deviceId = 'web-' + Math.random().toString(36).slice(2, 10) |
|||
} = options |
|||
|
|||
let client = null |
|||
let roomSubscription = null |
|||
let privateSubscription = null |
|||
let heartbeatTimer = null |
|||
let reconnectAttempts = 0 |
|||
const maxReconnectAttempts = 10 |
|||
const reconnectDelay = 2000 |
|||
|
|||
function getWsUrl() { |
|||
const token = getToken() |
|||
const base = window.location.origin + WS_BASE |
|||
const sep = base.includes('?') ? '&' : '?' |
|||
return base + '/ws' + (token ? sep + 'token=' + encodeURIComponent(token) : '') |
|||
} |
|||
|
|||
function sendJoin() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'JOIN', deviceId }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendLeave() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'LEAVE' }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendPing() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'PING' }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendChat(content) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'CHAT', content }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendPrivateChat(targetUserId, targetUserName, content) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'PRIVATE_CHAT', targetUserId, targetUserName, content }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendPrivateChatHistoryRequest(targetUserId) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'PRIVATE_CHAT_HISTORY_REQUEST', targetUserId }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendSyncRouteVisibility(routeId, visible) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'SYNC_ROUTE_VISIBILITY', routeId, visible }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendSyncWaypoints(routeId) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'SYNC_WAYPOINTS', routeId }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendSyncPlatformIcons() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'SYNC_PLATFORM_ICONS' }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendSyncRoomDrawings() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'SYNC_ROOM_DRAWINGS' }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function sendSyncPlatformStyles() { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'SYNC_PLATFORM_STYLES' }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
/** 发送:当前成员锁定某对象进入编辑 */ |
|||
function sendObjectEditLock(objectType, objectId) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'OBJECT_EDIT_LOCK', objectType, objectId }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
/** 发送:当前成员解锁某对象(结束编辑) */ |
|||
function sendObjectEditUnlock(objectType, objectId) { |
|||
if (client && client.connected) { |
|||
client.publish({ |
|||
destination: '/app/room/' + roomId, |
|||
body: JSON.stringify({ type: 'OBJECT_EDIT_UNLOCK', objectType, objectId }) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function startHeartbeat() { |
|||
stopHeartbeat() |
|||
heartbeatTimer = setInterval(sendPing, 30000) |
|||
} |
|||
|
|||
function stopHeartbeat() { |
|||
if (heartbeatTimer) { |
|||
clearInterval(heartbeatTimer) |
|||
heartbeatTimer = null |
|||
} |
|||
} |
|||
|
|||
function handleRoomMessage(message) { |
|||
try { |
|||
const body = JSON.parse(message.body) |
|||
const type = body.type |
|||
if (type === 'MEMBER_LIST' && body.members) { |
|||
onMembers && onMembers(body.members) |
|||
} else if (type === 'MEMBER_JOINED' && body.member) { |
|||
onMemberJoined && onMemberJoined(body.member) |
|||
} else if (type === 'MEMBER_LEFT' && body.member) { |
|||
onMemberLeft && onMemberLeft(body.member, body.sessionId) |
|||
} else if (type === 'CHAT' && body.sender) { |
|||
onChatMessage && onChatMessage(body) |
|||
} else if (type === 'SYNC_ROUTE_VISIBILITY' && body.routeId != null) { |
|||
onSyncRouteVisibility && onSyncRouteVisibility(body.routeId, !!body.visible, body.senderSessionId) |
|||
} else if (type === 'SYNC_WAYPOINTS' && body.routeId != null) { |
|||
onSyncWaypoints && onSyncWaypoints(body.routeId, body.senderSessionId) |
|||
} else if (type === 'SYNC_PLATFORM_ICONS') { |
|||
onSyncPlatformIcons && onSyncPlatformIcons(body.senderSessionId) |
|||
} else if (type === 'SYNC_ROOM_DRAWINGS') { |
|||
onSyncRoomDrawings && onSyncRoomDrawings(body.senderSessionId) |
|||
} else if (type === 'SYNC_PLATFORM_STYLES') { |
|||
onSyncPlatformStyles && onSyncPlatformStyles(body.senderSessionId) |
|||
} else if (type === 'OBJECT_EDIT_LOCK' && body.objectType != null && body.objectId != null && body.editor) { |
|||
onObjectEditLock && onObjectEditLock(body) |
|||
} else if (type === 'OBJECT_EDIT_UNLOCK' && body.objectType != null && body.objectId != null) { |
|||
onObjectEditUnlock && onObjectEditUnlock(body) |
|||
} |
|||
} catch (e) { |
|||
console.warn('[WebSocket] parse message error:', e) |
|||
} |
|||
} |
|||
|
|||
function handlePrivateMessage(message) { |
|||
try { |
|||
const body = JSON.parse(message.body) |
|||
const type = body.type |
|||
if (type === 'PRIVATE_CHAT' && body.sender) { |
|||
onPrivateChat && onPrivateChat(body) |
|||
} else if (type === 'CHAT_HISTORY' && Array.isArray(body.messages)) { |
|||
onChatHistory && onChatHistory(body.messages) |
|||
} else if (type === 'PRIVATE_CHAT_HISTORY' && body.targetUserId != null && Array.isArray(body.messages)) { |
|||
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages) |
|||
} else if (type === 'ROOM_STATE' && Array.isArray(body.visibleRouteIds)) { |
|||
onRoomState && onRoomState(body.visibleRouteIds) |
|||
} |
|||
} catch (e) { |
|||
console.warn('[WebSocket] parse private message error:', e) |
|||
} |
|||
} |
|||
|
|||
function connect() { |
|||
const token = getToken() |
|||
if (!token) { |
|||
onError && onError(new Error('未登录')) |
|||
return |
|||
} |
|||
|
|||
const sock = new SockJS(getWsUrl()) |
|||
client = new Client({ |
|||
webSocketFactory: () => sock, |
|||
reconnectDelay: 0, |
|||
heartbeatIncoming: 0, |
|||
heartbeatOutgoing: 0, |
|||
onConnect: () => { |
|||
reconnectAttempts = 0 |
|||
roomSubscription = client.subscribe('/topic/room/' + roomId, handleRoomMessage) |
|||
privateSubscription = client.subscribe('/user/queue/private', handlePrivateMessage) |
|||
sendJoin() |
|||
startHeartbeat() |
|||
onConnected && onConnected() |
|||
}, |
|||
onStompError: (frame) => { |
|||
console.warn('[WebSocket] STOMP error:', frame) |
|||
onError && onError(new Error(frame.headers?.message || '连接错误')) |
|||
}, |
|||
onWebSocketClose: () => { |
|||
stopHeartbeat() |
|||
roomSubscription = null |
|||
privateSubscription = null |
|||
onDisconnected && onDisconnected() |
|||
} |
|||
}) |
|||
client.activate() |
|||
} |
|||
|
|||
function disconnect() { |
|||
stopHeartbeat() |
|||
sendLeave() |
|||
if (roomSubscription) { |
|||
roomSubscription.unsubscribe() |
|||
roomSubscription = null |
|||
} |
|||
if (privateSubscription) { |
|||
privateSubscription.unsubscribe() |
|||
privateSubscription = null |
|||
} |
|||
if (client) { |
|||
client.deactivate() |
|||
client = null |
|||
} |
|||
} |
|||
|
|||
function tryReconnect() { |
|||
if (reconnectAttempts >= maxReconnectAttempts) return |
|||
reconnectAttempts++ |
|||
setTimeout(() => { |
|||
disconnect() |
|||
connect() |
|||
}, reconnectDelay) |
|||
} |
|||
|
|||
connect() |
|||
|
|||
return { |
|||
disconnect, |
|||
reconnect: connect, |
|||
sendChat, |
|||
sendPrivateChat, |
|||
sendPrivateChatHistoryRequest, |
|||
sendSyncRouteVisibility, |
|||
sendSyncWaypoints, |
|||
sendSyncPlatformIcons, |
|||
sendSyncRoomDrawings, |
|||
sendSyncPlatformStyles, |
|||
sendObjectEditLock, |
|||
sendObjectEditUnlock, |
|||
get connected() { |
|||
return client && client.connected |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue