Browse Source

房间进入权限管理,三级权限实现

mh
menghao 13 hours ago
parent
commit
370d572bf9
  1. 2
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/MissionScenarioController.java
  2. 1
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/ObjectOperationLogController.java
  3. 231
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomAccessController.java
  4. 1
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomsController.java
  5. 34
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
  6. 39
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  7. 165
      ruoyi-admin/src/main/java/com/ruoyi/web/service/RoomAccessRedisService.java
  8. 116
      ruoyi-admin/src/main/java/com/ruoyi/web/service/RoomRoutePermissionHelper.java
  9. 100
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  10. 16
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java
  11. 12
      ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java
  12. 7
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java
  13. 5
      ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java
  14. 10
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java
  15. 5
      ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml
  16. 21
      ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
  17. 76
      ruoyi-ui/src/api/system/rooms.js
  18. 27
      ruoyi-ui/src/utils/websocket.js
  19. 29
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  20. 78
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  21. 19
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  22. 326
      ruoyi-ui/src/views/childRoom/index.vue
  23. 309
      ruoyi-ui/src/views/dialogs/RoomAccessManageDialog.vue
  24. 22
      ruoyi-ui/src/views/selectRoom/index.vue
  25. 11
      sql/room_platform_icon_add_scale_xy.sql
  26. 9
      sql/routes_add_creator_id.sql

2
ruoyi-admin/src/main/java/com/ruoyi/web/controller/MissionScenarioController.java

@ -37,7 +37,6 @@ public class MissionScenarioController extends BaseController
/**
* 查询任务方案/沙箱列表
*/
@PreAuthorize("@ss.hasPermi('system:scenario:list')")
@GetMapping("/list")
public TableDataInfo list(MissionScenario missionScenario)
{
@ -62,7 +61,6 @@ public class MissionScenarioController extends BaseController
/**
* 获取任务方案/沙箱详细信息
*/
@PreAuthorize("@ss.hasPermi('system:scenario:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{

1
ruoyi-admin/src/main/java/com/ruoyi/web/controller/ObjectOperationLogController.java

@ -29,7 +29,6 @@ public class ObjectOperationLogController extends BaseController {
/**
* 分页查询对象级操作日志按房间
*/
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(
@RequestParam(required = false) Long roomId,

231
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomAccessController.java

@ -0,0 +1,231 @@
package com.ruoyi.web.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.RequestBody;
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.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.web.service.RoomAccessRedisService;
/**
* Room access management (Redis-backed).
*
* Role hierarchy:
* Admin (userLevel=1 or system admin userId=1) - can enter any room, manage hosts per room
* Host (userLevel=2, assigned to specific rooms via Redis) - can enter assigned rooms, manage normal users for assigned rooms
* Normal (userLevel=3) - can only enter rooms they are granted access to
*/
@RestController
@RequestMapping("/system/room-access")
public class RoomAccessController extends BaseController {
@Autowired
private RoomAccessRedisService roomAccessRedisService;
@Autowired
private ISysUserService userService;
@Autowired
private IRoomsService roomsService;
private boolean isAdminMode(LoginUser loginUser) {
return roomAccessRedisService.isAdmin(loginUser);
}
private boolean isHostForRoom(LoginUser loginUser, Long roomId) {
Long uid = loginUser.getUserId();
return roomAccessRedisService.isHost(uid, roomId);
}
private List<Long> parseIdList(Object raw) {
List<Long> out = new ArrayList<>();
if (!(raw instanceof List)) {
return out;
}
for (Object o : (List<?>) raw) {
if (o == null) continue;
try {
out.add(Long.parseLong(String.valueOf(o)));
} catch (NumberFormatException ignored) {}
}
return out;
}
/** Check if current user can enter a room. */
@GetMapping("/check")
public AjaxResult check(@RequestParam Long roomId) {
LoginUser loginUser = SecurityUtils.getLoginUser();
Long userId = loginUser.getUserId();
boolean ok = roomAccessRedisService.canEnterRoom(userId, roomId, loginUser);
Map<String, Object> data = new HashMap<>();
data.put("allowed", ok);
return success(data);
}
/** Check if current user is host for a specific room. */
@GetMapping("/isHost")
public AjaxResult isHost(@RequestParam Long roomId) {
LoginUser loginUser = SecurityUtils.getLoginUser();
boolean host = isHostForRoom(loginUser, roomId);
Map<String, Object> data = new HashMap<>();
data.put("isHost", host);
return success(data);
}
/** Context for the permission management dialog. */
@GetMapping("/manageContext")
public AjaxResult manageContext(@RequestParam Long roomId) {
LoginUser loginUser = SecurityUtils.getLoginUser();
boolean adminMode = isAdminMode(loginUser);
boolean hostMode = !adminMode && isHostForRoom(loginUser, roomId);
if (!adminMode && !hostMode) {
throw new ServiceException("No permission to manage room access");
}
Map<String, Object> data = new HashMap<>();
data.put("adminMode", adminMode);
data.put("hostMode", hostMode);
data.put("userIds", roomAccessRedisService.listAllowedUserIds(roomId));
data.put("hostUserIds", roomAccessRedisService.listHostUserIds(roomId));
return success(data);
}
/** All rooms list (for admin to pick target rooms when assigning hosts). */
@GetMapping("/allRooms")
public AjaxResult allRooms() {
LoginUser loginUser = SecurityUtils.getLoginUser();
if (!isAdminMode(loginUser)) {
throw new ServiceException("Only admins can list all rooms");
}
List<Rooms> list = roomsService.selectRoomsList(new Rooms());
List<Map<String, Object>> result = new ArrayList<>();
for (Rooms r : list) {
if (r == null || r.getId() == null) continue;
Map<String, Object> m = new HashMap<>();
m.put("id", r.getId());
m.put("name", r.getName());
m.put("parentId", r.getParentId());
result.add(m);
}
return success(result);
}
/** User candidates (paginated): profile=host -> userLevel=2, profile=normal -> userLevel=3/empty */
@GetMapping("/userCandidates")
public TableDataInfo userCandidates(
@RequestParam Long roomId,
@RequestParam String profile,
@RequestParam(required = false) String userName) {
LoginUser loginUser = SecurityUtils.getLoginUser();
if ("host".equals(profile) && !isAdminMode(loginUser)) {
throw new ServiceException("Only admins can query host list");
}
if ("normal".equals(profile) && !isAdminMode(loginUser) && !isHostForRoom(loginUser, roomId)) {
throw new ServiceException("No permission to query user list");
}
SysUser q = new SysUser();
if (userName != null && !userName.isEmpty()) {
q.setUserName(userName.trim());
}
startPage();
List<SysUser> list = userService.selectUsersForRoomAccess(q, profile);
return getDataTable(list);
}
/**
* Admin: assign hosts to specific rooms.
* Body: { userIds: [...], roomIds: [...] }
* Each user in userIds gets host status + room access for each room in roomIds.
*/
@PostMapping("/setHostBatch")
public AjaxResult setHostBatch(@RequestBody Map<String, Object> body) {
LoginUser op = SecurityUtils.getLoginUser();
if (!isAdminMode(op)) {
return error("Only admins can assign hosts");
}
List<Long> userIds = parseIdList(body.get("userIds"));
List<Long> roomIds = parseIdList(body.get("roomIds"));
if (userIds.isEmpty()) {
return error("userIds is required");
}
// If roomIds not provided, try single roomId for backward compatibility
if (roomIds.isEmpty()) {
Object rid = body.get("roomId");
if (rid != null) {
roomIds.add(Long.parseLong(String.valueOf(rid)));
}
}
if (roomIds.isEmpty()) {
return error("roomIds is required");
}
int ok = 0;
for (Long uid : userIds) {
for (Long rid : roomIds) {
try {
roomAccessRedisService.setHost(rid, uid, op);
ok++;
} catch (Exception e) {
logger.warn("setHostBatch skip userId={} roomId={} : {}", uid, rid, e.getMessage());
}
}
}
return success("Done: " + ok + " / " + (userIds.size() * roomIds.size()));
}
/**
* Admin or Host: grant normal users access to specific rooms.
* Body: { userIds: [...], roomId: N }
* Host can only grant for rooms they are host of.
*/
@PostMapping("/grantBatch")
public AjaxResult grantBatch(@RequestBody Map<String, Object> body) {
Long roomId = body.get("roomId") == null ? null : Long.parseLong(String.valueOf(body.get("roomId")));
List<Long> userIds = parseIdList(body.get("userIds"));
if (roomId == null || userIds.isEmpty()) {
return error("roomId and userIds are required");
}
LoginUser op = SecurityUtils.getLoginUser();
if (!isAdminMode(op) && !isHostForRoom(op, roomId)) {
return error("No permission to grant access for this room");
}
int ok = 0;
for (Long uid : userIds) {
try {
roomAccessRedisService.grantUser(roomId, uid, op);
ok++;
} catch (Exception e) {
logger.warn("grantBatch skip userId={} : {}", uid, e.getMessage());
}
}
return success("Done: " + ok + " / " + userIds.size());
}
@PostMapping("/revoke")
public AjaxResult revoke(@RequestBody Map<String, Long> body) {
Long roomId = body.get("roomId");
Long userId = body.get("userId");
try {
roomAccessRedisService.revokeUser(roomId, userId, SecurityUtils.getLoginUser());
return success();
} catch (SecurityException e) {
return error(e.getMessage());
} catch (Exception e) {
return error(e.getMessage());
}
}
}

1
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomsController.java

@ -62,7 +62,6 @@ public class RoomsController extends BaseController
/**
* 获取项目与任务房间详细信息
*/
@PreAuthorize("@ss.hasPermi('system:rooms:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{

34
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java

@ -21,6 +21,9 @@ import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRouteWaypointsService;
import com.ruoyi.web.service.RoomRoutePermissionHelper;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import org.springframework.web.bind.annotation.RequestParam;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -41,10 +44,12 @@ public class RouteWaypointsController extends BaseController
@Autowired
private IObjectOperationLogService objectOperationLogService;
@Autowired
private RoomRoutePermissionHelper roomRoutePermissionHelper;
/**
* 查询航线具体航点明细列表
*/
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(RouteWaypoints routeWaypoints)
{
@ -69,7 +74,6 @@ public class RouteWaypointsController extends BaseController
/**
* 获取航线具体航点明细详细信息
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
@ -85,6 +89,14 @@ public class RouteWaypointsController extends BaseController
@PostMapping
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
LoginUser loginUser = SecurityUtils.getLoginUser();
Long rid = roomId;
if (rid == null && routeWaypoints.getRouteId() != null) {
rid = roomRoutePermissionHelper.resolveRoomIdByRouteId(routeWaypoints.getRouteId());
}
if (rid != null && !roomRoutePermissionHelper.canEditRoutesInRoom(rid, loginUser)) {
return AjaxResult.error("暂无权限修改航点");
}
int rows = routeWaypointsService.insertRouteWaypoints(routeWaypoints);
if (rows > 0) {
try {
@ -115,6 +127,14 @@ public class RouteWaypointsController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
LoginUser loginUser = SecurityUtils.getLoginUser();
Long rid = roomId;
if (rid == null && routeWaypoints.getRouteId() != null) {
rid = roomRoutePermissionHelper.resolveRoomIdByRouteId(routeWaypoints.getRouteId());
}
if (rid != null && !roomRoutePermissionHelper.canEditRoutesInRoom(rid, loginUser)) {
return AjaxResult.error("暂无权限修改航点");
}
RouteWaypoints before = routeWaypoints.getId() != null ? routeWaypointsService.selectRouteWaypointsById(routeWaypoints.getId()) : null;
int rows = routeWaypointsService.updateRouteWaypoints(routeWaypoints);
if (rows > 0 && before != null) {
@ -147,6 +167,16 @@ public class RouteWaypointsController extends BaseController
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
LoginUser loginUser = SecurityUtils.getLoginUser();
for (Long id : ids) {
RouteWaypoints wp = routeWaypointsService.selectRouteWaypointsById(id);
if (wp != null && wp.getRouteId() != null) {
Long rid = roomId != null ? roomId : roomRoutePermissionHelper.resolveRoomIdByRouteId(wp.getRouteId());
if (rid != null && !roomRoutePermissionHelper.canEditRoutesInRoom(rid, loginUser)) {
return AjaxResult.error("暂无权限删除航点");
}
}
}
for (Long id : ids) {
RouteWaypoints before = routeWaypointsService.selectRouteWaypointsById(id);
if (before != null) {

39
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java

@ -23,6 +23,9 @@ import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRoomPlatformIconService;
import com.ruoyi.system.service.IRoutesService;
import com.ruoyi.web.service.RoomRoutePermissionHelper;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.dto.PlatformStyleDTO;
@ -58,6 +61,9 @@ public class RoutesController extends BaseController
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RoomRoutePermissionHelper roomRoutePermissionHelper;
@Autowired
@Qualifier("fourTRedisTemplate")
private RedisTemplate<String, String> fourTRedisTemplate;
@ -148,7 +154,6 @@ public class RoutesController extends BaseController
* Redis 获取平台样式
* 独立平台routeId=0优先用 platformIconInstanceId 取该实例样式未传时用 platformId兼容旧数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getPlatformStyle")
public AjaxResult getPlatformStyle(PlatformStyleDTO dto)
{
@ -242,7 +247,6 @@ public class RoutesController extends BaseController
/**
* Redis 获取4T数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/get4TData")
public AjaxResult get4TData(Long roomId)
{
@ -265,7 +269,6 @@ public class RoutesController extends BaseController
* Redis 获取截图展示数据GET + roomId 查询参数
* 与保存共用路径前缀方法不同避免与其它 GET 字面路径网关规则混淆
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/roomScreenshotGallery")
public AjaxResult getScreenshotGalleryData(@RequestParam Long roomId)
{
@ -321,7 +324,6 @@ public class RoutesController extends BaseController
/**
* Redis 获取六步法任务页数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getTaskPageData")
public AjaxResult getTaskPageData(Long roomId)
{
@ -360,7 +362,6 @@ public class RoutesController extends BaseController
/**
* Redis 获取六步法全部数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getSixStepsData")
public AjaxResult getSixStepsData(Long roomId)
{
@ -382,7 +383,6 @@ public class RoutesController extends BaseController
/**
* 获取导弹发射参数列表Redis房间+航线+平台为 key值为数组每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/missile-params")
public AjaxResult getMissileParams(Long roomId, Long routeId, Long platformId)
{
@ -481,7 +481,6 @@ public class RoutesController extends BaseController
/**
* 查询实体部署与航线列表
*/
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(Routes routes)
{
@ -507,7 +506,6 @@ public class RoutesController extends BaseController
* 获取实体部署与航线详细信息
* 路径仅匹配数字 id避免与 /get4TData/getScreenshotGalleryData 等字面路径冲突
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping(value = "/{id:\\d+}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
@ -523,7 +521,15 @@ public class RoutesController extends BaseController
@PostMapping
public AjaxResult add(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
// 1. 执行插入,MyBatis 会通过 useGeneratedKeys="true" 自动将新 ID 注入 routes 对象
LoginUser loginUser = SecurityUtils.getLoginUser();
Long rid = roomId;
if (rid == null && routes.getScenarioId() != null) {
rid = roomRoutePermissionHelper.resolveRoomIdByScenarioId(routes.getScenarioId());
}
if (rid != null && !roomRoutePermissionHelper.canEditRoutesInRoom(rid, loginUser)) {
return AjaxResult.error("暂无权限在该房间创建航线");
}
routes.setCreatorId(loginUser.getUserId());
int rows = routesService.insertRoutes(routes);
if (rows > 0) {
try {
@ -554,6 +560,14 @@ public class RoutesController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
LoginUser loginUser = SecurityUtils.getLoginUser();
Long rid = roomId;
if (rid == null && routes.getId() != null) {
rid = roomRoutePermissionHelper.resolveRoomIdByRouteId(routes.getId());
}
if (!roomRoutePermissionHelper.canEditRoute(routes.getId(), rid, loginUser)) {
return AjaxResult.error("暂无权限修改该航线(仅管理员或航线创建者可修改)");
}
Routes before = null;
if (routes.getId() != null) {
before = routesService.selectRoutesById(routes.getId());
@ -591,6 +605,13 @@ public class RoutesController extends BaseController
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
LoginUser loginUser = SecurityUtils.getLoginUser();
for (Long id : ids) {
Long rid = roomId != null ? roomId : roomRoutePermissionHelper.resolveRoomIdByRouteId(id);
if (!roomRoutePermissionHelper.canEditRoute(id, rid, loginUser)) {
return AjaxResult.error("暂无权限删除该航线(仅管理员或航线创建者可删除)");
}
}
for (Long id : ids) {
Routes before = routesService.selectRoutesById(id);
if (before != null) {

165
ruoyi-admin/src/main/java/com/ruoyi/web/service/RoomAccessRedisService.java

@ -0,0 +1,165 @@
package com.ruoyi.web.service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import javax.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.SecurityUtils;
/**
* Room access control via Redis (pure string serialization).
*/
@Service
public class RoomAccessRedisService {
private static final String KEY_HOSTS = "room:hosts:";
private static final String KEY_ROOM_USERS = "room:allowedUsers:";
private static final String KEY_USER_ROOMS = "user:";
private static final String KEY_USER_ROOMS_SUFFIX = ":allowedRooms";
@Resource(name = "roomAccessRedisTemplate")
private RedisTemplate<String, String> redisTemplate;
private String hostsKey(Long roomId) {
return KEY_HOSTS + roomId;
}
private String roomUsersKey(Long roomId) {
return KEY_ROOM_USERS + roomId;
}
private String userRoomsKey(Long userId) {
return KEY_USER_ROOMS + userId + KEY_USER_ROOMS_SUFFIX;
}
public boolean isRoomAccessConfigured(Long roomId) {
if (roomId == null) {
return false;
}
Long n = redisTemplate.opsForSet().size(roomUsersKey(roomId));
return n != null && n.longValue() > 0;
}
/**
* Admin = userId==1 OR userLevel=='1' OR has 'admin' role.
*/
public boolean isAdmin(com.ruoyi.common.core.domain.model.LoginUser user) {
if (user == null) return false;
if (user.getUserId() != null && user.getUserId() == 1L) return true;
if (user.getUser() != null && "1".equals(user.getUser().getUserLevel())) return true;
try {
if (SecurityUtils.hasRole("admin")) return true;
} catch (Exception ignored) {}
return false;
}
/**
* Admin always allowed. Others must be in the Redis allowed-user set.
*/
public boolean canEnterRoom(Long userId, Long roomId, com.ruoyi.common.core.domain.model.LoginUser loginUser) {
if (roomId == null || userId == null) {
return false;
}
if (isAdmin(loginUser)) {
return true;
}
Boolean in = redisTemplate.opsForSet().isMember(roomUsersKey(roomId), userId.toString());
return Boolean.TRUE.equals(in);
}
public boolean isHost(Long userId, Long roomId) {
if (userId == null || roomId == null) {
return false;
}
Boolean h = redisTemplate.opsForSet().isMember(hostsKey(roomId), userId.toString());
return Boolean.TRUE.equals(h);
}
/** Admin sets a user as host for a room (also adds to allowed set). */
public void setHost(Long roomId, Long hostUserId, com.ruoyi.common.core.domain.model.LoginUser operator) {
if (roomId == null || hostUserId == null || operator == null) {
throw new IllegalArgumentException("Missing required parameters");
}
if (!isAdmin(operator)) {
throw new SecurityException("Only admins can assign hosts");
}
String uid = hostUserId.toString();
String rid = roomId.toString();
redisTemplate.opsForSet().add(hostsKey(roomId), uid);
redisTemplate.opsForSet().add(roomUsersKey(roomId), uid);
redisTemplate.opsForSet().add(userRoomsKey(hostUserId), rid);
}
/** Admin or host grants a user access to a room. */
public void grantUser(Long roomId, Long targetUserId, com.ruoyi.common.core.domain.model.LoginUser operator) {
if (roomId == null || targetUserId == null || operator == null) {
throw new IllegalArgumentException("Missing required parameters");
}
Long opId = operator.getUserId();
boolean ok = isAdmin(operator) || isHost(opId, roomId);
if (!ok) {
throw new SecurityException("No permission to grant access");
}
String uid = targetUserId.toString();
String rid = roomId.toString();
redisTemplate.opsForSet().add(roomUsersKey(roomId), uid);
redisTemplate.opsForSet().add(userRoomsKey(targetUserId), rid);
}
public void revokeUser(Long roomId, Long targetUserId, com.ruoyi.common.core.domain.model.LoginUser operator) {
if (roomId == null || targetUserId == null || operator == null) {
throw new IllegalArgumentException("Missing required parameters");
}
Long opId = operator.getUserId();
boolean ok = isAdmin(operator) || isHost(opId, roomId);
if (!ok) {
throw new SecurityException("No permission to revoke access");
}
String uid = targetUserId.toString();
String rid = roomId.toString();
redisTemplate.opsForSet().remove(roomUsersKey(roomId), uid);
redisTemplate.opsForSet().remove(userRoomsKey(targetUserId), rid);
redisTemplate.opsForSet().remove(hostsKey(roomId), uid);
}
public List<Long> listAllowedUserIds(Long roomId) {
if (roomId == null) {
return Collections.emptyList();
}
Set<String> members = redisTemplate.opsForSet().members(roomUsersKey(roomId));
if (members == null || members.isEmpty()) {
return Collections.emptyList();
}
List<Long> out = new ArrayList<>();
for (String s : members) {
try {
out.add(Long.parseLong(s));
} catch (NumberFormatException ignored) {
// skip
}
}
return out;
}
public List<Long> listHostUserIds(Long roomId) {
if (roomId == null) {
return Collections.emptyList();
}
Set<String> members = redisTemplate.opsForSet().members(hostsKey(roomId));
if (members == null || members.isEmpty()) {
return Collections.emptyList();
}
List<Long> out = new ArrayList<>();
for (String s : members) {
try {
out.add(Long.parseLong(s));
} catch (NumberFormatException ignored) {
// skip
}
}
return out;
}
}

116
ruoyi-admin/src/main/java/com/ruoyi/web/service/RoomRoutePermissionHelper.java

@ -0,0 +1,116 @@
package com.ruoyi.web.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.system.domain.MissionScenario;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.service.IMissionScenarioService;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.system.service.IRoutesService;
/**
* Route edit permission:
* Admin (userLevel=1 / system admin) -> always allowed
* Host of the room (Redis room:hosts:{roomId}) -> allowed
* Route creator (routes.creator_id) -> allowed
* Others -> denied
*
* In parent rooms (parentId == null), only admin can edit.
*/
@Component
public class RoomRoutePermissionHelper {
@Autowired
private IRoomsService roomsService;
@Autowired
private IRoutesService routesService;
@Autowired
private IMissionScenarioService missionScenarioService;
@Autowired
private RoomAccessRedisService roomAccessService;
public Long resolveRoomIdByScenarioId(Long scenarioId) {
if (scenarioId == null) {
return null;
}
MissionScenario sc = missionScenarioService.selectMissionScenarioById(scenarioId);
return sc != null ? sc.getRoomId() : null;
}
public Long resolveRoomIdByRouteId(Long routeId) {
if (routeId == null) {
return null;
}
Routes r = routesService.selectRoutesById(routeId);
if (r == null || r.getScenarioId() == null) {
return null;
}
MissionScenario sc = missionScenarioService.selectMissionScenarioById(r.getScenarioId());
return sc != null ? sc.getRoomId() : null;
}
private boolean isAdminUser(LoginUser loginUser) {
return roomAccessService.isAdmin(loginUser);
}
/**
* Can the user create routes in this room?
* Admin -> yes; Host of this room -> yes; Parent room -> only admin; Others in child room -> yes (they create, they own)
*/
public boolean canEditRoutesInRoom(Long roomId, LoginUser loginUser) {
if (roomId == null || loginUser == null) {
return false;
}
if (isAdminUser(loginUser)) {
return true;
}
Rooms room = roomsService.selectRoomsById(roomId);
if (room == null) {
return false;
}
if (room.getParentId() == null) {
return false;
}
return true;
}
/**
* Can the user edit/delete a specific route?
* Admin -> always yes
* Parent room -> only admin
* Host of this room -> yes
* Route creator -> yes
* Others -> no
*/
public boolean canEditRoute(Long routeId, Long roomId, LoginUser loginUser) {
if (loginUser == null) {
return false;
}
Long uid = loginUser.getUserId();
if (isAdminUser(loginUser)) {
return true;
}
Rooms room = null;
if (roomId != null) {
room = roomsService.selectRoomsById(roomId);
}
if (room != null && room.getParentId() == null) {
return false;
}
if (roomId != null && roomAccessService.isHost(uid, roomId)) {
return true;
}
if (routeId != null) {
Routes route = routesService.selectRoutesById(routeId);
if (route != null && route.getCreatorId() != null && route.getCreatorId().equals(uid)) {
return true;
}
}
return false;
}
}

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

@ -2,8 +2,10 @@ package com.ruoyi.websocket.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
@ -78,6 +80,8 @@ public class RoomWebSocketController {
private static final String TYPE_OBJECT_EDIT_UNLOCK = "OBJECT_EDIT_UNLOCK";
/** 地图选中状态同步(供「当前操作」展示) */
private static final String TYPE_USER_SELECTION = "USER_SELECTION";
/** 六步法开关:大房间下发时同步到所有子房间 topic */
private static final String TYPE_SYNC_SIX_STEPS = "SYNC_SIX_STEPS";
/**
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATSYNC_*
@ -113,6 +117,8 @@ public class RoomWebSocketController {
handlePrivateChatHistoryRequest(roomId, loginUser, body);
} else if (TYPE_SYNC_ROUTE_VISIBILITY.equals(type)) {
handleSyncRouteVisibility(roomId, body, sessionId);
} else if (TYPE_SYNC_SIX_STEPS.equals(type)) {
handleSyncSixSteps(roomId, body, sessionId);
} else if (TYPE_SYNC_WAYPOINTS.equals(type)) {
handleSyncWaypoints(roomId, body, sessionId);
} else if (TYPE_SYNC_PLATFORM_ICONS.equals(type)) {
@ -362,9 +368,99 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp);
}
/** 航线显隐变更:小房间内每人展示各自内容,不再广播和持久化 */
/**
* 航线显隐在同一父房间下的所有子房间 + 父房间 topic 之间广播仅展示同步不涉及库表
*/
private void handleSyncRouteVisibility(Long roomId, Map<String, Object> body, String sessionId) {
// 小房间内每人展示各自内容,航线显隐不再同步给他人、不再持久化
if (body == null || !body.containsKey("routeId")) {
return;
}
Rooms room = roomsService.selectRoomsById(roomId);
if (room == null) {
return;
}
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_ROUTE_VISIBILITY);
msg.put("routeId", body.get("routeId"));
msg.put("visible", body.get("visible") != null && Boolean.TRUE.equals(body.get("visible")));
if (body.containsKey("styleRoomId")) {
msg.put("styleRoomId", body.get("styleRoomId"));
}
msg.put("senderSessionId", sessionId);
Set<Long> targets = new HashSet<>();
targets.add(roomId);
if (room.getParentId() != null) {
targets.add(room.getParentId());
Rooms query = new Rooms();
query.setParentId(room.getParentId());
List<Rooms> siblings = roomsService.selectRoomsList(query);
if (siblings != null) {
for (Rooms s : siblings) {
if (s != null && s.getId() != null) {
targets.add(s.getId());
}
}
}
} else {
Rooms query = new Rooms();
query.setParentId(roomId);
List<Rooms> children = roomsService.selectRoomsList(query);
if (children != null) {
for (Rooms c : children) {
if (c != null && c.getId() != null) {
targets.add(c.getId());
}
}
}
}
for (Long tid : targets) {
messagingTemplate.convertAndSend("/topic/room/" + tid, msg);
}
}
/** 解析前端 JSON 中的 boolean(兼容 true/false、1/0、字符串) */
private static boolean parseBooleanOpen(Map<String, Object> body) {
if (body == null || !body.containsKey("open")) {
return false;
}
Object o = body.get("open");
if (o instanceof Boolean) {
return (Boolean) o;
}
if (o instanceof Number) {
return ((Number) o).intValue() != 0;
}
String s = String.valueOf(o);
return "true".equalsIgnoreCase(s) || "1".equals(s);
}
/**
* 六步法仅当发送端在大房间parent_id 为空除本房间外再广播到全部子房间
*/
private void handleSyncSixSteps(Long roomId, Map<String, Object> body, String sessionId) {
Rooms room = roomsService.selectRoomsById(roomId);
if (room == null) {
return;
}
boolean open = parseBooleanOpen(body);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_SIX_STEPS);
msg.put("open", open);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
if (room.getParentId() == null) {
Rooms query = new Rooms();
query.setParentId(roomId);
List<Rooms> children = roomsService.selectRoomsList(query);
if (children != null) {
for (Rooms c : children) {
if (c != null && c.getId() != null) {
messagingTemplate.convertAndSend("/topic/room/" + c.getId(), msg);
}
}
}
}
}
/** 广播航点变更,供其他设备实时同步 */

16
ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java

@ -67,6 +67,22 @@ public class RedisConfig extends CachingConfigurerSupport
* 纯字符串 RedisTemplate用于存储 4T JSON 字符串避免 FastJson 序列化问题
* 命名为 fourTRedisTemplate 避免与 Spring Boot 自带的 stringRedisTemplate 冲突
*/
/**
* Room access RedisTemplate: pure string key+value, no FastJson wrapping.
*/
@Bean("roomAccessRedisTemplate")
public RedisTemplate<String, String> roomAccessRedisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean("fourTRedisTemplate")
public RedisTemplate<String, String> fourTRedisTemplate(RedisConnectionFactory connectionFactory)
{

12
ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java

@ -45,6 +45,9 @@ public class Routes extends BaseEntity {
@Excel(name = "实例属性JSON: 覆盖库里的默认值 (如当前初始油量, 挂载配置)")
private String attributes;
/** 航线创建者用户ID */
private Long creatorId;
private List<RouteWaypoints> waypoints;
/** 方案ID列表(查询用,非持久化):传入时查询 scenario_id IN (...) */
@ -96,6 +99,14 @@ public class Routes extends BaseEntity {
return attributes;
}
public Long getCreatorId() {
return creatorId;
}
public void setCreatorId(Long creatorId) {
this.creatorId = creatorId;
}
public List<RouteWaypoints> getWaypoints() {
return waypoints;
}
@ -148,6 +159,7 @@ public class Routes extends BaseEntity {
.append("platformId", getPlatformId())
.append("callSign", getCallSign())
.append("attributes", getAttributes())
.append("creatorId", getCreatorId())
.append("waypoints", getWaypoints())
.toString();
}

7
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java

@ -144,4 +144,11 @@ public interface SysUserMapper
* @return 结果
*/
public SysUser checkEmailUnique(String email);
/**
* 房间准入管理按账号等级筛选用户不走数据权限切面仅由房间准入接口鉴权后调用
*
* @param user 查询条件userName 模糊params.roomAccessProfile = host | normal
*/
public List<SysUser> selectUsersForRoomAccess(SysUser user);
}

5
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java

@ -20,6 +20,11 @@ public interface ISysUserService
public List<SysUser> selectUserList(SysUser user);
/**
* 房间准入管理筛选主持人(2)或普通用户(3/)无数据权限过滤
*/
public List<SysUser> selectUsersForRoomAccess(SysUser user, String profile);
/**
* 根据条件分页查询已分配用户角色列表
*
* @param user 用户信息

10
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java

@ -77,6 +77,16 @@ public class SysUserServiceImpl implements ISysUserService
}
/**
* 房间准入仅按等级与关键字筛选不应用数据权限由房间准入 Controller 鉴权
*/
@Override
public List<SysUser> selectUsersForRoomAccess(SysUser user, String profile)
{
user.getParams().put("roomAccessProfile", profile);
return userMapper.selectUsersForRoomAccess(user);
}
/**
* 根据条件分页查询已分配用户角色列表
*
* @param user 用户信息

5
ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml

@ -10,11 +10,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="platformId" column="platform_id" />
<result property="callSign" column="call_sign" />
<result property="attributes" column="attributes" />
<result property="creatorId" column="creator_id" />
<collection property="waypoints" javaType="java.util.List" resultMap="com.ruoyi.system.mapper.RouteWaypointsMapper.RouteWaypointsResult" />
</resultMap>
<sql id="selectRoutesVo">
select id, scenario_id, platform_id, call_sign, attributes from routes
select id, scenario_id, platform_id, call_sign, attributes, creator_id from routes
</sql>
<select id="selectRoutesList" parameterType="Routes" resultMap="RoutesResult">
@ -42,12 +43,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="platformId != null">platform_id,</if>
<if test="callSign != null and callSign != ''">call_sign,</if>
<if test="attributes != null and attributes != ''">attributes,</if>
<if test="creatorId != null">creator_id,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="scenarioId != null">#{scenarioId},</if>
<if test="platformId != null">#{platformId},</if>
<if test="callSign != null and callSign != ''">#{callSign},</if>
<if test="attributes != null and attributes != ''">#{attributes},</if>
<if test="creatorId != null">#{creatorId},</if>
</trim>
</insert>

21
ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml

@ -86,6 +86,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<!-- 数据范围过滤 -->
${params.dataScope}
</select>
<select id="selectUsersForRoomAccess" parameterType="SysUser" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, u.user_level
from sys_user u
where u.del_flag = '0' and u.status = '0'
<if test="userName != null and userName != ''">
AND (u.user_name like concat('%', #{userName}, '%') OR u.nick_name like concat('%', #{userName}, '%'))
</if>
<choose>
<when test="params.roomAccessProfile != null and params.roomAccessProfile == 'host'">
AND u.user_level = '2'
</when>
<when test="params.roomAccessProfile != null and params.roomAccessProfile == 'normal'">
AND (u.user_level = '3' OR u.user_level IS NULL OR u.user_level = '')
</when>
<otherwise>
AND 1 = 0
</otherwise>
</choose>
order by u.user_id
</select>
<select id="selectAllocatedList" parameterType="SysUser" resultMap="SysUserResult">
select distinct u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.phonenumber, u.status, u.create_time

76
ruoyi-ui/src/api/system/rooms.js

@ -1,6 +1,5 @@
import request from '@/utils/request'
// 查询项目与任务房间列表
export function listRooms(query) {
return request({
url: '/system/rooms/list',
@ -9,7 +8,6 @@ export function listRooms(query) {
})
}
// 查询项目与任务房间详细
export function getRooms(id) {
return request({
url: '/system/rooms/' + id,
@ -17,7 +15,6 @@ export function getRooms(id) {
})
}
// 新增项目与任务房间
export function addRooms(data) {
return request({
url: '/system/rooms',
@ -26,7 +23,6 @@ export function addRooms(data) {
})
}
// 修改项目与任务房间
export function updateRooms(data) {
return request({
url: '/system/rooms',
@ -35,10 +31,80 @@ export function updateRooms(data) {
})
}
// 删除项目与任务房间
export function delRooms(id) {
return request({
url: '/system/rooms/' + id,
method: 'delete'
})
}
/** Check if current user can enter a room */
export function checkRoomAccess(roomId) {
return request({
url: '/system/room-access/check',
method: 'get',
params: { roomId }
})
}
/** Check if current user is host for a specific room */
export function checkIsHost(roomId) {
return request({
url: '/system/room-access/isHost',
method: 'get',
params: { roomId }
})
}
/** Permission management dialog context */
export function getRoomAccessManageContext(roomId) {
return request({
url: '/system/room-access/manageContext',
method: 'get',
params: { roomId }
})
}
/** All rooms list (admin only, for room selector) */
export function getAllRoomsForAccess() {
return request({
url: '/system/room-access/allRooms',
method: 'get'
})
}
/** User candidates: profile=host | normal */
export function listRoomAccessUserCandidates(query) {
return request({
url: '/system/room-access/userCandidates',
method: 'get',
params: query
})
}
/** Admin: assign hosts to rooms. Body: { userIds, roomIds } */
export function setRoomHostBatch(data) {
return request({
url: '/system/room-access/setHostBatch',
method: 'post',
data
})
}
/** Admin/Host: grant users access to a room. Body: { userIds, roomId } */
export function grantRoomAccessBatch(data) {
return request({
url: '/system/room-access/grantBatch',
method: 'post',
data
})
}
/** Revoke user access */
export function revokeRoomAccess(data) {
return request({
url: '/system/room-access/revoke',
method: 'post',
data
})
}

27
ruoyi-ui/src/utils/websocket.js

@ -18,7 +18,8 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
* @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.onSyncRouteVisibility - 航线显隐同步 (routeId, visible, senderSessionId, styleRoomId) => {}
* @param {Function} options.onSyncSixSteps - 大房间六步法开关同步 (open, senderSessionId) => {}
* @param {Function} options.onSyncWaypoints - 航点变更同步回调 (routeId, senderUserId) => {}
* @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {}
@ -43,6 +44,7 @@ export function createRoomWebSocket(options) {
onChatHistory,
onPrivateChatHistory,
onSyncRouteVisibility,
onSyncSixSteps,
onSyncWaypoints,
onSyncPlatformIcons,
onSyncRoomDrawings,
@ -160,11 +162,24 @@ export function createRoomWebSocket(options) {
}
}
function sendSyncRouteVisibility(routeId, visible) {
function sendSyncRouteVisibility(routeId, visible, styleRoomId) {
if (client && client.connected) {
const payload = { type: 'SYNC_ROUTE_VISIBILITY', routeId, visible }
if (styleRoomId != null) {
payload.styleRoomId = styleRoomId
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
}
function sendSyncSixSteps(open) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_ROUTE_VISIBILITY', routeId, visible })
body: JSON.stringify({ type: 'SYNC_SIX_STEPS', open: !!open })
})
}
}
@ -268,7 +283,10 @@ export function createRoomWebSocket(options) {
} 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)
const srid = body.styleRoomId != null ? body.styleRoomId : null
onSyncRouteVisibility && onSyncRouteVisibility(body.routeId, !!body.visible, body.senderSessionId, srid)
} else if (type === 'SYNC_SIX_STEPS') {
onSyncSixSteps && onSyncSixSteps(!!body.open, body.senderSessionId)
} else if (type === 'SYNC_WAYPOINTS' && body.routeId != null) {
onSyncWaypoints && onSyncWaypoints(body.routeId, body.senderSessionId)
} else if (type === 'SYNC_PLATFORM_ICONS') {
@ -379,6 +397,7 @@ export function createRoomWebSocket(options) {
sendPrivateChat,
sendPrivateChatHistoryRequest,
sendSyncRouteVisibility,
sendSyncSixSteps,
sendSyncWaypoints,
sendSyncPlatformIcons,
sendSyncRoomDrawings,

29
ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue

@ -82,6 +82,11 @@ export default {
watch: {
showSixStepsOverlay(val) {
this.$emit('six-steps-overlay-visible', val)
},
/** 六步法开关变化:始终通知父页面,由 index 根据 roomDetail 判断是否为大房间再发 WS(避免 isParentRoom 在 roomDetail 未返回前一直为 false 导致从未上报) */
showSixStepsBar(val) {
if (this.suppressSixStepsBroadcast) return
this.$emit('six-steps-broadcast', val)
}
},
data() {
@ -100,10 +105,32 @@ export default {
{ title: '执行', desc: '实时监控执行过程', active: false, completed: false },
{ title: '评估', desc: '评估任务完成效果', active: false, completed: false }
],
savedProgress: {}
savedProgress: {},
/** 为 true 时不触发 six-steps-broadcast(避免远端同步回灌时再次上报) */
suppressSixStepsBroadcast: false
}
},
methods: {
/** 由父组件在收到 WebSocket SYNC_SIX_STEPS 时调用(子房间跟随大房间开关) */
applyRemoteSixSteps(open) {
this.suppressSixStepsBroadcast = true
if (open) {
this.showSixStepsBar = true
this.restoreProgress()
this.showSixStepsOverlay = true
this.showTimelineBar = true
if (this.$refs.timeline) {
this.$refs.timeline.isVisible = true
}
this.isExpanded = false
this.updateBottomPanelVisible()
} else {
this.closeBoth()
}
this.$nextTick(() => {
this.suppressSixStepsBroadcast = false
})
},
togglePanel() {
this.isExpanded = !this.isExpanded
},

78
ruoyi-ui/src/views/childRoom/RightPanel.vue

@ -103,6 +103,46 @@
</div>
</div>
</div>
<!-- 同一父房间下其他小房间的航线仅地图展示与显隐同步不参与本房间方案编辑 -->
<div v-if="peerRoutes.length" class="section" style="margin-top: 10px;">
<div class="section-header">
<div class="section-title">其他子房间航线仅展示</div>
</div>
<div class="tree-list">
<div
v-for="planGroup in peerRouteGroups"
:key="'peer-plan-' + planGroup.groupKey"
class="tree-item plan-item"
>
<div class="tree-item-header" @click="togglePeerPlan(planGroup.groupKey)">
<i :class="expandedPeerPlans.includes(planGroup.groupKey) ? 'el-icon-folder-opened' : 'el-icon-folder'" class="tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ planGroup.planName }}</div>
<div class="tree-item-meta">{{ planGroup.sourceRoomName }} · {{ planGroup.routes.length }}个航线</div>
</div>
</div>
<div v-if="expandedPeerPlans.includes(planGroup.groupKey)" class="tree-children route-children">
<div
v-for="route in planGroup.routes"
:key="'peer-' + route.id"
class="tree-item route-item peer-route-row"
:class="getRouteClasses(route.id)"
>
<div class="tree-item-header" @click="handleSelectRoute(route)">
<i class="el-icon-map-location tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ route.name }}</div>
</div>
<div class="tree-item-actions">
<i class="el-icon-view" title="显示/隐藏" @click.stop="handleToggleRouteVisibility(route)"></i>
<span class="peer-route-hint">仅查看</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'platform'" class="tab-content platform-content">
<div class="section-header" style="padding: 10px 0; display: flex; justify-content: space-between; align-items: center;">
@ -287,14 +327,40 @@ export default {
type: Array,
default: () => []
},
/** 同一父房间下兄弟小房间的航线(peerViewOnly) */
peerRoutes: {
type: Array,
default: () => []
}
},
data() {
return {
activePlatformTab: 'air',
expandedPlans: [], //
expandedPeerPlans: [], //
platformJustDragged: false //
}
},
computed: {
peerRouteGroups() {
const groups = {}
;(this.peerRoutes || []).forEach(route => {
const sourceRoomId = route.sourceRoomId != null ? route.sourceRoomId : 'unknown-room'
const scenarioId = route.scenarioId != null ? route.scenarioId : 'unknown-plan'
const key = `${sourceRoomId}-${scenarioId}`
if (!groups[key]) {
groups[key] = {
groupKey: key,
sourceRoomName: route.sourceRoomName || `房间${sourceRoomId}`,
planName: route.sourcePlanName || `方案${scenarioId}`,
routes: []
}
}
groups[key].routes.push(route)
})
return Object.values(groups)
}
},
watch: {
expandRouteIds(newVal) {
if (newVal && newVal.length) {
@ -345,6 +411,14 @@ export default {
}
}
},
togglePeerPlan(groupKey) {
const index = this.expandedPeerPlans.indexOf(groupKey)
if (index > -1) {
this.expandedPeerPlans.splice(index, 1)
} else {
this.expandedPeerPlans.push(groupKey)
}
},
// 线 selectRoute 线
toggleRoute(routeId) {
@ -418,6 +492,10 @@ export default {
},
handleOpenRouteDialog(route) {
if (route.peerViewOnly) {
this.$message.warning('其他子房间航线仅供展示与同步查看,不能在当前房间编辑')
return
}
this.$emit('open-route-dialog', route)
},

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

@ -229,6 +229,11 @@
</el-dropdown-menu>
</el-dropdown>
</div>
<span
v-if="showRoomAccessManage"
class="top-nav-item room-access-link"
@click.stop="$emit('open-room-access-manage')"
>权限管理</span>
</div>
</div>
@ -429,6 +434,11 @@ export default {
mapDragEnabled: {
type: Boolean,
default: false
},
/** 是否在「收藏」旁显示「权限管理」入口(由父组件根据房间角色计算) */
showRoomAccessManage: {
type: Boolean,
default: false
}
},
data() {
@ -1219,4 +1229,13 @@ export default {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.room-access-link {
color: #165dff;
font-weight: 600;
margin-left: 4px;
}
.room-access-link:hover {
text-decoration: underline;
}
</style>

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

@ -126,6 +126,7 @@
:is-icon-edit-mode="isIconEditMode"
:current-scale-config="scaleConfig"
:map-drag-enabled="mapDragEnabled"
:show-room-access-manage="roomAccessNavVisible"
@select-nav="selectTopNav"
@toggle-map-drag="mapDragEnabled = !mapDragEnabled"
@set-k-time="openKTimeSetDialog"
@ -178,6 +179,11 @@
@route-favorites="routeFavorites"
@show-online-members="showOnlineMembersDialog"
@edit-user-profile="openUserProfileDialog"
@open-room-access-manage="showRoomAccessManageDialog = true"
/>
<room-access-manage-dialog
v-model="showRoomAccessManageDialog"
:room-id="currentRoomId"
/>
<!-- 左侧折叠菜单栏 - 蓝色主题 -->
<left-menu
@ -210,6 +216,7 @@
:selected-plan-details="selectedPlanDetails"
:selected-route-id="selectedRouteId"
:routes="routes"
:peer-routes="peerRoutes"
:active-route-ids="activeRouteIds"
:route-locked="routeLocked"
:route-locked-by="routeLockedBy"
@ -238,7 +245,7 @@
@open-import-dialog="showImportDialog = true"
/>
<!-- 左下角工具面板白板模式下隐藏避免遮挡白板 -->
<bottom-left-panel v-show="!screenshotMode && !showWhiteboardPanel" @bottom-panel-visible="handleBottomPanelVisible" @six-steps-overlay-visible="sixStepsOverlayVisible = $event" @open-whiteboard="toggleWhiteboardMode" :room-id="currentRoomId" :is-parent-room="!!(roomDetail && roomDetail.parentId == null)" />
<bottom-left-panel ref="bottomLeftPanel" v-show="!screenshotMode && !showWhiteboardPanel" @bottom-panel-visible="handleBottomPanelVisible" @six-steps-overlay-visible="sixStepsOverlayVisible = $event" @open-whiteboard="toggleWhiteboardMode" @six-steps-broadcast="onParentSixStepsBroadcast" :room-id="currentRoomId" :is-parent-room="!!(roomDetail && roomDetail.parentId == null)" />
<!-- 底部时间轴最初版本的样式- 蓝色主题 -->
<div
v-show="!screenshotMode"
@ -549,6 +556,7 @@ 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 RoomAccessManageDialog from '@/views/dialogs/RoomAccessManageDialog'
import GenerateAirspaceDialog from '@/views/dialogs/GenerateAirspaceDialog'
import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
@ -563,7 +571,7 @@ import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle, savePlatformStyle, getMissileParams, updateMissilePositions, saveMissileParams, deleteMissileParams } from "@/api/system/routes";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms, listRooms } from "@/api/system/rooms";
import { getRooms, updateRooms, listRooms, checkRoomAccess, getRoomAccessManageContext, checkIsHost } from "@/api/system/rooms";
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig";
import { listByRoomId as listRoomPlatformIcons, addRoomPlatformIcon, updateRoomPlatformIcon, delRoomPlatformIcon } from "@/api/system/roomPlatformIcon";
import {
@ -605,6 +613,7 @@ export default {
PageLayoutDialog,
KTimeSetDialog,
UserProfileDialog,
RoomAccessManageDialog,
GenerateAirspaceDialog,
LeftMenu,
RightPanel,
@ -626,6 +635,9 @@ export default {
// 线
showOnlineMembers: false,
showUserProfileDialog: false,
showRoomAccessManageDialog: false,
roomAccessNavVisible: false,
isCurrentRoomHost: false,
//
showPlatformDialog: false,
selectedPlatform: null,
@ -688,6 +700,10 @@ export default {
onlineCount: 0,
wsOnlineMembers: [],
wsConnection: null,
/** 大房间 SYNC_SIX_STEPS 早于 roomDetail 返回时暂存,roomDetail 就绪后补应用 */
_pendingSyncSixSteps: null,
/** 大房间:六步法已开但 WebSocket 尚未 connected 时待发送的开关 */
_pendingParentSixStepsSend: null,
chatMessages: [],
privateChatMessages: {},
combatTime: 'K+00:00:00', //
@ -810,6 +826,8 @@ export default {
//
currentRoomId: null,
plans: [],
/** 同一父房间下兄弟小房间的航线列表(仅展示) */
peerRoutes: [],
activeRightTab: 'plan',
activeRouteIds: [], // 线ID
/** 新加入时收到的房间可见航线 ID,若 routes 未加载则暂存,getList 完成后应用 */
@ -1319,6 +1337,7 @@ export default {
if (response.code === 200 && response.data) {
const routeData = response.data;
// 线
const listMeta = this.routes.find(r => r.id === routeData.id) || this.peerRoutes.find(r => r.id === routeData.id);
const route = {
id: routeData.id,
name: routeData.callSign,
@ -1326,8 +1345,11 @@ export default {
platformId: routeData.platformId,
platform: routeData.platform,
attributes: routeData.attributes,
creatorId: routeData.creatorId || (listMeta && listMeta.creatorId) || null,
points: routeData.waypoints ? routeData.waypoints.length : 0,
waypoints: routeData.waypoints || []
waypoints: routeData.waypoints || [],
peerViewOnly: !!(listMeta && listMeta.peerViewOnly),
sourceRoomId: listMeta && listMeta.sourceRoomId
};
// 线
this.openRouteDialog(route);
@ -2409,8 +2431,21 @@ export default {
const newer = existing.filter(m => (m.timestamp || 0) > maxTs);
this.$set(this.privateChatMessages, targetUserId, [...history, ...newer]);
},
onSyncRouteVisibility: () => {
// 线
onSyncRouteVisibility: (rid, vis, senderSessionId, styleRoomId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncRouteVisibility(rid, vis, styleRoomId);
},
onSyncSixSteps: (open) => {
// 广
if (this.roomDetail && this.roomDetail.parentId == null) return;
// MEMBER_LIST sessionId mySyncSessionIds
// isMySyncSession
const wantOpen = !!open;
if (!this.roomDetail) {
this._pendingSyncSixSteps = wantOpen;
return;
}
this.applyRemoteSixStepsFromParent(wantOpen);
},
onSyncWaypoints: (routeId, senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
@ -2433,6 +2468,13 @@ export default {
},
onConnected: () => {
setTimeout(() => this.pushUserSelectionNow(), 200);
if (this._pendingParentSixStepsSend != null && this.roomDetail && this.roomDetail.parentId == null) {
const v = this._pendingParentSixStepsSend;
this._pendingParentSixStepsSend = null;
if (this.wsConnection && this.wsConnection.connected && this.wsConnection.sendSyncSixSteps) {
this.wsConnection.sendSyncSixSteps(v);
}
}
},
onDisconnected: () => {
this.onlineCount = 0;
@ -2453,6 +2495,7 @@ export default {
this.wsConnection.disconnect();
this.wsConnection = null;
}
this._pendingParentSixStepsSend = null;
this.roomStatePendingVisibleRouteIds = [];
this.wsOnlineMembers = [];
this.mySyncSessionIds = [];
@ -2534,10 +2577,31 @@ export default {
if (!senderSessionId) return false;
return Array.isArray(this.mySyncSessionIds) && this.mySyncSessionIds.includes(senderSessionId);
},
/** 收到其他设备的航线显隐同步:直接应用变更,不经过 selectRoute 的 toggle 逻辑,避免互相干扰 */
async applySyncRouteVisibility(routeId, visible) {
const route = this.routes.find(r => r.id === routeId);
/** 收到父子/兄弟房间的航线显隐同步(styleRoomId 用于 Redis 平台样式所在房间) */
async applySyncRouteVisibility(routeId, visible, styleRoomIdFromMsg) {
let route = this.routes.find(r => r.id === routeId) || this.peerRoutes.find(r => r.id === routeId);
if (!route && visible) {
try {
const res = await getRoutes(routeId);
if (res.code !== 200 || !res.data) return;
const d = res.data;
route = {
id: d.id,
name: d.callSign,
platformId: d.platformId,
platform: d.platform,
attributes: d.attributes,
scenarioId: d.scenarioId,
peerViewOnly: true,
sourceRoomId: styleRoomIdFromMsg || null,
points: (d.waypoints || []).length
};
} catch (_) {
return;
}
}
if (!route) return;
const styleRoomId = styleRoomIdFromMsg != null ? styleRoomIdFromMsg : this.resolveStyleRoomIdForRoute(route);
if (visible) {
if (this.activeRouteIds.includes(routeId)) return;
try {
@ -2558,11 +2622,14 @@ export default {
if (routeIndex > -1) {
this.$set(this.routes, routeIndex, { ...this.routes[routeIndex], waypoints });
}
const pi = this.peerRoutes.findIndex(r => r.id === route.id);
if (pi > -1) {
this.$set(this.peerRoutes, pi, { ...this.peerRoutes[pi], waypoints });
}
if (waypoints.length > 0 && this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && route.platformId) {
if (styleRoomId && route.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: route.id, platformId: route.platformId });
const styleRes = await getPlatformStyle({ roomId: styleRoomId, routeId: route.id, platformId: route.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
} catch (_) {}
}
@ -2580,7 +2647,7 @@ export default {
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
const res = await getRoutes(lastId);
if (res.code === 200 && res.data) {
const fromList = this.routes.find(r => r.id === lastId);
const fromList = this.routes.find(r => r.id === lastId) || this.peerRoutes.find(r => r.id === lastId);
this.selectedRouteId = res.data.id;
this.selectedRouteDetails = {
id: res.data.id,
@ -2605,7 +2672,8 @@ export default {
const res = await getRoutes(routeId);
if (res.code !== 200 || !res.data) return;
const waypoints = res.data.waypoints || [];
const route = this.routes.find(r => r.id === routeId);
let route = this.routes.find(r => r.id === routeId);
if (!route) route = this.peerRoutes.find(r => r.id === routeId);
if (route) {
this.$set(route, 'waypoints', waypoints);
}
@ -2613,7 +2681,7 @@ export default {
this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints };
}
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap && waypoints.length > 0) {
const r = this.routes.find(rr => rr.id === routeId);
const r = this.routes.find(rr => rr.id === routeId) || this.peerRoutes.find(rr => rr.id === routeId);
if (r) {
if (waypoints.some(wp => this.isHoldWaypoint(wp))) {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
@ -2824,8 +2892,79 @@ export default {
}
return false;
},
onParentSixStepsBroadcast(open) {
if (!(this.roomDetail && this.roomDetail.parentId == null)) {
return;
}
const want = !!open;
const ws = this.wsConnection;
if (ws && ws.connected && typeof ws.sendSyncSixSteps === 'function') {
ws.sendSyncSixSteps(want);
this._pendingParentSixStepsSend = null;
} else {
this._pendingParentSixStepsSend = want;
}
},
/** 子房间应用大房间下发的六步法开关;ref 未就绪时写入待处理队列 */
applyRemoteSixStepsFromParent(open) {
if (!(this.roomDetail && this.roomDetail.parentId != null)) return;
const panel = this.$refs.bottomLeftPanel;
if (panel && typeof panel.applyRemoteSixSteps === 'function') {
panel.applyRemoteSixSteps(open);
this._pendingSyncSixSteps = null;
} else {
this._pendingSyncSixSteps = open;
this.$nextTick(() => {
const p = this.$refs.bottomLeftPanel;
if (p && typeof p.applyRemoteSixSteps === 'function' && this._pendingSyncSixSteps != null) {
const o = this._pendingSyncSixSteps;
this._pendingSyncSixSteps = null;
p.applyRemoteSixSteps(o);
}
});
}
},
flushPendingSyncSixStepsIfAny() {
if (this._pendingSyncSixSteps == null) return;
if (this.roomDetail && this.roomDetail.parentId == null) {
this._pendingSyncSixSteps = null;
return;
}
if (this.roomDetail && this.roomDetail.parentId != null) {
const o = this._pendingSyncSixSteps;
this._pendingSyncSixSteps = null;
this.$nextTick(() => this.applyRemoteSixStepsFromParent(o));
}
},
resolveStyleRoomIdForRoute(route) {
if (!route) return this.currentRoomId;
const isParentRoom = this.roomDetail && this.roomDetail.parentId == null;
const plan = this.plans.find(p => p.id === route.scenarioId);
if (isParentRoom && plan && plan.roomId) return plan.roomId;
if (route.sourceRoomId) return route.sourceRoomId;
return this.currentRoomId;
},
routeEditDeniedReason(route) {
if (!route) return '无效航线';
if (route.peerViewOnly) return '兄弟房间航线仅供展示与同步查看';
const isParent = this.roomDetail && this.roomDetail.parentId == null;
const ul = String(this.$store.getters.userLevel || '');
const adminOk = this.isAdmin || ul === '1';
if (adminOk) return '';
if (isParent) return '大房间内仅管理员可修改航线';
if (this.isCurrentRoomHost) return '';
const myId = this.currentUserId;
const isCreator = route.creatorId != null && myId != null && String(route.creatorId) === String(myId);
if (isCreator) return '';
return '暂无权限修改该航线(仅管理员、房间主持人或航线创建者可修改)';
},
// 线
openRouteDialog(route) {
const deny = this.routeEditDeniedReason(route);
if (deny) {
this.$message.warning(deny);
return;
}
this.selectedRoute = route;
this.routeEditInitialTab = null;
this.showRouteDialog = true;
@ -2958,6 +3097,10 @@ export default {
}
},
async handleDeleteRoute(route) {
if (route && route.peerViewOnly) {
this.$message.warning('兄弟房间航线仅展示在本地,不能从当前房间删除');
return;
}
if (this.isRouteLockedByOther(route.id)) {
this.$message.warning('该航线正被其他成员编辑,无法删除');
return;
@ -3036,6 +3179,7 @@ export default {
platformId: item.platformId,
platform: item.platform,
attributes: item.attributes,
creatorId: item.creatorId || null,
points: item.waypoints ? item.waypoints.length : 0,
waypoints: (item.waypoints || []).slice().sort((a, b) => {
const saRaw = a.seq != null ? a.seq : a.Seq;
@ -3057,9 +3201,60 @@ export default {
this.routes = allRoutes;
let peerRoutesAcc = [];
if (!isParentRoom && this.roomDetail && this.roomDetail.parentId) {
try {
const sibRes = await listRooms({ pageNum: 1, pageSize: 999, parentId: this.roomDetail.parentId });
const siblings = (sibRes.rows || []).filter(r => String(r.id) !== String(roomId));
for (const sib of siblings) {
const sibScen = await listScenario({ roomId: sib.id, pageNum: 1, pageSize: 9999 });
const srows = (sibScen.code === 200 && sibScen.rows) ? sibScen.rows : [];
const pids = srows.map(p => p.id);
const scenarioNameMap = {};
srows.forEach(p => { scenarioNameMap[p.id] = p.name || `方案${p.id}`; });
if (pids.length === 0) continue;
const sibRt = await listRoutes({ scenarioIdsStr: pids.join(','), pageNum: 1, pageSize: 9999 });
let sibRows = (sibRt.code === 200 && sibRt.rows) ? sibRt.rows : [];
sibRows = sibRows.filter(r => pids.includes(r.scenarioId));
sibRows.forEach(item => {
peerRoutesAcc.push({
id: item.id,
name: item.callSign,
platformId: item.platformId,
platform: item.platform,
attributes: item.attributes,
points: item.waypoints ? item.waypoints.length : 0,
waypoints: (item.waypoints || []).slice().sort((a, b) => {
const saRaw = a.seq != null ? a.seq : a.Seq;
const sbRaw = b.seq != null ? b.seq : b.Seq;
const saNum = Number(saRaw);
const sbNum = Number(sbRaw);
const sa = Number.isFinite(saNum) ? saNum : Number.POSITIVE_INFINITY;
const sb = Number.isFinite(sbNum) ? sbNum : Number.POSITIVE_INFINITY;
if (sa !== sb) return sa - sb;
return (Number(a.id) || 0) - (Number(b.id) || 0);
}),
conflict: false,
scenarioId: item.scenarioId,
peerViewOnly: true,
sourceRoomId: sib.id,
sourceRoomName: sib.name || `房间${sib.id}`,
sourcePlanName: scenarioNameMap[item.scenarioId] || `方案${item.scenarioId}`
});
});
}
} catch (e) {
console.warn('load peer routes', e);
}
}
this.peerRoutes = peerRoutesAcc;
// / routeId
// activeRouteIds routes 线
const existingIdSet = new Set(allRoutes.map(r => String(r.id)));
const existingIdSet = new Set([
...allRoutes.map(r => String(r.id)),
...peerRoutesAcc.map(r => String(r.id))
]);
const missingRouteIds = (this.activeRouteIds || []).filter(id => !existingIdSet.has(String(id)));
if (missingRouteIds.length > 0 && this.$refs.cesiumMap) {
missingRouteIds.forEach((routeId) => {
@ -3446,26 +3641,67 @@ export default {
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
},
refreshRoomAccessNav() {
if (!this.currentRoomId) {
this.roomAccessNavVisible = false;
this.isCurrentRoomHost = false;
return;
}
const ul = String(this.$store.getters.userLevel || '');
const isAdminUser = this.isAdmin || ul === '1';
if (isAdminUser) {
this.roomAccessNavVisible = true;
this.isCurrentRoomHost = false;
return;
}
checkIsHost(this.currentRoomId).then(res => {
const d = res.data != null ? res.data : res;
this.isCurrentRoomHost = !!(d && d.isHost);
this.roomAccessNavVisible = this.isCurrentRoomHost;
}).catch(() => {
this.isCurrentRoomHost = false;
this.roomAccessNavVisible = false;
});
},
getRoomDetail(callback) {
if (!this.currentRoomId) { callback && callback(); return; }
getRooms(this.currentRoomId).then(res => {
if (res.code === 200 && res.data) {
this.roomDetail = res.data;
this.$nextTick(() => this.loadRoomDrawings());
if (res.data.parentId == null) {
listRooms({ pageNum: 1, pageSize: 999, parentId: this.currentRoomId }).then(roomsRes => {
const rows = roomsRes.rows || roomsRes || [];
this.childRoomKTimes = rows
.filter(r => r.kAnchorTime)
.map(r => ({ name: r.name || `房间${r.id}`, kAnchorTime: r.kAnchorTime }));
this.selectedChildKTimeIndex = 0;
}).catch(() => { this.childRoomKTimes = []; });
} else {
this.childRoomKTimes = [];
if (!this.currentRoomId) { this.roomAccessNavVisible = false; callback && callback(); return; }
const loadDetail = () => {
getRooms(this.currentRoomId).then(res => {
if (res.code === 200 && res.data) {
this.roomDetail = res.data;
this.refreshRoomAccessNav();
this.$nextTick(() => {
this.loadRoomDrawings();
this.flushPendingSyncSixStepsIfAny();
if (res.data.parentId == null && this._pendingParentSixStepsSend != null && this.wsConnection && this.wsConnection.connected && this.wsConnection.sendSyncSixSteps) {
const v = this._pendingParentSixStepsSend;
this._pendingParentSixStepsSend = null;
this.wsConnection.sendSyncSixSteps(v);
}
});
if (res.data.parentId == null) {
listRooms({ pageNum: 1, pageSize: 999, parentId: this.currentRoomId }).then(roomsRes => {
const rows = roomsRes.rows || roomsRes || [];
this.childRoomKTimes = rows
.filter(r => r.kAnchorTime)
.map(r => ({ name: r.name || `房间${r.id}`, kAnchorTime: r.kAnchorTime }));
this.selectedChildKTimeIndex = 0;
}).catch(() => { this.childRoomKTimes = []; });
} else {
this.childRoomKTimes = [];
}
}
callback && callback();
}).catch(() => { callback && callback(); });
};
checkRoomAccess(this.currentRoomId).then(chk => {
if (chk.code === 200 && chk.data && chk.data.allowed === false) {
this.$message.warning('您暂无进入该房间的权限,请联系管理员或主持人授权');
this.$router.replace({ path: '/selectRoom' }).catch(() => {});
return;
}
callback && callback();
}).catch(() => { callback && callback(); });
loadDetail();
}).catch(() => loadDetail());
},
/** 加载当前房间的空域/威力区图形(与房间 ID 绑定,进入该房间即显示);大房间时合并所有子房间的 frontend_drawings */
async loadRoomDrawings() {
@ -7210,7 +7446,7 @@ export default {
attributes: route.attributes
};
// routes 线 waypoints
// routes / peerRoutes 线 waypoints
const routeIndex = this.routes.findIndex(r => r.id === route.id);
if (routeIndex > -1) {
this.$set(this.routes, routeIndex, {
@ -7218,13 +7454,20 @@ export default {
waypoints: waypoints
});
}
const peerIx = this.peerRoutes.findIndex(r => r.id === route.id);
if (peerIx > -1) {
this.$set(this.peerRoutes, peerIx, {
...this.peerRoutes[peerIx],
waypoints: waypoints
});
}
if (waypoints.length > 0) {
if (this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && route.platformId) {
const styleRoomId = this.resolveStyleRoomIdForRoute(route);
if (styleRoomId && route.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: route.id, platformId: route.platformId });
const styleRes = await getPlatformStyle({ roomId: styleRoomId, routeId: route.id, platformId: route.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
} catch (_) {}
}
@ -7363,13 +7606,12 @@ export default {
if (this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(route.id);
}
// 线
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
if (this.activeRouteIds.length > 0) {
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
getRoutes(lastId).then(res => {
if (res.code === 200 && res.data) {
const fromList = this.routes.find(r => r.id === lastId);
const fromList = this.routes.find(r => r.id === lastId) || this.peerRoutes.find(r => r.id === lastId);
this.selectedRouteId = res.data.id;
this.selectedRouteDetails = {
id: res.data.id,
@ -7388,10 +7630,14 @@ export default {
this.selectedRouteDetails = null;
}
}
if (!fromPlanSwitch && this.wsConnection && this.wsConnection.sendSyncRouteVisibility) {
this.wsConnection.sendSyncRouteVisibility(route.id, false, this.resolveStyleRoomIdForRoute(route));
}
} else {
// 线
await this.selectRoute(route);
// 线
if (!fromPlanSwitch && this.wsConnection && this.wsConnection.sendSyncRouteVisibility) {
this.wsConnection.sendSyncRouteVisibility(route.id, true, this.resolveStyleRoomIdForRoute(route));
}
}
},

309
ruoyi-ui/src/views/dialogs/RoomAccessManageDialog.vue

@ -0,0 +1,309 @@
<template>
<el-dialog
:title="'\u6743\u9650\u7ba1\u7406'"
:visible.sync="dialogVisible"
width="780px"
append-to-body
destroy-on-close
@open="onOpen"
>
<div v-if="loading" class="hint">{{'加载中...'}}</div>
<template v-else>
<p v-if="!adminMode && !hostMode" class="hint">{{'当前账号无权管理房间准入'}}</p>
<template v-else>
<!-- Admin mode description -->
<p v-if="adminMode" class="desc">
{{'管理员模式:选择主持人和目标房间,将主持人分配到指定房间进行管理。'}}
</p>
<!-- Host mode description -->
<p v-else class="desc">
{{'主持人模式:选择普通用户,授予其进入当前房间的权限。'}}
</p>
<!-- Search -->
<el-form inline size="small" @submit.native.prevent="fetchUsers">
<el-form-item :label="'\u641c\u7d22'">
<el-input
v-model="keyword"
clearable
:placeholder="'\u8d26\u53f7 / \u6635\u79f0'"
style="width:200px"
@keyup.enter.native="fetchUsers"
@clear="onKeywordClear"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchUsers">{{'查询'}}</el-button>
</el-form-item>
</el-form>
<!-- User table -->
<el-table
ref="table"
:data="rows"
height="260"
border
size="small"
@selection-change="sel = $event"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="userId" label="ID" width="70" />
<el-table-column prop="userName" :label="'\u8d26\u53f7'" min-width="100" />
<el-table-column prop="nickName" :label="'\u6635\u79f0'" min-width="100" />
<el-table-column prop="userLevel" :label="'\u7b49\u7ea7'" width="90">
<template slot-scope="scope">
{{ levelLabel(scope.row.userLevel) }}
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
:current-page.sync="pageNum"
:page-size="pageSize"
layout="total, prev, pager, next"
:total="total"
@current-change="fetchUsers"
/>
</div>
<!-- Admin: room selector (tree-grouped) -->
<div v-if="adminMode" class="room-selector">
<p class="desc" style="margin-top:14px;margin-bottom:6px;">{{'选择要分配的房间:'}}</p>
<el-select
v-model="selectedRoomIds"
multiple
filterable
:placeholder="'\u641c\u7d22\u623f\u95f4\u540d\u79f0'"
style="width:100%"
size="small"
>
<template v-for="group in roomTree">
<el-option-group :key="'g-' + group.id" :label="group.name">
<el-option
:key="group.id"
:label="group.name + '\uFF08\u5927\u623F\u95F4\uFF09'"
:value="group.id"
/>
<el-option
v-for="child in group.children"
:key="child.id"
:label="'\u00A0\u00A0\u00A0\u00A0' + child.name"
:value="child.id"
/>
</el-option-group>
</template>
<!-- Standalone rooms (no parent, no children) -->
<el-option
v-for="room in standaloneRooms"
:key="room.id"
:label="room.name"
:value="room.id"
/>
</el-select>
</div>
<!-- Action buttons -->
<div class="actions">
<template v-if="adminMode">
<el-button
type="primary"
size="small"
:disabled="!selectionIds.length || !selectedRoomIds.length"
@click="doSetHostBatch"
>{{'设为所选房间的主持人'}}</el-button>
</template>
<template v-else>
<el-button
type="primary"
size="small"
:disabled="!selectionIds.length"
@click="doGrantBatch"
>{{'授予当前房间准入'}}</el-button>
</template>
</div>
</template>
</template>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">{{'关闭'}}</el-button>
</span>
</el-dialog>
</template>
<script>
import {
getRoomAccessManageContext,
getAllRoomsForAccess,
listRoomAccessUserCandidates,
grantRoomAccessBatch,
setRoomHostBatch
} from '@/api/system/rooms'
export default {
name: 'RoomAccessManageDialog',
props: {
value: { type: Boolean, default: false },
roomId: { type: [Number, String], default: null }
},
data() {
return {
loading: false,
adminMode: false,
hostMode: false,
keyword: '',
rows: [],
total: 0,
pageNum: 1,
pageSize: 10,
sel: [],
allRooms: [],
selectedRoomIds: []
}
},
computed: {
dialogVisible: {
get() { return this.value },
set(v) { this.$emit('input', v) }
},
selectionIds() {
return (this.sel || []).map(r => r.userId).filter(id => id != null)
},
profile() {
return this.adminMode ? 'host' : 'normal'
},
roomTree() {
const parents = this.allRooms.filter(r => r.parentId == null)
const childMap = {}
this.allRooms.forEach(r => {
if (r.parentId != null) {
if (!childMap[r.parentId]) childMap[r.parentId] = []
childMap[r.parentId].push(r)
}
})
return parents
.filter(p => childMap[p.id] && childMap[p.id].length > 0)
.map(p => ({ ...p, children: childMap[p.id] || [] }))
},
standaloneRooms() {
const parentIds = new Set(this.allRooms.filter(r => r.parentId == null).map(r => r.id))
const hasChildren = new Set()
this.allRooms.forEach(r => {
if (r.parentId != null) hasChildren.add(r.parentId)
})
return this.allRooms.filter(r => r.parentId == null && !hasChildren.has(r.id))
}
},
methods: {
onKeywordClear() {
this.keyword = ''
this.pageNum = 1
this.fetchUsers()
},
levelLabel(lv) {
if (lv === '1') return '\u7ba1\u7406\u5458'
if (lv === '2') return '\u4e3b\u6301\u4eba'
if (lv === '3' || lv == null || lv === '') return '\u666e\u901a\u7528\u6237'
return String(lv)
},
async onOpen() {
this.keyword = ''
this.pageNum = 1
this.rows = []
this.sel = []
this.allRooms = []
this.selectedRoomIds = []
this.loading = true
try {
const res = await getRoomAccessManageContext(this.roomId)
const d = (res && res.data != null) ? res.data : res || {}
this.adminMode = !!d.adminMode
this.hostMode = !!d.hostMode
if (this.adminMode) {
const roomRes = await getAllRoomsForAccess()
this.allRooms = (roomRes && roomRes.data) || []
// Pre-select current room if available
if (this.roomId) {
const rid = Number(this.roomId)
if (this.allRooms.some(r => r.id === rid)) {
this.selectedRoomIds = [rid]
}
}
}
await this.fetchUsers()
} catch (e) {
this.$message.error((e && e.message) || 'Load failed')
} finally {
this.loading = false
}
},
async fetchUsers() {
if (!this.roomId || (!this.adminMode && !this.hostMode)) {
this.rows = []
this.total = 0
return
}
try {
const res = await listRoomAccessUserCandidates({
roomId: this.roomId,
profile: this.profile,
userName: this.keyword || undefined,
pageNum: this.pageNum,
pageSize: this.pageSize
})
this.rows = res.rows || []
this.total = res.total != null ? res.total : 0
} catch (e) {
this.rows = []
this.total = 0
this.$message.error((e && e.message) || '\u67e5\u8be2\u7528\u6237\u5931\u8d25')
}
},
async doSetHostBatch() {
if (!this.selectionIds.length || !this.selectedRoomIds.length) return
try {
await setRoomHostBatch({
userIds: this.selectionIds,
roomIds: this.selectedRoomIds
})
this.$message.success('\u5df2\u5c06\u9009\u4e2d\u4e3b\u6301\u4eba\u5206\u914d\u5230\u6307\u5b9a\u623f\u95f4')
} catch (e) {
this.$message.error((e && e.msg) || (e && e.message) || '\u64cd\u4f5c\u5931\u8d25')
}
},
async doGrantBatch() {
if (!this.selectionIds.length) return
try {
await grantRoomAccessBatch({ roomId: Number(this.roomId), userIds: this.selectionIds })
this.$message.success('\u5df2\u6388\u4e88\u5f53\u524d\u623f\u95f4\u51c6\u5165')
} catch (e) {
this.$message.error((e && e.msg) || (e && e.message) || '\u64cd\u4f5c\u5931\u8d25')
}
}
}
}
</script>
<style scoped>
.hint {
color: #909399;
font-size: 13px;
}
.desc {
font-size: 13px;
color: #606266;
line-height: 1.5;
margin: 0 0 12px;
}
.pager {
margin-top: 10px;
text-align: right;
}
.room-selector {
margin-top: 6px;
}
.actions {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

22
ruoyi-ui/src/views/selectRoom/index.vue

@ -143,7 +143,7 @@
</template>
<script>
import {addRooms, updateRooms, delRooms, listRooms} from "@/api/system/rooms";
import {addRooms, updateRooms, delRooms, listRooms, checkRoomAccess} from "@/api/system/rooms";
export default {
name: 'RoomSelect',
@ -235,13 +235,21 @@ export default {
selectRoom(room) {
this.selectedRoom = room.id
},
enterRoom() {
if (this.selectedRoom) {
this.$router.push({
path: '/childRoom',
query: { roomId: this.selectedRoom }
});
async enterRoom() {
if (!this.selectedRoom) return;
try {
const chk = await checkRoomAccess(this.selectedRoom);
if (chk.code === 200 && chk.data && chk.data.allowed === false) {
this.$message.warning('暂无进入该房间的权限,请联系管理员或主持人授权');
return;
}
} catch (_) {
/* 校验接口异常时不阻断进入 */
}
this.$router.push({
path: '/childRoom',
query: { roomId: this.selectedRoom }
});
},
showContextMenu(event, room) {
const padding = 12

11
sql/room_platform_icon_add_scale_xy.sql

@ -0,0 +1,11 @@
-- room_platform_icon: ADD icon_scale_x, icon_scale_y (matches Java/MyBatis)
-- Run once. New DBs: include these columns after icon_scale in CREATE TABLE.
ALTER TABLE room_platform_icon
ADD COLUMN icon_scale_x DOUBLE DEFAULT NULL COMMENT 'horizontal scale' AFTER icon_scale,
ADD COLUMN icon_scale_y DOUBLE DEFAULT NULL COMMENT 'vertical scale' AFTER icon_scale_x;
UPDATE room_platform_icon
SET icon_scale_x = COALESCE(icon_scale, 1),
icon_scale_y = COALESCE(icon_scale, 1)
WHERE icon_scale_x IS NULL OR icon_scale_y IS NULL;

9
sql/routes_add_creator_id.sql

@ -0,0 +1,9 @@
-- routes add creator_id
ALTER TABLE routes ADD COLUMN creator_id BIGINT NULL COMMENT 'route creator user id' AFTER attributes;
-- backfill: set creator_id = room owner_id for existing routes
UPDATE routes r
JOIN mission_scenario ms ON r.scenario_id = ms.id
JOIN rooms rm ON ms.room_id = rm.id
SET r.creator_id = rm.owner_id
WHERE r.creator_id IS NULL;
Loading…
Cancel
Save