cuitw 6 days ago
parent
commit
11c69186c7
  1. 100
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java
  2. 48
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  3. 13
      ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
  4. 5
      ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
  5. 115
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomOnlineMemberBroadcastService.java
  6. 32
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java
  7. 8
      ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java
  8. 47
      ruoyi-system/src/main/java/com/ruoyi/system/domain/RoomUserProfile.java
  9. 12
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomUserProfileMapper.java
  10. 12
      ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomUserProfileService.java
  11. 63
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomUserProfileServiceImpl.java
  12. 54
      ruoyi-system/src/main/resources/mapper/system/RoomUserProfileMapper.xml
  13. 55
      ruoyi-ui/src/api/system/whiteboard.js
  14. 82
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  15. 117
      ruoyi-ui/src/views/cesiumMap/index.vue
  16. 2
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  17. 175
      ruoyi-ui/src/views/childRoom/index.vue
  18. 286
      ruoyi-ui/src/views/dialogs/UserProfileDialog.vue
  19. 11
      sql/room_user_profile.sql

100
ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java

@ -1,17 +1,30 @@
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;
/**
@ -24,6 +37,12 @@ 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) {
@ -60,4 +79,85 @@ public class WhiteboardController extends BaseController {
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;
}
}

48
ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java

@ -16,9 +16,12 @@ 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;
/**
@ -41,6 +44,12 @@ public class RoomWebSocketController {
@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";
@ -49,9 +58,7 @@ public class RoomWebSocketController {
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_JOINED = "MEMBER_JOINED";
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
private static final String TYPE_MEMBER_LIST = "MEMBER_LIST";
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";
@ -119,11 +126,12 @@ public class RoomWebSocketController {
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", loginUser.getUser().getNickName());
editor.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
editor.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
@ -163,18 +171,7 @@ public class RoomWebSocketController {
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, body);
roomWebSocketService.joinRoom(roomId, sessionId, member);
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
Map<String, Object> memberListMsg = new HashMap<>();
memberListMsg.put("type", TYPE_MEMBER_LIST);
memberListMsg.put("members", members);
Map<String, Object> joinedMsg = new HashMap<>();
joinedMsg.put("type", TYPE_MEMBER_JOINED);
joinedMsg.put("member", member);
String topic = "/topic/room/" + roomId;
messagingTemplate.convertAndSend(topic, memberListMsg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId);
List<Object> chatHistory = roomChatService.getGroupChatHistory(roomId);
Map<String, Object> chatHistoryMsg = new HashMap<>();
@ -195,6 +192,7 @@ public class RoomWebSocketController {
msg.put("sessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId);
}
private void handlePing(Long roomId, String sessionId, LoginUser loginUser) {
@ -211,11 +209,12 @@ public class RoomWebSocketController {
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", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
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<>();
@ -238,15 +237,16 @@ public class RoomWebSocketController {
String targetUserName = String.valueOf(targetUserNameObj);
if (targetUserName.equals(loginUser.getUsername())) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
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", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
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<>();
@ -272,7 +272,7 @@ public class RoomWebSocketController {
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId == null) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
List<RoomMemberDTO> members = roomOnlineMemberBroadcastService.aggregateMembersForView(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserId.equals(m.getUserId()));
if (!targetInRoom) return;
@ -324,11 +324,13 @@ public class RoomWebSocketController {
}
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(loginUser.getUser().getNickName());
dto.setAvatar(loginUser.getUser().getAvatar());
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());

13
ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java

@ -12,6 +12,11 @@ public class RoomMemberDTO implements Serializable {
/** 用户ID */
private Long userId;
/**
* 该成员会话所属的房间ID用于前端按当前房间统计在线人数
* 大房间聚合下发给子房间时仍保留成员的原始房间来源
*/
private Long roomId;
/** 用户账号 */
private String userName;
/** 用户昵称 */
@ -35,6 +40,14 @@ public class RoomMemberDTO implements Serializable {
this.userId = userId;
}
public Long getRoomId() {
return roomId;
}
public void setRoomId(Long roomId) {
this.roomId = roomId;
}
public String getUserName() {
return userName;
}

5
ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java

@ -8,6 +8,7 @@ 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;
/**
@ -27,6 +28,9 @@ public class WebSocketDisconnectListener implements ApplicationListener<SessionD
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService;
@Override
public void onApplicationEvent(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();
@ -42,5 +46,6 @@ public class WebSocketDisconnectListener implements ApplicationListener<SessionD
String topic = "/topic/room/" + info.getRoomId();
messagingTemplate.convertAndSend(topic, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(info.getRoomId());
}
}

115
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomOnlineMemberBroadcastService.java

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

32
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java

@ -18,6 +18,7 @@ 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
@ -28,6 +29,10 @@ public class WhiteboardRoomService {
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) {
@ -121,4 +126,31 @@ public class WhiteboardRoomService {
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);
}
}

8
ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java

@ -105,6 +105,14 @@ public class RuoYiConfig
}
/**
* 获取协同房间用户头像上传路径与平台文件目录隔离
*/
public static String getRoomUserAvatarPath()
{
return getProfile() + "/room-user-avatar";
}
/**
* 获取下载路径
*/
public static String getDownloadPath()

47
ruoyi-system/src/main/java/com/ruoyi/system/domain/RoomUserProfile.java

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

12
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomUserProfileMapper.java

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

12
ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomUserProfileService.java

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

63
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomUserProfileServiceImpl.java

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

54
ruoyi-system/src/main/resources/mapper/system/RoomUserProfileMapper.xml

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

55
ruoyi-ui/src/api/system/whiteboard.js

@ -41,3 +41,58 @@ export function deleteWhiteboard(roomId, 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
})
}

82
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue

@ -1,5 +1,6 @@
<template>
<div class="context-menu" v-if="visible" :style="positionStyle">
<div>
<div class="context-menu" v-if="visible" :style="positionStyle">
<!-- 重叠/接近时切换选择其他图形 -->
<div class="menu-section" v-if="pickList && pickList.length > 1">
<div class="menu-item" @click="$emit('switch-pick')">
@ -585,14 +586,51 @@
</div>
</div>
<!-- 白板平台仅显示伸缩框用于旋转 -->
<!-- 白板平台 -->
<div class="menu-section" v-if="entityData.type === 'platformIcon' && entityData.isWhiteboard">
<div class="menu-title">白板平台</div>
<div class="menu-item" @click="handleShowTransformBox">
<span class="menu-icon">🔄</span>
<span>显示伸缩框</span>
</div>
<div class="menu-item" @click="openWhiteboardPlatformStyleDialog">
<span class="menu-icon">🎨</span>
<span>颜色与大小</span>
</div>
</div>
</div>
<el-dialog
title="颜色与大小"
:visible.sync="showWhiteboardPlatformStyleDialog"
width="360px"
append-to-body
:close-on-click-modal="false"
>
<el-form :model="whiteboardPlatformStyleForm" label-width="90px" size="small">
<el-form-item label="颜色">
<el-color-picker
v-model="whiteboardPlatformStyleForm.color"
:predefine="presetColors"
@active-change="handleWhiteboardColorActiveChange"
/>
</el-form-item>
<el-form-item label="大小">
<el-select v-model="whiteboardPlatformStyleForm.iconScale" style="width:100%">
<el-option
v-for="scale in presetPlatformScales"
:key="'wb-style-scale-' + scale"
:label="scale + 'x'"
:value="scale"
/>
</el-select>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showWhiteboardPlatformStyleDialog = false">取消</el-button>
<el-button type="primary" @click="confirmWhiteboardPlatformStyle">确定</el-button>
</span>
</el-dialog>
</div>
</template>
@ -653,6 +691,12 @@ export default {
showColorPickerFor: null,
showWidthPicker: false,
showSizePicker: false,
sizePickerType: '',
showWhiteboardPlatformStyleDialog: false,
whiteboardPlatformStyleForm: {
color: '#008aff',
iconScale: 1.5
},
showOpacityPicker: false,
showFontSizePicker: false,
showBearingTypeMenu: false,
@ -664,6 +708,7 @@ export default {
],
presetWidths: [1, 2, 3, 4, 5, 6, 8, 10, 12],
presetSizes: [6, 8, 10, 12, 14, 16, 18, 20, 24],
presetPlatformScales: [0.8, 1, 1.2, 1.5, 1.8, 2.2, 2.6, 3],
presetOpacities: [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
presetFontSizes: [8, 10, 12, 14, 16, 18, 20, 24, 28, 32]
}
@ -755,6 +800,34 @@ export default {
handleShowTransformBox() {
this.$emit('show-transform-box')
},
openWhiteboardPlatformStyleDialog() {
const color = (this.entityData && this.entityData.color) || '#008aff'
const iconScale = this.entityData && this.entityData.iconScale != null ? Number(this.entityData.iconScale) : 1.5
this.whiteboardPlatformStyleForm = {
color,
iconScale: Number.isFinite(iconScale) ? iconScale : 1.5
}
this.showWhiteboardPlatformStyleDialog = true
},
handleWhiteboardColorActiveChange(color) {
if (color) this.whiteboardPlatformStyleForm.color = color
},
confirmWhiteboardPlatformStyle() {
const color = this.whiteboardPlatformStyleForm.color || '#008aff'
const iconScale = Number(this.whiteboardPlatformStyleForm.iconScale)
if (!Number.isFinite(iconScale) || iconScale <= 0) {
this.$message && this.$message.warning('请选择有效大小')
return
}
this.showWhiteboardPlatformStyleDialog = false
this.$emit('apply-whiteboard-platform-style', {
id: this.entityData && this.entityData.id,
color,
iconScale
})
//
this.$emit('close-menu')
},
handleToggleRouteLabel() {
this.$emit('toggle-route-label')
@ -927,15 +1000,17 @@ export default {
this.showWidthPicker = false
},
toggleSizePicker() {
toggleSizePicker(type = 'default') {
if (this.showSizePicker) {
this.showSizePicker = false
this.sizePickerType = ''
} else {
//
this.showColorPickerFor = null
this.showWidthPicker = false
this.showOpacityPicker = false
this.showFontSizePicker = false
this.sizePickerType = type
this.showSizePicker = true
}
},
@ -943,6 +1018,7 @@ export default {
selectSize(size) {
this.$emit('update-property', 'size', size)
this.showSizePicker = false
this.sizePickerType = ''
},
toggleOpacityPicker() {

117
ruoyi-ui/src/views/cesiumMap/index.vue

@ -31,7 +31,6 @@
/>
<map-screen-dom-labels :items="screenDomLabelItems" />
<context-menu
v-if="contextMenu.visible"
:visible="contextMenu.visible"
:position="contextMenu.position"
:entity-data="contextMenu.entityData"
@ -68,6 +67,8 @@
@edit-hold-speed="handleEditHoldSpeed"
@launch-missile="openLaunchMissileDialog"
@adjust-airspace-position="startAirspacePositionEdit"
@apply-whiteboard-platform-style="applyWhiteboardPlatformStyleFromMenu"
@close-menu="contextMenu.visible = false"
@delete-box-selected-platforms="deleteBoxSelectedPlatformsFromMenu"
/>
@ -8742,6 +8743,16 @@ export default {
entity.polyline.width = data.width
}
break
case 'platformIcon':
if (entity.billboard) {
if (data.iconScale != null) {
this.updatePlatformIconBillboardSize(data)
}
if (data.color) {
entity.billboard.color = Cesium.Color.fromCssColorString(data.color)
}
}
break
case 'text': {
this.ensureMapScreenDomLabelRegistry()
const tkey = entity.id ? `map-dom-maptext-text-${entity.id}` : null
@ -11600,6 +11611,20 @@ export default {
// contentByTime
if (entityData.isWhiteboard && this.getDrawingEntityTypes().includes(entityData.type)) {
this.$emit('whiteboard-drawing-updated', this.serializeWhiteboardDrawingEntity(entityData))
} else if (entityData.isWhiteboard && entityData.type === 'platformIcon') {
this.$emit('whiteboard-platform-updated', {
id: entityData.id,
type: 'platformIcon',
platformId: entityData.platformId,
platform: entityData.platform || {},
platformName: entityData.platformName || (entityData.platform && entityData.platform.name) || '',
lat: entityData.lat,
lng: entityData.lng,
heading: entityData.heading != null ? entityData.heading : 0,
iconScale: entityData.iconScale != null ? entityData.iconScale : 1.5,
label: entityData.label || '',
color: entityData.color || '#ffffff'
})
} else if (this.getDrawingEntityTypes().includes(entityData.type)) {
this.notifyDrawingEntitiesChanged()
}
@ -11637,6 +11662,54 @@ export default {
this.viewer.scene.requestRender()
this.$message && this.$message.success('已显示伸缩框')
},
/** 白板平台右键“颜色与大小”统一弹窗:点确定后一次性应用并回传父组件保存 */
applyWhiteboardPlatformStyleFromMenu(payload) {
const targetId = payload && payload.id
let ed = null
if (targetId && this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[targetId]) {
ed = this.whiteboardEntityDataMap[targetId]
} else if (this.contextMenu && this.contextMenu.entityData) {
ed = this.contextMenu.entityData
}
if (!ed || ed.type !== 'platformIcon' || !ed.isWhiteboard) {
this.contextMenu.visible = false
return
}
const color = (payload && payload.color) || ed.color || '#008aff'
const rawScale = payload && payload.iconScale != null ? Number(payload.iconScale) : (ed.iconScale != null ? Number(ed.iconScale) : 1.5)
const iconScale = Math.max(0.2, Math.min(3, Number.isFinite(rawScale) ? rawScale : 1.5))
ed.color = color
ed.iconScale = iconScale
this.updateEntityStyle(ed)
if (this.whiteboardEntityDataMap && ed.id && this.whiteboardEntityDataMap[ed.id]) {
this.whiteboardEntityDataMap[ed.id].color = color
this.whiteboardEntityDataMap[ed.id].iconScale = iconScale
}
this.$emit('whiteboard-platform-updated', {
id: ed.id,
type: 'platformIcon',
platformId: ed.platformId,
platform: ed.platform || {},
platformName: ed.platformName || (ed.platform && ed.platform.name) || '',
lat: ed.lat,
lng: ed.lng,
heading: ed.heading != null ? ed.heading : 0,
iconScale,
label: ed.label || '',
color
})
//
this.$emit('whiteboard-platform-style-updated', {
id: ed.id,
color,
iconScale
})
this.contextMenu.visible = false
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRender) {
this.viewer.scene.requestRender()
}
this.$message && this.$message.success('白板平台样式已更新')
},
/** 右键「在此之前插入航线」:先画平台前的航点,右键结束时将平台作为最后一站 */
handleStartRouteBeforePlatform() {
@ -12289,12 +12362,36 @@ export default {
existing.lng = ed.lng
existing.heading = ed.heading != null ? ed.heading : 0
existing.iconScale = ed.iconScale != null ? ed.iconScale : 1.5
existing.color = ed.color || existing.color || '#008aff'
existing.entity.position = Cesium.Cartesian3.fromDegrees(ed.lng, ed.lat)
existing.entity.billboard.rotation = Math.PI / 2 - (existing.heading * Math.PI / 180)
this.updatePlatformIconBillboardSize(existing)
if (existing.entity.billboard) {
existing.entity.billboard.color = Cesium.Color.fromCssColorString(existing.color)
}
this.ensureWhiteboardPlatformColorableImage(existing)
if (existing.transformHandles) this.updateTransformHandlePositions(existing)
if (this.viewer.scene && this.viewer.scene.requestRender) this.viewer.scene.requestRender()
},
/** 白板平台图标转白底后再着色,避免黑色图标乘色后看不出颜色变化 */
ensureWhiteboardPlatformColorableImage(entityData) {
if (!entityData || !entityData.entity || !entityData.entity.billboard) return
if (entityData._whiteboardIconPrepared) return
const platform = entityData.platform || {}
const iconUrl = platform.imageUrl || platform.iconUrl
const imageSrc = iconUrl ? this.formatPlatformIconUrl(iconUrl) : this.getDefaultPlatformIconDataUrl()
this.loadAndWhitenImage(imageSrc).then((whiteImage) => {
if (!entityData || !entityData.entity || !entityData.entity.billboard) return
entityData.entity.billboard.image = whiteImage
if (entityData.color) {
entityData.entity.billboard.color = Cesium.Color.fromCssColorString(entityData.color)
}
entityData._whiteboardIconPrepared = true
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRender) {
this.viewer.scene.requestRender()
}
}).catch(() => {})
},
/** 白板平台图标:添加 billboard,并存入 whiteboardEntityDataMap 以支持拖拽/旋转 */
addWhiteboardPlatformIcon(ed) {
const platform = ed.platform || {}
@ -12304,6 +12401,7 @@ export default {
const lat = ed.lat
const lng = ed.lng
const heading = ed.heading != null ? ed.heading : 0
const color = ed.color || '#008aff'
const rotation = Math.PI / 2 - (heading * Math.PI / 180)
const size = this.PLATFORM_ICON_BASE_SIZE * (ed.iconScale != null ? ed.iconScale : 1.5)
const cartesian = Cesium.Cartesian3.fromDegrees(lng, lat)
@ -12315,6 +12413,7 @@ export default {
image: imageSrc,
width: size,
height: size,
color: Cesium.Color.fromCssColorString(color),
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
rotation,
@ -12322,9 +12421,23 @@ export default {
translucencyByDistance: new Cesium.NearFarScalar(1000, 1.0, 500000, 0.6)
}
})
const entityData = { id, entity, lat, lng, heading, platform, platformName: platform.name, label: ed.label, type: 'platformIcon', isWhiteboard: true, iconScale: ed.iconScale != null ? ed.iconScale : 1.5 }
const entityData = {
id,
entity,
lat,
lng,
heading,
color,
platform,
platformName: platform.name,
label: ed.label,
type: 'platformIcon',
isWhiteboard: true,
iconScale: ed.iconScale != null ? ed.iconScale : 1.5
}
this.whiteboardEntityDataMap = this.whiteboardEntityDataMap || {}
this.whiteboardEntityDataMap[id] = entityData
this.ensureWhiteboardPlatformColorableImage(entityData)
},
/** 白板空域/图形实体:复用 importEntity 逻辑但不加入 allEntities */
addWhiteboardDrawingEntity(ed) {

2
ruoyi-ui/src/views/childRoom/TopHeader.vue

@ -332,7 +332,7 @@
<!-- 用户状态区域 -->
<div class="user-status-area">
<!-- 用户头像 -->
<el-avatar :size="32" :src="userAvatar" class="user-avatar" />
<el-avatar :size="32" :src="userAvatar" class="user-avatar" @click.native="$emit('edit-user-profile')" />
</div>
</div>

175
ruoyi-ui/src/views/childRoom/index.vue

@ -51,6 +51,7 @@
@platform-style-saved="onPlatformStyleSaved"
@whiteboard-draw-complete="handleWhiteboardDrawComplete"
@whiteboard-platform-updated="handleWhiteboardPlatformUpdated"
@whiteboard-platform-style-updated="handleWhiteboardPlatformStyleUpdated"
@whiteboard-entity-deleted="handleWhiteboardEntityDeleted"
@whiteboard-drawing-updated="handleWhiteboardDrawingUpdated" />
<div v-show="!screenshotMode" class="map-overlay-text">
@ -166,6 +167,7 @@
@layer-favorites="layerFavorites"
@route-favorites="routeFavorites"
@show-online-members="showOnlineMembersDialog"
@edit-user-profile="openUserProfileDialog"
/>
<!-- 左侧折叠菜单栏 - 蓝色主题 -->
<left-menu
@ -332,6 +334,12 @@
@rollback-done="onOperationRollbackDone"
/>
<user-profile-dialog
v-model="showUserProfileDialog"
:profile="roomUserProfile"
@profile-updated="handleUserProfileUpdated"
/>
<!-- 平台编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<platform-edit-dialog
v-model="showPlatformDialog"
@ -513,6 +521,7 @@ import ScaleDialog from '@/views/dialogs/ScaleDialog'
import ExternalParamsDialog from '@/views/dialogs/ExternalParamsDialog'
import PageLayoutDialog from '@/views/dialogs/PageLayoutDialog'
import KTimeSetDialog from '@/views/dialogs/KTimeSetDialog'
import UserProfileDialog from '@/views/dialogs/UserProfileDialog'
import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
@ -528,7 +537,16 @@ import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms, listRooms } from "@/api/system/rooms";
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig";
import { listByRoomId as listRoomPlatformIcons, addRoomPlatformIcon, updateRoomPlatformIcon, delRoomPlatformIcon } from "@/api/system/roomPlatformIcon";
import { listWhiteboards, getWhiteboard, createWhiteboard, updateWhiteboard, deleteWhiteboard } from "@/api/system/whiteboard";
import {
listWhiteboards,
getWhiteboard,
createWhiteboard,
updateWhiteboard,
deleteWhiteboard,
saveWhiteboardPlatformStyle,
deleteWhiteboardPlatformStyle,
getRoomUserProfile
} from "@/api/system/whiteboard";
import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue";
import ExportRoutesDialog from "@/views/dialogs/ExportRoutesDialog.vue";
import ImportRoutesDialog from "@/views/dialogs/ImportRoutesDialog.vue";
@ -555,6 +573,7 @@ export default {
ExternalParamsDialog,
PageLayoutDialog,
KTimeSetDialog,
UserProfileDialog,
LeftMenu,
RightPanel,
BottomLeftPanel,
@ -573,6 +592,7 @@ export default {
mapDragEnabled: false,
// 线
showOnlineMembers: false,
showUserProfileDialog: false,
//
showPlatformDialog: false,
selectedPlatform: null,
@ -803,6 +823,13 @@ export default {
//
userAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
roomUserProfile: {
userId: null,
userName: '',
displayName: '',
avatar: ''
},
avatarRefreshTs: Date.now(),
/** 当前连接的 WebSocket sessionId 集合(用于过滤自己发出的同步消息,避免重复应用) */
mySyncSessionIds: [],
};
@ -1049,6 +1076,7 @@ export default {
created() {
this.currentRoomId = this.$route.query.roomId;
console.log("从路由接收到的真实房间 ID:", this.currentRoomId);
this.fetchRoomUserProfile();
this.getPlatformList();
if (this.currentRoomId) {
this.getRoomDetail(() => this.getList());
@ -1936,6 +1964,53 @@ export default {
showOnlineMembersDialog() {
this.showOnlineMembers = true;
},
openUserProfileDialog() {
this.showUserProfileDialog = true;
},
async fetchRoomUserProfile() {
try {
const res = await getRoomUserProfile();
const p = res.data || {};
this.roomUserProfile = {
userId: p.userId,
userName: p.userName || this.$store.getters.name || '',
displayName: p.displayName || p.userName || this.$store.getters.name || '',
avatar: p.avatar || ''
};
this.userAvatar = this.resolveAvatarUrl(this.roomUserProfile.avatar) || this.userAvatar;
} catch (e) {
//
}
},
handleUserProfileUpdated(profile) {
if (!profile) return;
this.avatarRefreshTs = Date.now();
const fixedAvatar = profile.avatar || profile.imgUrl || profile.fullImgUrl || this.roomUserProfile.avatar || '';
const instantPreview = profile.localAvatarPreview || profile.previewAvatarUrl || '';
this.roomUserProfile = {
...this.roomUserProfile,
...profile,
avatar: fixedAvatar
};
this.userAvatar = instantPreview || this.resolveAvatarUrl(this.roomUserProfile.avatar) || this.userAvatar;
// 线
const myId = this.currentUserId;
if (myId != null && Array.isArray(this.wsOnlineMembers)) {
this.wsOnlineMembers = this.wsOnlineMembers.map(m => {
if (Number(m.userId) !== Number(myId)) return m;
return {
...m,
name: this.roomUserProfile.displayName || m.name,
avatar: instantPreview || this.resolveAvatarUrl(this.roomUserProfile.avatar) || m.avatar
};
});
}
if (fixedAvatar) {
this.$nextTick(() => {
this.fetchRoomUserProfile();
});
}
},
onOperationRollbackDone() {
// 线/
this.getList();
@ -1964,14 +2039,22 @@ export default {
return m;
}).filter(Boolean);
},
resolveAvatarUrl(avatar) {
if (!avatar) return '';
if (avatar.startsWith('http')) return avatar;
const baseUrl = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || ''));
const fullUrl = baseUrl + avatar;
const sep = fullUrl.includes('?') ? '&' : '?';
return `${fullUrl}${sep}t=${this.avatarRefreshTs}`;
},
sendPrivateChat(targetUserId, targetUserName, content) {
if (!this.wsConnection || !this.wsConnection.sendPrivateChat) return;
this.wsConnection.sendPrivateChat(targetUserId, targetUserName, content);
const sender = {
userId: this.currentUserId,
userName: this.$store.getters.name,
nickName: this.$store.getters.nickName || this.$store.getters.name,
avatar: this.$store.getters.avatar || ''
nickName: this.roomUserProfile.displayName || this.$store.getters.nickName || this.$store.getters.name,
avatar: this.roomUserProfile.avatar || ''
};
const msg = { type: 'PRIVATE_CHAT', sender, targetUserId, targetUserName, content, timestamp: Date.now() };
const list = this.privateChatMessages[targetUserId] || [];
@ -1992,9 +2075,11 @@ export default {
role: m.role === 'owner' ? '房主' : (m.role === 'admin' ? '管理员' : '成员'),
status: '在线',
isEditing: false,
avatar: m.avatar ? (m.avatar.startsWith('http') ? m.avatar : (baseUrl + m.avatar)) : ''
avatar: m.avatar ? (m.avatar.startsWith('http') ? m.avatar : (baseUrl + m.avatar)) : '',
// 线
roomId: m.roomId
}));
this.onlineCount = this.wsOnlineMembers.length;
this.onlineCount = this.wsOnlineMembers.filter(x => x.roomId != null && String(x.roomId) === String(this.currentRoomId)).length;
const myId = this.$store.getters.id;
this.mySyncSessionIds = (members || []).filter(m => myId != null && String(m.userId) === String(myId)).map(m => m.sessionId).filter(Boolean);
},
@ -2008,11 +2093,12 @@ export default {
role: member.role === 'owner' ? '房主' : (member.role === 'admin' ? '管理员' : '成员'),
status: '在线',
isEditing: false,
avatar: member.avatar ? (member.avatar.startsWith('http') ? member.avatar : (baseUrl + member.avatar)) : ''
avatar: member.avatar ? (member.avatar.startsWith('http') ? member.avatar : (baseUrl + member.avatar)) : '',
roomId: member.roomId
};
if (!this.wsOnlineMembers.find(x => x.id === m.id)) {
this.wsOnlineMembers = [...this.wsOnlineMembers, m];
this.onlineCount = this.wsOnlineMembers.length;
this.onlineCount = this.wsOnlineMembers.filter(x => x.roomId != null && String(x.roomId) === String(this.currentRoomId)).length;
}
const myId = this.$store.getters.id;
if (myId != null && String(member.userId) === String(myId) && member.sessionId) {
@ -2023,7 +2109,7 @@ export default {
},
onMemberLeft: (member, sessionId) => {
this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId);
this.onlineCount = this.wsOnlineMembers.length;
this.onlineCount = this.wsOnlineMembers.filter(x => x.roomId != null && String(x.roomId) === String(this.currentRoomId)).length;
this.mySyncSessionIds = this.mySyncSessionIds.filter(s => s !== sessionId && s !== member.sessionId);
//
Object.keys(this.routeLockedBy).forEach(rid => {
@ -3930,22 +4016,83 @@ export default {
this.saveCurrentWhiteboard({ contentByTime })
},
/** 白板平台拖拽/旋转后更新 contentByTime 并保存 */
/** 白板平台拖拽/旋转后更新 contentByTime 并保存(仅位置与朝向) */
handleWhiteboardPlatformUpdated(entityData) {
if (!this.currentWhiteboard || !this.currentWhiteboardTimeBlock || !entityData || !entityData.id) return
const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) }
const currentContent = contentByTime[this.currentWhiteboardTimeBlock] || { entities: [] }
const ents = [...(currentContent.entities || [])]
const idx = ents.findIndex(e => e.id === entityData.id)
const normalizeId = (id) => {
if (id == null) return ''
const str = String(id)
return str.startsWith('wb_') ? str.slice(3) : str
}
const targetId = String(entityData.id)
const targetIdNormalized = normalizeId(targetId)
const idx = ents.findIndex((e) => {
const eid = String((e && e.id) || '')
return eid === targetId || normalizeId(eid) === targetIdNormalized
})
if (idx >= 0) {
const updated = { ...ents[idx], lat: entityData.lat, lng: entityData.lng, heading: entityData.heading != null ? entityData.heading : 0 }
if (entityData.iconScale != null) updated.iconScale = entityData.iconScale
const updated = {
...ents[idx],
lat: entityData.lat,
lng: entityData.lng,
heading: entityData.heading != null ? entityData.heading : 0
}
// /
ents[idx] = updated
contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents }
this.saveCurrentWhiteboard({ contentByTime })
}
},
/** 白板平台样式更新(颜色/大小)单独落库,避免被位置事件覆盖 */
handleWhiteboardPlatformStyleUpdated(stylePayload) {
if (!this.currentWhiteboard || !stylePayload || !stylePayload.id) return
const normalizeId = (id) => {
if (id == null) return ''
const str = String(id)
return str.startsWith('wb_') ? str.slice(3) : str
}
const targetId = String(stylePayload.id)
const targetIdNormalized = normalizeId(targetId)
let platformInstanceId = targetId
let styleColor = stylePayload.color
let styleScale = stylePayload.iconScale
// id
const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) }
const currentContent = contentByTime[this.currentWhiteboardTimeBlock] || { entities: [] }
const ents = [...(currentContent.entities || [])]
const idx = ents.findIndex((e) => {
const eid = String((e && e.id) || '')
return eid === targetId || normalizeId(eid) === targetIdNormalized
})
if (idx >= 0) {
const updated = { ...ents[idx] }
if (stylePayload.color) updated.color = stylePayload.color
if (stylePayload.iconScale != null) updated.iconScale = stylePayload.iconScale
ents[idx] = updated
contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents }
this.saveCurrentWhiteboard({ contentByTime })
platformInstanceId = updated.id || targetId
styleColor = updated.color
styleScale = updated.iconScale
}
//
if (!styleColor || styleScale == null) return
saveWhiteboardPlatformStyle({
schemeId: this.currentWhiteboard.id,
platformInstanceId,
style: {
color: styleColor,
iconScale: styleScale
}
}).catch(() => {})
},
/** 白板平台从右键菜单删除后,从 contentByTime 移除并保存 */
handleWhiteboardEntityDeleted(entityData) {
if (!this.currentWhiteboard || !this.currentWhiteboardTimeBlock || !entityData || !entityData.id) return
@ -3954,6 +4101,10 @@ export default {
const ents = (currentContent.entities || []).filter(e => e.id !== entityData.id)
contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents }
this.saveCurrentWhiteboard({ contentByTime })
deleteWhiteboardPlatformStyle({
schemeId: this.currentWhiteboard.id,
platformInstanceId: entityData.id
}).catch(() => {})
},
/** 白板空域/图形编辑后(调整位置、修改属性等)更新 contentByTime 并保存 */

286
ruoyi-ui/src/views/dialogs/UserProfileDialog.vue

@ -0,0 +1,286 @@
<template>
<div>
<el-dialog
title="编辑用户信息"
:visible.sync="innerVisible"
width="420px"
append-to-body
@close="handleClose"
>
<div class="profile-dialog-body">
<div class="avatar-row">
<el-avatar :size="72" :src="previewAvatar" />
<el-upload
class="avatar-uploader"
action="#"
:auto-upload="false"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:on-change="handleChooseAvatar"
accept=".png,.jpg,.jpeg,.gif,.webp"
>
<el-button size="mini">上传头像</el-button>
</el-upload>
</div>
<el-form label-width="80px" size="small">
<el-form-item label="用户名">
<el-input v-model.trim="form.displayName" maxlength="32" show-word-limit />
</el-form-item>
<el-form-item label="账号">
<el-input :value="form.userName" disabled />
</el-form-item>
</el-form>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="innerVisible = false"> </el-button>
<el-button type="primary" :loading="saving" @click="saveProfile"> </el-button>
</span>
</el-dialog>
<el-dialog
title="调整头像"
:visible.sync="cropperVisible"
width="760px"
append-to-body
@close="onCropperClose"
>
<el-row>
<el-col :span="12" style="height: 360px;">
<vue-cropper
v-if="cropperVisible"
ref="cropper"
:img="cropperOptions.img"
:info="true"
:autoCrop="true"
:autoCropWidth="220"
:autoCropHeight="220"
:fixedBox="true"
:outputType="'png'"
@realTime="realTimePreview"
/>
</el-col>
<el-col :span="12" style="height: 360px;">
<div class="avatar-upload-preview">
<img :src="cropperPreview.url" :style="cropperPreview.img" />
</div>
</el-col>
</el-row>
<div class="cropper-toolbar-wrap">
<el-button class="zoom-btn" icon="el-icon-plus" size="small" @click="changeScale(1)">放大</el-button>
<el-button class="zoom-btn" icon="el-icon-minus" size="small" @click="changeScale(-1)">缩小</el-button>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="cropperVisible = false"> </el-button>
<el-button type="primary" :loading="uploading" @click="confirmCropAndUpload"> </el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { VueCropper } from 'vue-cropper'
import { updateRoomUserProfile, uploadRoomUserAvatar } from '@/api/system/whiteboard'
export default {
name: 'UserProfileDialog',
components: {
VueCropper
},
props: {
value: {
type: Boolean,
default: false
},
profile: {
type: Object,
default: () => ({})
}
},
data() {
return {
innerVisible: false,
saving: false,
uploading: false,
cropperVisible: false,
cropperOptions: {
img: '',
filename: 'avatar.png'
},
cropperPreview: {},
localAvatarPreview: '',
form: {
userName: '',
displayName: '',
avatar: ''
}
}
},
computed: {
previewAvatar() {
if (this.localAvatarPreview) return this.localAvatarPreview
if (!this.form.avatar) return ''
if (this.form.avatar.startsWith('data:')) return this.form.avatar
if (this.form.avatar.startsWith('http')) return this.form.avatar
const base = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '/dev-api'))
return base + this.form.avatar
}
},
watch: {
value: {
immediate: true,
handler(v) {
this.innerVisible = v
if (v) this.syncForm()
}
},
innerVisible(v) {
this.$emit('input', v)
if (v) this.syncForm()
},
profile: {
deep: true,
handler() {
if (this.innerVisible) this.syncForm()
}
}
},
methods: {
syncForm() {
const p = this.profile || {}
this.form.userName = p.userName || ''
this.form.displayName = p.displayName || p.userName || ''
this.form.avatar = p.avatar || ''
},
beforeAvatarUpload(file) {
const okType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)
if (!okType) {
this.$message.error('仅支持 JPG/PNG/GIF/WEBP 图片')
return false
}
const okSize = file.size / 1024 / 1024 < 2
if (!okSize) {
this.$message.error('头像大小不能超过 2MB')
return false
}
return true
},
handleChooseAvatar(file) {
const rawFile = file && file.raw
if (!rawFile) return
const reader = new FileReader()
reader.readAsDataURL(rawFile)
reader.onload = () => {
this.localAvatarPreview = reader.result || ''
this.cropperOptions.img = reader.result
this.cropperOptions.filename = rawFile.name || 'avatar.png'
this.cropperVisible = true
}
},
changeScale(num) {
if (this.$refs.cropper) this.$refs.cropper.changeScale(num)
},
realTimePreview(data) {
this.cropperPreview = data || {}
},
confirmCropAndUpload() {
if (!this.$refs.cropper || this.uploading) return
this.uploading = true
this.$refs.cropper.getCropBlob(async (blob) => {
try {
this.localAvatarPreview = URL.createObjectURL(blob)
const file = new File([blob], this.cropperOptions.filename, { type: 'image/png' })
const res = await uploadRoomUserAvatar(file)
const data = res.data || {}
const nested = data.data || {}
const avatar = data.avatar || nested.avatar || res.imgUrl || res.fullImgUrl || ''
const merged = {
...data,
...nested,
avatar
}
this.form.avatar = avatar || this.form.avatar
this.$emit('profile-updated', merged)
this.localAvatarPreview = ''
this.cropperVisible = false
this.$message.success('头像上传成功')
} finally {
this.uploading = false
}
})
},
async saveProfile() {
if (!this.form.displayName) {
this.$message.warning('用户名不能为空')
return
}
this.saving = true
try {
const res = await updateRoomUserProfile({ displayName: this.form.displayName })
this.$emit('profile-updated', res.data || {})
this.$message.success('保存成功')
this.innerVisible = false
} finally {
this.saving = false
}
},
handleClose() {
this.$emit('input', false)
},
onCropperClose() {
this.cropperPreview = {}
}
}
}
</script>
<style scoped>
.profile-dialog-body {
padding: 4px 10px;
}
.avatar-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.avatar-upload-preview {
width: 220px;
height: 220px;
border-radius: 50%;
overflow: hidden;
margin: 0 auto;
border: 1px solid #dcdfe6;
}
.cropper-toolbar-wrap {
margin-top: 14px;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
.zoom-btn {
min-width: 88px;
height: 34px;
border-radius: 18px;
border: 1px solid #d0def5;
background: #f4f8ff;
color: #2f6fd6;
font-weight: 600;
transition: all 0.2s ease;
}
.zoom-btn:hover,
.zoom-btn:focus {
border-color: #6ea0ef;
background: #e9f2ff;
color: #1f5fcb;
}
.zoom-btn:active {
transform: translateY(1px);
}
</style>

11
sql/room_user_profile.sql

@ -0,0 +1,11 @@
-- 协同房间用户资料表(显示名 + 头像)
CREATE TABLE IF NOT EXISTS room_user_profile (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT 'sys_user.user_id',
display_name VARCHAR(64) NOT NULL COMMENT '协同显示名',
avatar VARCHAR(255) DEFAULT NULL COMMENT '协同头像URL/资源路径',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协同房间用户资料';
Loading…
Cancel
Save