diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java index 5ce71d7..ff6fbc4 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java @@ -163,6 +163,45 @@ public class RoutesController extends BaseController } /** + * 保存六步法全部数据到 Redis(任务页、理解、后五步、背景、多页等) + */ + @PreAuthorize("@ss.hasPermi('system:routes:edit')") + @PostMapping("/saveSixStepsData") + public AjaxResult saveSixStepsData(@RequestBody java.util.Map params) + { + Object roomId = params.get("roomId"); + Object data = params.get("data"); + if (roomId == null || data == null) { + return AjaxResult.error("参数不完整"); + } + String key = "room:" + String.valueOf(roomId) + ":six_steps"; + fourTRedisTemplate.opsForValue().set(key, data.toString()); + return success(); + } + + /** + * 从 Redis 获取六步法全部数据 + */ + @PreAuthorize("@ss.hasPermi('system:routes:query')") + @GetMapping("/getSixStepsData") + public AjaxResult getSixStepsData(Long roomId) + { + if (roomId == null) { + return AjaxResult.error("房间ID不能为空"); + } + String key = "room:" + String.valueOf(roomId) + ":six_steps"; + String val = fourTRedisTemplate.opsForValue().get(key); + if (val != null && !val.isEmpty()) { + try { + return success(JSON.parseObject(val)); + } catch (Exception e) { + return success(val); + } + } + return success(); + } + + /** * 获取导弹发射参数列表(Redis,房间+航线+平台为 key,值为数组,每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg) */ @PreAuthorize("@ss.hasPermi('system:routes:query')") diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java new file mode 100644 index 0000000..26c4a13 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java @@ -0,0 +1,63 @@ +package com.ruoyi.web.controller; + +import java.util.List; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.websocket.service.WhiteboardRoomService; + +/** + * 白板 Controller:房间维度的白板 CRUD,数据存 Redis + */ +@RestController +@RequestMapping("/room") +public class WhiteboardController extends BaseController { + + @Autowired + private WhiteboardRoomService whiteboardRoomService; + + /** 获取房间下所有白板列表 */ + @GetMapping("/{roomId}/whiteboards") + public AjaxResult list(@PathVariable Long roomId) { + List list = whiteboardRoomService.listWhiteboards(roomId); + return success(list); + } + + /** 获取单个白板详情 */ + @GetMapping("/{roomId}/whiteboard/{whiteboardId}") + public AjaxResult get(@PathVariable Long roomId, @PathVariable String whiteboardId) { + Object wb = whiteboardRoomService.getWhiteboard(roomId, whiteboardId); + if (wb == null) return error("白板不存在"); + return success(wb); + } + + /** 创建白板 */ + @PostMapping("/{roomId}/whiteboard") + public AjaxResult create(@PathVariable Long roomId, @RequestBody Object whiteboard) { + Object created = whiteboardRoomService.createWhiteboard(roomId, whiteboard); + return success(created); + } + + /** 更新白板 */ + @PutMapping("/{roomId}/whiteboard/{whiteboardId}") + public AjaxResult update(@PathVariable Long roomId, @PathVariable String whiteboardId, + @RequestBody Object whiteboard) { + boolean ok = whiteboardRoomService.updateWhiteboard(roomId, whiteboardId, whiteboard); + return ok ? success() : error("更新失败"); + } + + /** 删除白板 */ + @DeleteMapping("/{roomId}/whiteboard/{whiteboardId}") + public AjaxResult delete(@PathVariable Long roomId, @PathVariable String whiteboardId) { + boolean ok = whiteboardRoomService.deleteWhiteboard(roomId, whiteboardId); + return ok ? success() : error("删除失败"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java index f7818c0..4c007a1 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java @@ -1,10 +1,8 @@ package com.ruoyi.websocket.controller; -import java.util.ArrayList; import java.util.HashMap; 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; @@ -21,7 +19,6 @@ import com.ruoyi.system.domain.Rooms; import com.ruoyi.system.service.IRoomsService; import com.ruoyi.websocket.dto.RoomMemberDTO; import com.ruoyi.websocket.service.RoomChatService; -import com.ruoyi.websocket.service.RoomRoomStateService; import com.ruoyi.websocket.service.RoomWebSocketService; /** @@ -44,9 +41,6 @@ public class RoomWebSocketController { @Autowired private IRoomsService roomsService; - @Autowired - private RoomRoomStateService roomRoomStateService; - private static final String TYPE_JOIN = "JOIN"; private static final String TYPE_LEAVE = "LEAVE"; private static final String TYPE_PING = "PING"; @@ -188,13 +182,7 @@ public class RoomWebSocketController { chatHistoryMsg.put("messages", chatHistory); messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg); - Set visibleRouteIds = roomRoomStateService.getVisibleRouteIds(roomId); - if (visibleRouteIds != null && !visibleRouteIds.isEmpty()) { - Map roomStateMsg = new HashMap<>(); - roomStateMsg.put("type", TYPE_ROOM_STATE); - roomStateMsg.put("visibleRouteIds", new ArrayList<>(visibleRouteIds)); - messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", roomStateMsg); - } + // 小房间内每人展示各自内容,新加入用户不同步他人的可见航线 } private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) { @@ -296,28 +284,9 @@ public class RoomWebSocketController { messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp); } - /** 广播航线显隐变更,供其他设备实时同步;并持久化到 Redis 供新加入用户同步 */ + /** 航线显隐变更:小房间内每人展示各自内容,不再广播和持久化 */ private void handleSyncRouteVisibility(Long roomId, Map body, String sessionId) { - if (body == null || !body.containsKey("routeId")) return; - Object routeIdObj = body.get("routeId"); - boolean visible = body.get("visible") != null && Boolean.TRUE.equals(body.get("visible")); - Long routeId = null; - if (routeIdObj instanceof Number) { - routeId = ((Number) routeIdObj).longValue(); - } else if (routeIdObj != null) { - try { - routeId = Long.parseLong(routeIdObj.toString()); - } catch (NumberFormatException ignored) {} - } - if (routeId != null) { - roomRoomStateService.updateRouteVisibility(roomId, routeId, visible); - } - Map msg = new HashMap<>(); - msg.put("type", TYPE_SYNC_ROUTE_VISIBILITY); - msg.put("routeId", body.get("routeId")); - msg.put("visible", visible); - msg.put("senderSessionId", sessionId); - messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); + // 小房间内每人展示各自内容,航线显隐不再同步给他人、不再持久化 } /** 广播航点变更,供其他设备实时同步 */ diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java new file mode 100644 index 0000000..1423da3 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java @@ -0,0 +1,124 @@ +package com.ruoyi.websocket.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import com.alibaba.fastjson2.JSON; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +/** + * 白板房间服务:按房间维度将白板数据存储于 Redis + */ +@Service +public class WhiteboardRoomService { + + private static final String ROOM_WHITEBOARDS_PREFIX = "room:"; + private static final String ROOM_WHITEBOARDS_SUFFIX = ":whiteboards"; + private static final int EXPIRE_HOURS = 24; + + @Autowired + @Qualifier("stringObjectRedisTemplate") + private RedisTemplate redisTemplate; + + private String whiteboardsKey(Long roomId) { + return ROOM_WHITEBOARDS_PREFIX + roomId + ROOM_WHITEBOARDS_SUFFIX; + } + + /** 获取房间下所有白板列表 */ + @SuppressWarnings("unchecked") + public List listWhiteboards(Long roomId) { + if (roomId == null) return new ArrayList<>(); + String key = whiteboardsKey(roomId); + Object raw = redisTemplate.opsForValue().get(key); + if (raw == null) return new ArrayList<>(); + if (raw instanceof List) return (List) raw; + if (raw instanceof String) { + try { + return JSON.parseArray((String) raw); + } catch (Exception e) { + return new ArrayList<>(); + } + } + return new ArrayList<>(); + } + + /** 获取单个白板详情 */ + public Object getWhiteboard(Long roomId, String whiteboardId) { + List list = listWhiteboards(roomId); + for (Object item : list) { + if (item instanceof java.util.Map) { + Object id = ((java.util.Map) item).get("id"); + if (whiteboardId.equals(String.valueOf(id))) return item; + } + } + return null; + } + + /** 创建白板 */ + public Object createWhiteboard(Long roomId, Object whiteboard) { + if (roomId == null || whiteboard == null) return null; + List list = listWhiteboards(roomId); + String id = UUID.randomUUID().toString(); + Object wb = whiteboard; + if (wb instanceof java.util.Map) { + ((java.util.Map) wb).put("id", id); + if (!((java.util.Map) wb).containsKey("name")) { + ((java.util.Map) wb).put("name", "草稿"); + } + if (!((java.util.Map) wb).containsKey("timeBlocks")) { + ((java.util.Map) wb).put("timeBlocks", new ArrayList()); + } + if (!((java.util.Map) wb).containsKey("contentByTime")) { + ((java.util.Map) wb).put("contentByTime", new java.util.HashMap()); + } + } + list.add(wb); + saveWhiteboards(roomId, list); + return wb; + } + + /** 更新白板 */ + public boolean updateWhiteboard(Long roomId, String whiteboardId, Object whiteboard) { + if (roomId == null || whiteboardId == null || whiteboard == null) return false; + List list = listWhiteboards(roomId); + for (int i = 0; i < list.size(); i++) { + Object item = list.get(i); + if (item instanceof java.util.Map) { + Object id = ((java.util.Map) item).get("id"); + if (whiteboardId.equals(String.valueOf(id))) { + if (whiteboard instanceof java.util.Map) { + ((java.util.Map) whiteboard).put("id", whiteboardId); + } + list.set(i, whiteboard); + saveWhiteboards(roomId, list); + return true; + } + } + } + return false; + } + + /** 删除白板 */ + public boolean deleteWhiteboard(Long roomId, String whiteboardId) { + if (roomId == null || whiteboardId == null) return false; + List list = listWhiteboards(roomId); + boolean removed = list.removeIf(item -> { + if (item instanceof java.util.Map) { + Object id = ((java.util.Map) item).get("id"); + return whiteboardId.equals(String.valueOf(id)); + } + return false; + }); + if (removed) saveWhiteboards(roomId, list); + return removed; + } + + private void saveWhiteboards(Long roomId, List list) { + String key = whiteboardsKey(roomId); + redisTemplate.opsForValue().set(key, list, EXPIRE_HOURS, TimeUnit.HOURS); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java index 605eb17..f8b9214 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java @@ -38,6 +38,9 @@ public class MissionScenario extends BaseEntity @Excel(name = "是否为当前默认展示方案") private Integer isActive; + /** 大房间ID(查询用,非持久化):传入时查询 room_id IN (该大房间下所有子房间ID) */ + private Long parentRoomId; + public void setId(Long id) { this.id = id; @@ -98,6 +101,16 @@ public class MissionScenario extends BaseEntity return isActive; } + public Long getParentRoomId() + { + return parentRoomId; + } + + public void setParentRoomId(Long parentRoomId) + { + this.parentRoomId = parentRoomId; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java index b12ba07..e8188ef 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java @@ -47,6 +47,12 @@ public class Routes extends BaseEntity { private List waypoints; + /** 方案ID列表(查询用,非持久化):传入时查询 scenario_id IN (...) */ + private java.util.List scenarioIds; + + /** 方案ID逗号分隔(查询用,便于前端传参):如 "1,2,3" */ + private String scenarioIdsStr; + /** 关联的平台信息(仅用于 API 返回,非数据库字段) */ private java.util.Map platform; @@ -98,6 +104,34 @@ public class Routes extends BaseEntity { this.waypoints = waypoints; } + public java.util.List getScenarioIds() { + return scenarioIds; + } + + public void setScenarioIds(java.util.List scenarioIds) { + this.scenarioIds = scenarioIds; + } + + public String getScenarioIdsStr() { + return scenarioIdsStr; + } + + public void setScenarioIdsStr(String scenarioIdsStr) { + this.scenarioIdsStr = scenarioIdsStr; + if (scenarioIdsStr != null && !scenarioIdsStr.trim().isEmpty()) { + java.util.List list = new java.util.ArrayList<>(); + for (String s : scenarioIdsStr.split(",")) { + s = s.trim(); + if (!s.isEmpty()) { + try { + list.add(Long.parseLong(s)); + } catch (NumberFormatException ignored) {} + } + } + this.scenarioIds = list.isEmpty() ? null : list; + } + } + public java.util.Map getPlatform() { return platform; } diff --git a/ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml b/ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml index 152b447..d1eccc6 100644 --- a/ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml @@ -20,7 +20,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - and scenario_id = #{scenarioId} + and scenario_id in + #{sid} + + and scenario_id = #{scenarioId} and platform_id = #{platformId} and call_sign = #{callSign} and attributes = #{attributes} diff --git a/ruoyi-ui/src/api/system/routes.js b/ruoyi-ui/src/api/system/routes.js index 4a1853b..afcb27f 100644 --- a/ruoyi-ui/src/api/system/routes.js +++ b/ruoyi-ui/src/api/system/routes.js @@ -100,6 +100,25 @@ export function getTaskPageData(params) { }) } +// 保存六步法全部数据到 Redis(任务页、理解、后五步、背景、多页等) +export function saveSixStepsData(data) { + return request({ + url: '/system/routes/saveSixStepsData', + method: 'post', + data, + headers: { repeatSubmit: false } + }) +} + +// 从 Redis 获取六步法全部数据 +export function getSixStepsData(params) { + return request({ + url: '/system/routes/getSixStepsData', + method: 'get', + params + }) +} + // 获取导弹发射参数(Redis:房间+航线+平台为 key) export function getMissileParams(params) { return request({ diff --git a/ruoyi-ui/src/api/system/whiteboard.js b/ruoyi-ui/src/api/system/whiteboard.js new file mode 100644 index 0000000..73d9f43 --- /dev/null +++ b/ruoyi-ui/src/api/system/whiteboard.js @@ -0,0 +1,43 @@ +import request from '@/utils/request' + +/** 获取房间下所有白板列表 */ +export function listWhiteboards(roomId) { + return request({ + url: `/room/${roomId}/whiteboards`, + method: 'get' + }) +} + +/** 获取单个白板详情 */ +export function getWhiteboard(roomId, whiteboardId) { + return request({ + url: `/room/${roomId}/whiteboard/${whiteboardId}`, + method: 'get' + }) +} + +/** 创建白板 */ +export function createWhiteboard(roomId, data) { + return request({ + url: `/room/${roomId}/whiteboard`, + method: 'post', + data: data || {} + }) +} + +/** 更新白板 */ +export function updateWhiteboard(roomId, whiteboardId, data) { + return request({ + url: `/room/${roomId}/whiteboard/${whiteboardId}`, + method: 'put', + data: data || {} + }) +} + +/** 删除白板 */ +export function deleteWhiteboard(roomId, whiteboardId) { + return request({ + url: `/room/${roomId}/whiteboard/${whiteboardId}`, + method: 'delete' + }) +} diff --git a/ruoyi-ui/src/lang/en.js b/ruoyi-ui/src/lang/en.js index 4ad098c..b7166b4 100644 --- a/ruoyi-ui/src/lang/en.js +++ b/ruoyi-ui/src/lang/en.js @@ -21,7 +21,9 @@ export default { importATO: 'Import ATO', importLayer: 'Import Layer', importRoute: 'Import Route', - export: 'Export' + export: 'Export', + exportRoute: 'Export Routes', + exportPlan: 'Export Plan' }, edit: { routeEdit: 'Route Edit', diff --git a/ruoyi-ui/src/lang/zh.js b/ruoyi-ui/src/lang/zh.js index 0d91067..481dcac 100644 --- a/ruoyi-ui/src/lang/zh.js +++ b/ruoyi-ui/src/lang/zh.js @@ -21,7 +21,9 @@ export default { importATO: '导入ATO', importLayer: '导入图层', importRoute: '导入航线', - export: '导出' + export: '导出', + exportRoute: '导出航线', + exportPlan: '导出计划' }, edit: { routeEdit: '航线编辑', diff --git a/ruoyi-ui/src/utils/imageUrl.js b/ruoyi-ui/src/utils/imageUrl.js new file mode 100644 index 0000000..dbd9402 --- /dev/null +++ b/ruoyi-ui/src/utils/imageUrl.js @@ -0,0 +1,14 @@ +/** + * 解析背景图/图片 URL + * - data:image/... (base64) 或 http(s):// 开头:原样返回 + * - 否则视为若依 profile 路径,拼接 base API + */ +export function resolveImageUrl(img) { + if (!img) return '' + if (img.startsWith('data:') || img.startsWith('http://') || img.startsWith('https://')) { + return img + } + const base = process.env.VUE_APP_BASE_API || '' + const path = img.startsWith('/') ? img : '/' + img + return base + path +} diff --git a/ruoyi-ui/src/utils/request.js b/ruoyi-ui/src/utils/request.js index 892176c..bd92ba7 100644 --- a/ruoyi-ui/src/utils/request.js +++ b/ruoyi-ui/src/utils/request.js @@ -66,6 +66,10 @@ service.interceptors.request.use(config => { } } } + // FormData 上传时移除 Content-Type,让浏览器自动设置 multipart/form-data; boundary=... + if (config.data instanceof FormData) { + delete config.headers['Content-Type'] + } return config }, error => { console.log(error) diff --git a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue index 77a7f8b..dac82db 100644 --- a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue +++ b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue @@ -173,7 +173,7 @@ - - - @@ -511,6 +564,10 @@ export default { this.$emit('start-route-after-platform', this.entityData) }, + handleShowTransformBox() { + this.$emit('show-transform-box') + }, + handleToggleRouteLabel() { this.$emit('toggle-route-label') }, diff --git a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue index b0e2ec6..4a496f4 100644 --- a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue +++ b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue @@ -13,6 +13,19 @@ + +
+
+ {{ opt.label }} +
+
@@ -35,10 +48,19 @@ export default { toolMode: { type: String, default: 'airspace' // 'airspace' or 'ranging' + }, + auxiliaryLineConstraint: { + type: String, + default: 'none' // 'none' | 'horizontal' | 'vertical' } }, data() { return { + auxiliaryConstraintOptions: [ + { value: 'none', label: '自由' }, + { value: 'horizontal', label: '水平' }, + { value: 'vertical', label: '竖直' } + ], // 完整工具列表(空域模式,移除点和线) allToolbarItems: [ { id: 'mouse', name: '鼠标', icon: 'cursor' }, @@ -46,6 +68,7 @@ export default { { id: 'rectangle', name: '矩形空域', icon: 'jx' }, { id: 'circle', name: '圆形空域', icon: 'circle' }, { id: 'sector', name: '扇形空域', icon: 'sx' }, + { id: 'auxiliaryLine', name: '辅助线', icon: 'el-icon-minus' }, { id: 'arrow', name: '箭头', icon: 'el-icon-right' }, { id: 'text', name: '文本', icon: 'el-icon-document' }, { id: 'image', name: '图片', icon: 'el-icon-picture-outline' }, @@ -160,6 +183,36 @@ export default { font-size: 16px; } +.auxiliary-constraint-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(0, 138, 255, 0.2); +} + +.constraint-option { + font-size: 12px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + text-align: center; + color: #666; + background: rgba(255, 255, 255, 0.6); + transition: all 0.2s; +} + +.constraint-option:hover { + background: rgba(0, 138, 255, 0.1); + color: #008aff; +} + +.constraint-option.active { + background: rgba(0, 138, 255, 0.2); + color: #008aff; +} + .toolbar-item:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index be4f0ad..e19d3ba 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -7,11 +7,13 @@ :drawing-mode="drawingMode" :has-entities="allEntities.length > 0" :tool-mode="toolMode" + :auxiliary-line-constraint="auxiliaryLineConstraint" @toggle-drawing="toggleDrawing" @clear-all="clearAll" @export-data="exportData" @import-data="importData" @locate="handleLocate" + @auxiliary-line-constraint="auxiliaryLineConstraint = $event" /> [] } }, @@ -476,6 +488,10 @@ export default { opacity: 0.3, visible: true }, + /** 白板模式:进入时保存的主地图实体原始 show 状态,退出时还原 */ + whiteboardHiddenEntityShows: {}, + /** 白板平台实体 id -> entityData,用于拖拽/旋转时更新并回传 */ + whiteboardEntityDataMap: {}, /** 独立平台图标(非航线)的探测区/威力区样式缓存:platformIconId -> { ... } */ platformIconZoneStyles: {}, /** 探测区/威力区弹窗预选颜色(可与透明度组合) */ @@ -521,9 +537,12 @@ export default { circle: { color: '#800080', opacity: 0, width: 2 }, sector: { color: '#FF6347', opacity: 0, width: 2 }, arrow: { color: '#FF0000', width: 6 }, + auxiliaryLine: { color: '#000000', width: 2 }, text: { color: '#000000', font: '14px PingFang SC, Microsoft YaHei, Helvetica Neue, sans-serif', backgroundColor: 'rgba(255, 255, 255, 0.92)' }, image: { width: 150, height: 150 } }, + /** 辅助线:水平/竖直约束,'none' | 'horizontal' | 'vertical' */ + auxiliaryLineConstraint: 'none', // 鼠标经纬度 coordinatesText: '经度: --, 纬度: --', currentCoordinates: null, @@ -654,6 +673,29 @@ export default { if (!val) { this.clearMissilePreview() } + }, + whiteboardMode: { + handler(active) { + this.applyWhiteboardMode(active) + if (active) { + // 双 nextTick 确保父组件已更新 whiteboardEntities(如从主地图切回时 loadWhiteboards 完成) + this.$nextTick(() => { + this.$nextTick(() => { + const ents = this.whiteboardEntities || [] + if (ents.length > 0) this.renderWhiteboardEntities(ents) + }) + }) + } + } + }, + whiteboardEntities: { + deep: true, + immediate: true, + handler(entities) { + if (this.whiteboardMode && entities && entities.length > 0) { + this.renderWhiteboardEntities(entities) + } + } } }, computed: { @@ -1662,9 +1704,15 @@ export default { /** 从手柄实体 id 解析出平台图标 entityData 与类型(rotate / scale-0~3) */ getPlatformIconDataFromHandleId(handleEntityId) { if (!handleEntityId || typeof handleEntityId !== 'string') return null; + const findEd = (baseId) => { + const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === baseId); + if (ed) return ed; + if (baseId.startsWith('wb_') && this.whiteboardEntityDataMap) return this.whiteboardEntityDataMap[baseId]; + return null; + }; if (handleEntityId.endsWith('-rotate-handle')) { const baseId = handleEntityId.replace(/-rotate-handle$/, ''); - const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === baseId); + const ed = findEd(baseId); return ed ? { entityData: ed, type: 'rotate' } : null; } const scaleIdx = handleEntityId.lastIndexOf('-scale-'); @@ -1672,7 +1720,7 @@ export default { const baseId = handleEntityId.substring(0, scaleIdx); const cornerIndex = parseInt(handleEntityId.substring(scaleIdx + 7), 10); if (!isNaN(cornerIndex) && cornerIndex >= 0 && cornerIndex <= 3) { - const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === baseId); + const ed = findEd(baseId); return ed ? { entityData: ed, type: 'scale', cornerIndex } : null; } } @@ -4176,10 +4224,10 @@ export default { /** * 从 Redis 加载指定房间+航线列表的导弹数据并创建实体(会先清空当前导弹实体) - * routePlatforms: [{ routeId, platformId }] + * routePlatforms: [{ routeId, platformId, roomId? }],roomId 可选,大房间时按航线所属方案用 plan.roomId */ async loadMissilesFromRedis(roomId, routePlatforms) { - if (!this.viewer || !this.viewer.entities || !roomId || !Array.isArray(routePlatforms)) return + if (!this.viewer || !this.viewer.entities || !Array.isArray(routePlatforms)) return // 先清空当前所有导弹实体(包括 routePlatforms 为空时,如删除航线后需清除地图上的导弹) while (this.missileEntities.length) { const { entity } = this.missileEntities.pop() @@ -4189,9 +4237,12 @@ export default { if (this.viewer.scene) this.viewer.scene.requestRender() return } - for (const { routeId, platformId } of routePlatforms) { + for (const item of routePlatforms) { + const { routeId, platformId } = item + const rId = item.roomId != null ? item.roomId : roomId + if (!rId) continue try { - const res = await getMissileParams({ roomId, routeId, platformId: platformId != null ? platformId : 0 }) + const res = await getMissileParams({ roomId: rId, routeId, platformId: platformId != null ? platformId : 0 }) let data = res.data if (!data) continue const arr = Array.isArray(data) ? data : [data] @@ -4299,6 +4350,8 @@ export default { } this.loadOfflineMap() this.setup2DConstraints() + // 禁用右键拖拽缩放,仅保留滚轮缩放 + this.viewer.scene.screenSpaceCameraController.zoomEventTypes = Cesium.CameraEventType.WHEEL // 移除 Cesium 自带的双击缩放,使双击实体/空白均无任何效果 try { const handler = this.viewer.screenSpaceEventHandler || (this.viewer.cesiumWidget && this.viewer.cesiumWidget.screenSpaceEventHandler) @@ -4728,6 +4781,9 @@ export default { } } } + if (!entityData && idStr.startsWith('wb_')) { + entityData = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[idStr] + } if (!entityData) { // 查找对应的实体数据 entityData = this.allEntities.find(e => e.entity === pickedEntity || e === pickedEntity) @@ -4940,7 +4996,10 @@ export default { return; } } - const entityData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === picked.id); + let entityData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === picked.id); + if (!entityData && idStr.startsWith('wb_')) { + entityData = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[idStr]; + } if (entityData) { this.pendingDragIcon = entityData; this.dragStartScreenPos = { x: click.position.x, y: click.position.y }; @@ -5056,22 +5115,39 @@ export default { } this.clickedOnEmpty = false; if (this.draggingRotateHandle) { - this.$emit('platform-icon-updated', this.draggingRotateHandle); + if (this.draggingRotateHandle.isWhiteboard) { + this.$emit('whiteboard-platform-updated', this.draggingRotateHandle); + } else { + this.$emit('platform-icon-updated', this.draggingRotateHandle); + } this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; this.draggingRotateHandle = null; } if (this.draggingScaleHandle) { - this.$emit('platform-icon-updated', this.draggingScaleHandle.entityData); + const ed = this.draggingScaleHandle.entityData; + if (ed && ed.isWhiteboard) { + this.$emit('whiteboard-platform-updated', ed); + } else { + this.$emit('platform-icon-updated', ed); + } this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; this.draggingScaleHandle = null; } if (this.draggingPlatformIcon) { - this.$emit('platform-icon-updated', this.draggingPlatformIcon); + if (this.draggingPlatformIcon.isWhiteboard) { + this.$emit('whiteboard-platform-updated', this.draggingPlatformIcon); + } else { + this.$emit('platform-icon-updated', this.draggingPlatformIcon); + } this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; this.draggingPlatformIcon = null; } if (this.rotatingPlatformIcon) { - this.$emit('platform-icon-updated', this.rotatingPlatformIcon); + if (this.rotatingPlatformIcon.isWhiteboard) { + this.$emit('whiteboard-platform-updated', this.rotatingPlatformIcon); + } else { + this.$emit('platform-icon-updated', this.rotatingPlatformIcon); + } this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; this.rotatingPlatformIcon = null; this.platformIconRotateTip = ''; @@ -5314,6 +5390,9 @@ export default { case 'sector': this.startSectorDrawing() break + case 'auxiliaryLine': + this.startAuxiliaryLineDrawing() + break case 'arrow': this.startArrowDrawing() break @@ -5571,14 +5650,26 @@ export default { }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); }, finishLineDrawing() { - // 将预览线段转换为最终线段 if (this.drawingPoints.length > 1) { - // 移除预览线段 if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } - // 创建最终的实线实体(updateLineSegmentLabels 会在 addLineEntity 中设置各段终点标签,含累计长度和角度) + if (this.whiteboardMode) { + const points = this.drawingPoints.map(p => this.cartesianToLatLng(p)) + const entityData = { + id: 'wb_line_' + Date.now(), + type: 'line', + label: '测距', + color: this.defaultStyles.line ? this.defaultStyles.line.color : '#008aff', + data: { points, width: (this.defaultStyles.line && this.defaultStyles.line.width) || 2 } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + this.drawingPointEntities = [] + this.tempEntity = null + return null + } const entity = this.addLineEntity([...this.drawingPoints], [...this.drawingPointEntities]); // 计算长度 const length = this.calculateLineLength([...this.drawingPoints]); @@ -5674,6 +5765,20 @@ export default { }, finishPolygonDrawing() { const positions = [...this.drawingPoints] + if (this.whiteboardMode && positions.length >= 3) { + const points = positions.map(p => this.cartesianToLatLng(p)) + const entityData = { + id: 'wb_polygon_' + Date.now(), + type: 'polygon', + label: '面', + color: this.defaultStyles.polygon.color, + data: { points, opacity: 0, width: this.defaultStyles.polygon.width } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null } + return + } const entity = this.addPolygonEntity(positions) // 计算面积 const area = this.calculatePolygonArea(positions) @@ -5771,6 +5876,29 @@ export default { }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); }, finishRectangleDrawing() { + if (this.whiteboardMode && this.drawingPoints.length >= 2) { + const rect = Cesium.Rectangle.fromCartesianArray(this.drawingPoints) + const west = Cesium.Math.toDegrees(rect.west) + const south = Cesium.Math.toDegrees(rect.south) + const east = Cesium.Math.toDegrees(rect.east) + const north = Cesium.Math.toDegrees(rect.north) + const entityData = { + id: 'wb_rectangle_' + Date.now(), + type: 'rectangle', + label: '矩形', + color: this.defaultStyles.rectangle.color, + data: { + coordinates: { west, south, east, north }, + points: this.drawingPoints.map(p => this.cartesianToLatLng(p)), + opacity: this.defaultStyles.rectangle.opacity, + width: this.defaultStyles.rectangle.width + } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null } + return + } // 1. 获取最终的矩形范围对象 const rect = Cesium.Rectangle.fromCartesianArray(this.drawingPoints); // 2. 移除动态预览的临时实体 @@ -6010,13 +6138,25 @@ export default { }, finishCircleDrawing(edgePosition) { const centerPoint = this.drawingPoints[0]; - // 1. 计算最终半径 const radius = Cesium.Cartesian3.distance(centerPoint, edgePosition); - // 2. 移除动态预览实体 if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + if (this.whiteboardMode) { + const center = this.cartesianToLatLng(centerPoint) + const entityData = { + id: 'wb_circle_' + Date.now(), + type: 'circle', + label: '圆形', + color: this.defaultStyles.circle.color, + data: { center: { lat: center.lat, lng: center.lng }, radius, opacity: this.defaultStyles.circle.opacity, width: this.defaultStyles.circle.width } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + this.activeCursorPosition = null + return + } // 3. 创建最终显示的静态实体 this.entityCounter++; const id = `circle_${this.entityCounter}`; @@ -6205,11 +6345,23 @@ export default { const radius = Cesium.Cartesian3.distance(centerPoint, radiusPoint); const startAngle = this.calculatePointAngle(centerPoint, radiusPoint); const endAngle = this.calculatePointAngle(centerPoint, anglePoint); - // 1. 移除动态预览实体 if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + if (this.whiteboardMode) { + const center = this.cartesianToLatLng(centerPoint) + const entityData = { + id: 'wb_sector_' + Date.now(), + type: 'sector', + label: '扇形', + color: this.defaultStyles.sector.color, + data: { center: { lat: center.lat, lng: center.lng }, radius, startAngle, endAngle, opacity: this.defaultStyles.sector.opacity, width: this.defaultStyles.sector.width } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + return + } // 2. 生成扇形顶点 const positions = this.generateSectorPositions(centerPoint, radius, startAngle, endAngle); // 3. 创建最终显示的静态实体 @@ -6357,6 +6509,131 @@ export default { calculateDistance(point1, point2) { return Cesium.Cartesian3.distance(point1, point2); }, + /** 根据辅助线约束修正第二点坐标(水平:同纬度;竖直:同经度) */ + applyAuxiliaryLineConstraint(firstPos, secondPos) { + if (!firstPos || !secondPos || this.auxiliaryLineConstraint === 'none') return secondPos + const firstLL = this.cartesianToLatLng(firstPos) + const secondLL = this.cartesianToLatLng(secondPos) + if (!firstLL || !secondLL) return secondPos + if (this.auxiliaryLineConstraint === 'horizontal') { + return Cesium.Cartesian3.fromDegrees(secondLL.lng, firstLL.lat) + } + if (this.auxiliaryLineConstraint === 'vertical') { + return Cesium.Cartesian3.fromDegrees(firstLL.lng, secondLL.lat) + } + return secondPos + }, + // 绘制辅助线(两点定线段,默认黑色 1px) + startAuxiliaryLineDrawing() { + this.drawingPoints = [] + if (this.tempEntity) this.viewer.entities.remove(this.tempEntity) + if (this.tempPreviewEntity) this.viewer.entities.remove(this.tempPreviewEntity) + this.tempEntity = null + this.tempPreviewEntity = null + const that = this + this.drawingHandler.setInputAction((movement) => { + let newPosition = that.getClickPosition(movement.endPosition) + if (newPosition && that.drawingPoints.length === 1) { + newPosition = that.applyAuxiliaryLineConstraint(that.drawingPoints[0], newPosition) + } + if (newPosition) { + that.activeCursorPosition = newPosition + if (that.viewer.scene.requestRenderMode) that.viewer.scene.requestRender() + } + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE) + this.drawingHandler.setInputAction((click) => { + let position = this.getClickPosition(click.position) + if (position && this.drawingPoints.length === 1) { + position = this.applyAuxiliaryLineConstraint(this.drawingPoints[0], position) + } + if (position) { + this.drawingPoints.push(position) + if (this.drawingPoints.length === 1) { + this.activeCursorPosition = position + this.tempPreviewEntity = this.viewer.entities.add({ + polyline: { + positions: new Cesium.CallbackProperty(() => { + if (this.drawingPoints.length > 0 && this.activeCursorPosition) { + return [this.drawingPoints[this.drawingPoints.length - 1], this.activeCursorPosition] + } + return [] + }, false), + width: this.defaultStyles.auxiliaryLine.width, + material: Cesium.Color.fromCssColorString(this.defaultStyles.auxiliaryLine.color), + arcType: Cesium.ArcType.NONE + } + }) + } else { + this.activeCursorPosition = null + this.finishAuxiliaryLineDrawing() + } + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK) + this.drawingHandler.setInputAction(() => { + this.cancelDrawing() + }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) + }, + finishAuxiliaryLineDrawing() { + if (this.drawingPoints.length >= 2) { + if (this.tempPreviewEntity) { + this.viewer.entities.remove(this.tempPreviewEntity) + this.tempPreviewEntity = null + } + if (this.whiteboardMode) { + const points = this.drawingPoints.map(p => this.cartesianToLatLng(p)) + const entityData = { + id: 'wb_auxiliaryLine_' + Date.now(), + type: 'auxiliaryLine', + label: '辅助线', + color: this.defaultStyles.auxiliaryLine.color, + data: { points, width: this.defaultStyles.auxiliaryLine.width } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + return null + } + const entity = this.addAuxiliaryLineEntity([...this.drawingPoints]) + this.drawingPoints = [] + this.notifyDrawingEntitiesChanged() + return entity + } else { + this.cancelDrawing() + return null + } + }, + addAuxiliaryLineEntity(positions) { + if (!positions || positions.length < 2) return null + const linePositions = positions.length === 2 ? positions : [positions[0], positions[positions.length - 1]] + this.entityCounter++ + const id = `auxiliaryLine_${this.entityCounter}` + const entity = this.viewer.entities.add({ + id, + name: `辅助线 ${this.entityCounter}`, + polyline: { + positions: linePositions, + width: this.defaultStyles.auxiliaryLine.width, + material: Cesium.Color.fromCssColorString(this.defaultStyles.auxiliaryLine.color), + arcType: Cesium.ArcType.NONE + } + }) + const entityData = { + id, + type: 'auxiliaryLine', + points: linePositions.map(p => this.cartesianToLatLng(p)), + positions: linePositions, + entity, + color: this.defaultStyles.auxiliaryLine.color, + width: this.defaultStyles.auxiliaryLine.width, + label: `辅助线 ${this.entityCounter}` + } + this.allEntities.push(entityData) + entity.clickHandler = (e) => { + this.selectEntity(entityData) + e.stopPropagation() + } + this.notifyDrawingEntitiesChanged() + return entityData + }, // 绘制箭头 startArrowDrawing() { this.drawingPoints = []; // 存储起点和终点 @@ -6421,14 +6698,24 @@ export default { }, // 完成箭头绘制 finishArrowDrawing() { - // 将预览箭头转换为最终箭头 if (this.drawingPoints.length > 1) { - // 移除预览箭头 if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } - // 创建最终的箭头实体 + if (this.whiteboardMode) { + const points = this.drawingPoints.map(p => this.cartesianToLatLng(p)) + const entityData = { + id: 'wb_arrow_' + Date.now(), + type: 'arrow', + label: '箭头', + color: this.defaultStyles.arrow.color, + data: { points, width: this.defaultStyles.arrow.width } + } + this.$emit('whiteboard-draw-complete', entityData) + this.drawingPoints = [] + return null + } const entity = this.addArrowEntity([...this.drawingPoints]); // 重置绘制点数组,保持绘制状态以继续绘制 this.drawingPoints = []; @@ -6501,10 +6788,20 @@ export default { }, // 添加文本实体 addTextEntity(position, text) { + const { lat, lng } = this.cartesianToLatLng(position) + if (this.whiteboardMode) { + const entityData = { + id: 'wb_text_' + Date.now(), + type: 'text', + label: text, + color: this.defaultStyles.text.color, + data: { position: { lat, lng }, text, lat, lng, fontSize: 14, color: this.defaultStyles.text.color } + } + this.$emit('whiteboard-draw-complete', entityData) + return + } this.entityCounter++ const id = `text_${this.entityCounter}` - // 获取经纬度坐标 - const {lat, lng} = this.cartesianToLatLng(position) const entity = this.viewer.entities.add({ id: id, name: `文本 ${this.entityCounter}`, @@ -6628,10 +6925,19 @@ export default { }, // 添加图片实体 addImageEntity(position, imageUrl) { + const { lat, lng } = this.cartesianToLatLng(position) + if (this.whiteboardMode) { + const entityData = { + id: 'wb_image_' + Date.now(), + type: 'image', + label: '图片', + data: { lat, lng, imageUrl, width: this.defaultStyles.image.width, height: this.defaultStyles.image.height } + } + this.$emit('whiteboard-draw-complete', entityData) + return + } this.entityCounter++ const id = `image_${this.entityCounter}` - // 获取经纬度坐标 - const {lat, lng} = this.cartesianToLatLng(position) const entity = this.viewer.entities.add({ id: id, name: `图片 ${this.entityCounter}`, @@ -7200,6 +7506,12 @@ export default { entity.polyline.width = data.width } break + case 'auxiliaryLine': + if (entity.polyline) { + entity.polyline.material = Cesium.Color.fromCssColorString(data.color) + entity.polyline.width = data.width + } + break case 'text': if (entity.label) { entity.label.fillColor = Cesium.Color.fromCssColorString(data.color) @@ -7403,7 +7715,7 @@ export default { /** 开始空域位置调整模式:右键菜单点击「调整位置」后进入,移动鼠标预览,左键确认、右键取消 */ startAirspacePositionEdit() { const ed = this.contextMenu.entityData; - if (!ed || !['polygon', 'rectangle', 'circle', 'sector', 'arrow'].includes(ed.type)) { + if (!ed || !['polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow'].includes(ed.type)) { this.contextMenu.visible = false; return; } @@ -7599,6 +7911,30 @@ export default { }); break; } + case 'auxiliaryLine': { + const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color; + const auxWidth = entityData.width != null ? entityData.width : this.defaultStyles.auxiliaryLine.width; + this.airspacePositionEditPreviewEntity = this.viewer.entities.add({ + polyline: { + positions: new Cesium.CallbackProperty(() => { + const mc = that.airspacePositionEditContext?.mouseCartesian; + const oldCenter = that.getAirspaceCenter(entityData); + if (!mc || !oldCenter || !entityData.positions || entityData.positions.length < 2) return entityData.positions || []; + const delta = Cesium.Cartesian3.subtract(mc, oldCenter, new Cesium.Cartesian3()); + const first = entityData.positions[0]; + const last = entityData.positions[entityData.positions.length - 1]; + return [ + Cesium.Cartesian3.add(first, delta, new Cesium.Cartesian3()), + Cesium.Cartesian3.add(last, delta, new Cesium.Cartesian3()) + ]; + }, false), + width: auxWidth, + material: Cesium.Color.fromCssColorString(auxColor), + arcType: Cesium.ArcType.NONE + } + }); + break; + } } }, @@ -7643,6 +7979,7 @@ export default { } break; case 'arrow': + case 'auxiliaryLine': if (entityData.positions && entityData.positions.length >= 2) { const sum = entityData.positions.reduce((acc, p) => Cesium.Cartesian3.add(acc, p, new Cesium.Cartesian3()), new Cesium.Cartesian3()); return Cesium.Cartesian3.divideByScalar(sum, entityData.positions.length, new Cesium.Cartesian3()); @@ -7698,12 +8035,15 @@ export default { } break; } - case 'circle': + case 'circle': { entityData.entity.position = newCenter; const radius = entityData.radius || 1000; entityData.entity.polyline.positions = this.generateCirclePositions(newCenter, radius); - entityData.points = [this.cartesianToLatLng(newCenter), entityData.points && entityData.points[1] ? entityData.points[1] : this.cartesianToLatLng(newCenter)]; + const circleCenterLL = this.cartesianToLatLng(newCenter); + entityData.center = circleCenterLL; + entityData.points = [circleCenterLL, entityData.points && entityData.points[1] ? entityData.points[1] : circleCenterLL]; break; + } case 'sector': { const newCenterLL = this.cartesianToLatLng(newCenter); entityData.center = newCenterLL; @@ -7746,9 +8086,43 @@ export default { }; } break; + case 'auxiliaryLine': + if (entityData.positions && entityData.positions.length >= 2) { + const first = entityData.positions[0]; + const last = entityData.positions[entityData.positions.length - 1]; + const newFirst = Cesium.Cartesian3.add(first, delta, new Cesium.Cartesian3()); + const newLast = Cesium.Cartesian3.add(last, delta, new Cesium.Cartesian3()); + const linePositions = [newFirst, newLast]; + const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color; + const auxWidth = entityData.width != null ? entityData.width : this.defaultStyles.auxiliaryLine.width; + const oldEntity = entityData.entity; + this.viewer.entities.remove(oldEntity); + const newEntity = this.viewer.entities.add({ + id: oldEntity.id, + name: oldEntity.name, + polyline: { + positions: linePositions, + width: auxWidth, + material: Cesium.Color.fromCssColorString(auxColor), + arcType: Cesium.ArcType.NONE + } + }); + entityData.entity = newEntity; + entityData.positions = linePositions; + entityData.points = [this.cartesianToLatLng(newFirst), this.cartesianToLatLng(newLast)]; + newEntity.clickHandler = (e) => { + this.selectEntity(entityData); + e.stopPropagation(); + }; + } + break; } entityData.entity.show = true; - this.notifyDrawingEntitiesChanged(); + if (entityData.isWhiteboard) { + this.$emit('whiteboard-drawing-updated', this.serializeWhiteboardDrawingEntity(entityData)); + } else { + this.notifyDrawingEntitiesChanged(); + } }, /** 清除空域位置调整模式 */ @@ -8632,12 +9006,15 @@ export default { deleteEntityFromContextMenu() { if (this.contextMenu.entityData) { const entityData = this.contextMenu.entityData - if (entityData.id) { + if (entityData.isWhiteboard) { + this.$emit('whiteboard-entity-deleted', entityData) + if (entityData.entity) this.viewer.entities.remove(entityData.entity) + if (this.whiteboardEntityDataMap && entityData.id) delete this.whiteboardEntityDataMap[entityData.id] + } else if (entityData.id) { this.removeEntity(entityData.id) } else if (entityData.entity && entityData.entity.id) { this.removeEntity(entityData.entity.id) } - // 隐藏右键菜单 this.contextMenu.visible = false } }, @@ -8777,11 +9154,12 @@ export default { } // 更新实体样式 this.updateEntityStyle(entityData) - // 若修改的是空域/威力区图形的样式,触发自动保存到房间,避免刷新后样式丢失 - if (this.getDrawingEntityTypes().includes(entityData.type)) { + // 白板实体:通知父组件更新 contentByTime + if (entityData.isWhiteboard && this.getDrawingEntityTypes().includes(entityData.type)) { + this.$emit('whiteboard-drawing-updated', this.serializeWhiteboardDrawingEntity(entityData)) + } else if (this.getDrawingEntityTypes().includes(entityData.type)) { this.notifyDrawingEntitiesChanged() } - // 隐藏右键菜单 this.contextMenu.visible = false } }, @@ -8793,7 +9171,9 @@ export default { this.contextMenu.visible = false return } - const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === fromMenu.id) || fromMenu + let ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === fromMenu.id) + if (!ed && fromMenu.id && this.whiteboardEntityDataMap) ed = this.whiteboardEntityDataMap[fromMenu.id] + if (!ed) ed = fromMenu if (!ed.entity) { this.contextMenu.visible = false return @@ -9050,13 +9430,74 @@ export default { // ================== 空域/威力区图形持久化 ================== /** 需要持久化到方案的空域图形类型(含测距;不含平台图标、航线) */ getDrawingEntityTypes() { - return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'arrow', 'text', 'image', 'powerZone'] + return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow', 'text', 'image', 'powerZone'] }, /** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */ notifyDrawingEntitiesChanged() { if (this.loadingDrawingsFromRoom) return this.$emit('drawing-entities-changed') }, + /** 白板绘制实体序列化(排除 entity 引用,供父组件更新 contentByTime;输出可复用的 data 结构) */ + serializeWhiteboardDrawingEntity(entityData) { + if (!entityData || !entityData.id) return null + const base = { id: entityData.id, type: entityData.type, color: entityData.color || '#008aff' } + const data = entityData.data ? { ...entityData.data } : {} + switch (entityData.type) { + case 'polygon': + if (entityData.points && entityData.points.length >= 3) { + data.points = entityData.points + data.opacity = entityData.opacity != null ? entityData.opacity : 0 + data.width = entityData.width != null ? entityData.width : 2 + data.borderColor = entityData.borderColor || entityData.color + } + break + case 'rectangle': + if (entityData.points && entityData.points.length >= 2) { + const lngs = entityData.points.map(p => p.lng) + const lats = entityData.points.map(p => p.lat) + data.coordinates = { west: Math.min(...lngs), south: Math.min(...lats), east: Math.max(...lngs), north: Math.max(...lats) } + data.points = entityData.points + } + data.opacity = entityData.opacity != null ? entityData.opacity : 0 + data.width = entityData.width != null ? entityData.width : 2 + data.borderColor = entityData.borderColor || entityData.color + break + case 'circle': + data.center = entityData.center || (entityData.points && entityData.points[0] ? entityData.points[0] : null) + data.radius = entityData.radius != null ? entityData.radius : 1000 + data.opacity = entityData.opacity != null ? entityData.opacity : 0 + data.width = entityData.width != null ? entityData.width : 2 + data.borderColor = entityData.borderColor || entityData.color + break + case 'sector': + data.center = entityData.center || (entityData.points && entityData.points[0] ? entityData.points[0] : null) + data.radius = entityData.radius != null ? entityData.radius : 1000 + data.startAngle = entityData.startAngle != null ? entityData.startAngle : 0 + data.endAngle = entityData.endAngle != null ? entityData.endAngle : Math.PI * 0.5 + data.opacity = entityData.opacity != null ? entityData.opacity : 0.5 + data.width = entityData.width != null ? entityData.width : 2 + data.borderColor = entityData.borderColor || entityData.color + break + case 'line': + case 'auxiliaryLine': + if (entityData.points && entityData.points.length >= 2) { + data.points = entityData.points + data.width = entityData.width != null ? entityData.width : 2 + data.color = entityData.color || entityData.data?.color + } + break + case 'arrow': + if (entityData.points && entityData.points.length >= 2) { + data.points = entityData.points + data.width = entityData.width != null ? entityData.width : 12 + data.color = entityData.color || entityData.data?.color + } + break + default: + return { ...base, data: entityData.data || {} } + } + return { ...base, data } + }, /** 将单个实体序列化为可存储的 JSON 结构 */ serializeEntityForSave(entity) { const base = { id: entity.id, type: entity.type, label: entity.label || '', color: entity.color || '#008aff' } @@ -9130,6 +9571,11 @@ export default { borderColor: entity.borderColor || entity.color, name: entity.name || '' }; break + case 'auxiliaryLine': + data = { + points: entity.points || [], + width: entity.width != null ? entity.width : this.defaultStyles.auxiliaryLine.width + }; break case 'arrow': data = { points: entity.points || [] }; break case 'text': @@ -9192,6 +9638,402 @@ export default { }) this.allEntities = this.allEntities.filter(item => !types.includes(item.type)) }, + /** 从屏幕坐标获取经纬度(用于白板拖放等) */ + getLatLngFromScreen(clientX, clientY) { + if (!this.viewer || !this.viewer.scene || !this.viewer.scene.canvas) return null + const canvas = this.viewer.scene.canvas + const rect = canvas.getBoundingClientRect() + const x = clientX - rect.left + const y = clientY - rect.top + const cartesian = this.viewer.camera.pickEllipsoid(new Cesium.Cartesian2(x, y), this.viewer.scene.globe.ellipsoid) + if (!cartesian) return null + return this.cartesianToLatLng(cartesian) + }, + /** 白板模式:遍历 viewer 全部实体,隐藏非白板实体(含航点、航线、平台等),退出时还原 */ + applyWhiteboardMode(active) { + if (!this.viewer) return + const now = Cesium.JulianDate.now() + if (active) { + this.whiteboardHiddenEntityShows = {} + this.viewer.entities.values.forEach(entity => { + const id = entity.id + if (!id || String(id).startsWith('wb_')) return + let origShow = true + if (entity.show != null) { + origShow = typeof entity.show.getValue === 'function' ? entity.show.getValue(now) : entity.show + } + this.whiteboardHiddenEntityShows[id] = origShow + entity.show = false + }) + this.renderWhiteboardEntities(this.whiteboardEntities || []) + } else { + Object.keys(this.whiteboardHiddenEntityShows || {}).forEach(id => { + const entity = this.viewer.entities.getById(id) + if (entity) entity.show = this.whiteboardHiddenEntityShows[id] + }) + this.whiteboardHiddenEntityShows = {} + this.clearWhiteboardEntities() + } + }, + /** 清除白板实体(id 以 wb_ 开头) */ + clearWhiteboardEntities() { + if (!this.viewer) return + const toRemove = [] + this.viewer.entities.values.forEach(e => { + if (e.id && String(e.id).startsWith('wb_')) toRemove.push(e) + }) + toRemove.forEach(e => this.viewer.entities.remove(e)) + this.whiteboardEntityDataMap = {} + }, + /** 渲染白板实体到地图(平台图标:已存在则就地更新,避免清除重建导致渲染闪烁/消失) */ + renderWhiteboardEntities(entities) { + if (!this.viewer || !Array.isArray(entities)) return + const scene = this.viewer.scene + const wantIds = new Set() + entities.forEach(ed => { + if (ed.type === 'platformIcon') { + const id = (ed.id || '').startsWith('wb_') ? ed.id : ('wb_' + (ed.id || '')) + if (id) wantIds.add(id) + } else { + const id = (ed.id || '').startsWith('wb_') ? ed.id : ('wb_' + (ed.id || '')) + if (id) wantIds.add(id) + } + }) + + this.whiteboardEntityDataMap = this.whiteboardEntityDataMap || {} + const toRemove = [] + this.viewer.entities.values.forEach(e => { + if (e.id && String(e.id).startsWith('wb_') && !wantIds.has(e.id)) toRemove.push(e) + }) + toRemove.forEach(e => this.viewer.entities.remove(e)) + Object.keys(this.whiteboardEntityDataMap).forEach(id => { + if (!wantIds.has(id)) { + const ed = this.whiteboardEntityDataMap[id] + if (ed && ed.entity) this.viewer.entities.remove(ed.entity) + delete this.whiteboardEntityDataMap[id] + } + }) + + entities.forEach(ed => { + try { + if (ed.type === 'platformIcon') { + const id = (ed.id || 'wb_platform_' + Date.now()).startsWith('wb_') ? ed.id : 'wb_' + ed.id + const existing = this.whiteboardEntityDataMap[id] + if (existing && existing.entity) { + this.updateWhiteboardPlatformIconInPlace(existing, ed) + } else { + this.addWhiteboardPlatformIcon(ed) + } + } else { + this.addWhiteboardDrawingEntity(ed) + } + } catch (err) { + console.warn('renderWhiteboardEntity failed', ed?.type, err) + } + }) + + if (scene && scene.requestRender) { + scene.requestRender() + setTimeout(() => scene.requestRender(), 50) + } + }, + /** 就地更新白板平台图标(不销毁实体,避免 requestRenderMode 下渲染消失) */ + updateWhiteboardPlatformIconInPlace(existing, ed) { + existing.lat = ed.lat + existing.lng = ed.lng + existing.heading = ed.heading != null ? ed.heading : 0 + existing.iconScale = ed.iconScale != null ? ed.iconScale : 1.5 + 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.transformHandles) this.updateTransformHandlePositions(existing) + if (this.viewer.scene && this.viewer.scene.requestRender) this.viewer.scene.requestRender() + }, + /** 白板平台图标:添加 billboard,并存入 whiteboardEntityDataMap 以支持拖拽/旋转 */ + addWhiteboardPlatformIcon(ed) { + const platform = ed.platform || {} + const iconUrl = platform.imageUrl || platform.iconUrl + const imageSrc = iconUrl ? this.formatPlatformIconUrl(iconUrl) : this.getDefaultPlatformIconDataUrl() + const id = (ed.id || 'wb_platform_' + Date.now()).startsWith('wb_') ? ed.id : 'wb_' + ed.id + const lat = ed.lat + const lng = ed.lng + const heading = ed.heading != null ? ed.heading : 0 + 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) + const entity = this.viewer.entities.add({ + id, + name: ed.label || platform.name || '平台', + position: cartesian, + billboard: { + image: imageSrc, + width: size, + height: size, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + rotation, + scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 200000, 0.35), + 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 } + this.whiteboardEntityDataMap = this.whiteboardEntityDataMap || {} + this.whiteboardEntityDataMap[id] = entityData + }, + /** 白板空域/图形实体:复用 importEntity 逻辑但不加入 allEntities */ + addWhiteboardDrawingEntity(ed) { + const id = (ed.id || 'wb_draw_' + Date.now()).startsWith('wb_') ? ed.id : 'wb_' + ed.id + const entityData = { ...ed, id } + if (ed.data) { + entityData.data = ed.data + } + const existing = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[id] + if (existing && existing.entity) { + this.viewer.entities.remove(existing.entity) + delete this.whiteboardEntityDataMap[id] + } + this.importEntityAsWhiteboard(entityData) + }, + /** 仅添加图形到 viewer,不加入 allEntities(用于白板) */ + importEntityAsWhiteboard(entityData) { + const color = entityData.color || '#008aff' + const types = this.getDrawingEntityTypes() + if (!types.includes(entityData.type)) return + const data = entityData.data || {} + let entity + switch (entityData.type) { + case 'rectangle': { + const rectCoords = data.coordinates || (data.points && data.points.length >= 2 + ? { west: Math.min(...data.points.map(p => p.lng)), south: Math.min(...data.points.map(p => p.lat)), east: Math.max(...data.points.map(p => p.lng)), north: Math.max(...data.points.map(p => p.lat)) } + : null) + if (!rectCoords) return + const rectOpacity = data.opacity != null ? data.opacity : 0 + const rectWidth = data.width != null ? data.width : 2 + const rectBorderColor = data.borderColor || color + const southwest = Cesium.Cartesian3.fromDegrees(rectCoords.west, rectCoords.south) + const southeast = Cesium.Cartesian3.fromDegrees(rectCoords.east, rectCoords.south) + const northeast = Cesium.Cartesian3.fromDegrees(rectCoords.east, rectCoords.north) + const northwest = Cesium.Cartesian3.fromDegrees(rectCoords.west, rectCoords.north) + entity = this.viewer.entities.add({ + id: entityData.id, + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees(rectCoords.west, rectCoords.south, rectCoords.east, rectCoords.north), + material: Cesium.Color.fromCssColorString(color).withAlpha(rectOpacity) + }, + polyline: { + positions: [southwest, southeast, northeast, northwest, southwest], + width: rectWidth, + material: Cesium.Color.fromCssColorString(rectBorderColor), + arcType: Cesium.ArcType.NONE + } + }) + entityData.points = [this.cartesianToLatLng(southwest), this.cartesianToLatLng(southeast), this.cartesianToLatLng(northeast), this.cartesianToLatLng(northwest)] + entityData.opacity = rectOpacity + entityData.width = rectWidth + entityData.borderColor = rectBorderColor + break + } + case 'polygon': { + const polygonPositions = (data.points || []).map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + if (polygonPositions.length < 3) return + const polyOpacity = data.opacity != null ? data.opacity : 0 + const polyWidth = data.width != null ? data.width : 2 + const polyBorderColor = data.borderColor || color + entity = this.viewer.entities.add({ + id: entityData.id, + polygon: { + hierarchy: polygonPositions, + material: Cesium.Color.fromCssColorString(color).withAlpha(polyOpacity) + }, + polyline: { + positions: [...polygonPositions, polygonPositions[0]], + width: polyWidth, + material: Cesium.Color.fromCssColorString(polyBorderColor), + arcType: Cesium.ArcType.NONE + } + }) + entityData.positions = polygonPositions + entityData.points = data.points || polygonPositions.map(p => this.cartesianToLatLng(p)) + entityData.opacity = polyOpacity + entityData.width = polyWidth + entityData.borderColor = polyBorderColor + break + } + case 'circle': { + const center = data.center || {} + const radius = data.radius || 1000 + const circleCenter = Cesium.Cartesian3.fromDegrees(center.lng || 0, center.lat || 0) + const circlePositions = this.generateCirclePositions(circleCenter, radius) + const circleOpacity = data.opacity != null ? data.opacity : 0 + const circleWidth = data.width != null ? data.width : 2 + const circleBorderColor = data.borderColor || color + entity = this.viewer.entities.add({ + id: entityData.id, + position: circleCenter, + ellipse: { + semiMajorAxis: radius, + semiMinorAxis: radius, + material: Cesium.Color.fromCssColorString(color).withAlpha(circleOpacity), + arcType: Cesium.ArcType.NONE + }, + polyline: { + positions: circlePositions, + width: circleWidth, + material: Cesium.Color.fromCssColorString(circleBorderColor), + arcType: Cesium.ArcType.NONE + } + }) + entityData.radius = radius + entityData.center = center + entityData.points = [center.lng != null && center.lat != null ? center : this.cartesianToLatLng(circleCenter)] + entityData.opacity = circleOpacity + entityData.width = circleWidth + entityData.borderColor = circleBorderColor + break + } + case 'powerZone': { + const center = data.center || {} + const radius = (data.radius || 50000) * 1000 + const centerPos = Cesium.Cartesian3.fromDegrees(center.lng || 0, center.lat || 0) + const zoneColor = data.color || color + const zoneOpacity = data.opacity != null ? data.opacity : 0.3 + entity = this.viewer.entities.add({ + id: entityData.id, + position: centerPos, + ellipse: { + semiMajorAxis: radius, + semiMinorAxis: radius, + material: Cesium.Color.fromCssColorString(zoneColor).withAlpha(zoneOpacity), + arcType: Cesium.ArcType.NONE + } + }) + break + } + case 'text': { + const pos = data.position || (data.lat != null && data.lng != null ? { lat: data.lat, lng: data.lng } : null) + const text = data.text || entityData.label || '' + if (!pos || (pos.lng == null && pos.lat == null)) return + const lng = pos.lng != null ? pos.lng : (data.lng != null ? data.lng : 0) + const lat = pos.lat != null ? pos.lat : (data.lat != null ? data.lat : 0) + entity = this.viewer.entities.add({ + id: entityData.id, + position: Cesium.Cartesian3.fromDegrees(lng, lat), + label: { + text, + font: (data.fontSize || 14) + 'px sans-serif', + fillColor: Cesium.Color.fromCssColorString(data.color || color), + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, -10) + } + }) + break + } + case 'sector': { + const center = data.center || {} + const radius = data.radius || 1000 + const startAngle = data.startAngle != null ? data.startAngle : 0 + const endAngle = data.endAngle != null ? data.endAngle : Math.PI * 0.5 + const centerPos = Cesium.Cartesian3.fromDegrees(center.lng || 0, center.lat || 0) + const positions = this.generateSectorPositions(centerPos, radius, startAngle, endAngle) + const sectorOpacity = data.opacity != null ? data.opacity : 0.5 + const sectorWidth = data.width != null ? data.width : 2 + const sectorBorderColor = data.borderColor || color + entity = this.viewer.entities.add({ + id: entityData.id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy(positions), + material: Cesium.Color.fromCssColorString(color).withAlpha(sectorOpacity) + }, + polyline: { + positions: positions, + width: sectorWidth, + material: Cesium.Color.fromCssColorString(sectorBorderColor), + arcType: Cesium.ArcType.NONE + } + }) + entityData.center = center + entityData.radius = radius + entityData.startAngle = startAngle + entityData.endAngle = endAngle + entityData.positions = positions + entityData.opacity = sectorOpacity + entityData.width = sectorWidth + entityData.borderColor = sectorBorderColor + break + } + case 'line': + case 'auxiliaryLine': { + const pts = (data.points || []).map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + if (pts.length < 2) return + const lineWidth = data.width != null ? data.width : 2 + const lineColor = data.color || color + entity = this.viewer.entities.add({ + id: entityData.id, + polyline: { + positions: pts, + width: lineWidth, + material: Cesium.Color.fromCssColorString(lineColor), + arcType: Cesium.ArcType.NONE + } + }) + entityData.positions = pts + entityData.points = data.points || pts.map(p => this.cartesianToLatLng(p)) + entityData.width = lineWidth + entityData.color = lineColor + break + } + case 'arrow': { + const arrowPts = (data.points || []).map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + if (arrowPts.length < 2) return + const arrowWidth = data.width != null ? data.width : 12 + const arrowColor = data.color || color + entity = this.viewer.entities.add({ + id: entityData.id, + polyline: { + positions: arrowPts, + width: arrowWidth, + material: new Cesium.PolylineArrowMaterialProperty(Cesium.Color.fromCssColorString(arrowColor)), + arcType: Cesium.ArcType.NONE, + widthInMeters: false + } + }) + entityData.positions = arrowPts + entityData.points = data.points || arrowPts.map(p => this.cartesianToLatLng(p)) + entityData.width = arrowWidth + entityData.color = arrowColor + break + } + case 'image': { + if (!data.imageUrl || data.lat == null || data.lng == null) return + const imgPos = Cesium.Cartesian3.fromDegrees(data.lng, data.lat) + entity = this.viewer.entities.add({ + id: entityData.id, + position: imgPos, + billboard: { + image: data.imageUrl, + width: data.width != null ? data.width : 64, + height: data.height != null ? data.height : 64, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.12, 1200000, 0.72), + translucencyByDistance: new Cesium.NearFarScalar(300, 1.0, 600000, 0.88) + } + }) + break + } + default: + return + } + if (entity && entityData.id) { + entityData.entity = entity + entityData.isWhiteboard = true + this.whiteboardEntityDataMap = this.whiteboardEntityDataMap || {} + this.whiteboardEntityDataMap[entityData.id] = entityData + } + }, /** 从房间/方案加载的 frontend_drawings JSON 恢复空域图形(先清空当前图形再导入;加载期间不触发自动保存) */ loadFrontendDrawings(data) { if (!this.viewer) return @@ -9230,6 +10072,7 @@ export default { polygon: '面', rectangle: '矩形', circle: '圆形', + auxiliaryLine: '辅助线', ellipse: '椭圆', hold_circle: '圆形盘旋', hold_ellipse: '椭圆盘旋', @@ -9610,6 +10453,38 @@ export default { this.notifyDrawingEntitiesChanged() return } + case 'auxiliaryLine': { + const auxPts = entityData.data.points + if (!auxPts || auxPts.length < 2) break + const auxPositions = auxPts.map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + const auxLinePositions = auxPositions.length === 2 ? auxPositions : [auxPositions[0], auxPositions[auxPositions.length - 1]] + const auxColor = entityData.color || this.defaultStyles.auxiliaryLine.color + const auxWidth = entityData.data.width != null ? entityData.data.width : this.defaultStyles.auxiliaryLine.width + entity = this.viewer.entities.add({ + polyline: { + positions: auxLinePositions, + width: auxWidth, + material: Cesium.Color.fromCssColorString(auxColor), + arcType: Cesium.ArcType.NONE + } + }) + this.allEntities.push({ + id: entity.id, + type: 'auxiliaryLine', + points: [auxPts[0], auxPts[auxPts.length - 1]], + positions: auxLinePositions, + entity, + color: auxColor, + width: auxWidth, + label: entityData.label || '辅助线' + }) + entity.clickHandler = (e) => { + this.selectEntity(this.allEntities.find(ed => ed.entity === entity)) + e.stopPropagation() + } + this.notifyDrawingEntitiesChanged() + return + } case 'arrow': { const pts = entityData.data.points if (!pts || pts.length < 2) break diff --git a/ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue b/ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue index 78dcc61..386bb15 100644 --- a/ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue +++ b/ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue @@ -5,6 +5,7 @@ ref="timeline" @timeline-hidden="onTimelineHidden" :room-id="roomId" + :is-parent-room="isParentRoom" />
@@ -22,12 +23,17 @@ 六步法
+
+ + 白板 +
@@ -56,11 +65,18 @@ export default { roomId: { type: [Number, String], default: null + }, + isParentRoom: { + type: Boolean, + default: false } }, computed: { showPanel() { return !this.showTimelineBar && !this.showSixStepsBar + }, + progressStorageKey() { + return this.roomId != null ? `six-steps-progress-${this.roomId}` : null } }, watch: { @@ -83,7 +99,8 @@ export default { { title: '准备', desc: '识别和评估潜在风险', active: false, completed: false }, { title: '执行', desc: '实时监控执行过程', active: false, completed: false }, { title: '评估', desc: '评估任务完成效果', active: false, completed: false } - ] + ], + savedProgress: {} } }, methods: { @@ -105,11 +122,8 @@ export default { toggleSixSteps() { this.showSixStepsBar = !this.showSixStepsBar if (this.showSixStepsBar) { + this.restoreProgress() this.showSixStepsOverlay = true - this.taskBlockActive = true - this.sixStepsData.forEach(s => { s.active = false; s.completed = false }) - this.activeStepIndex = 0 - // 点开六步法时同时显示时间轴 this.showTimelineBar = true if (this.$refs.timeline) { this.$refs.timeline.isVisible = true @@ -120,6 +134,67 @@ export default { this.isExpanded = false this.updateBottomPanelVisible() }, + toggleWhiteboard() { + this.$emit('open-whiteboard') + this.isExpanded = false + }, + saveProgress(overlayProgress) { + if (!this.progressStorageKey) return + try { + const progress = { + taskBlockActive: this.taskBlockActive, + activeStepIndex: this.activeStepIndex, + sixStepsData: this.sixStepsData.map(s => ({ ...s })), + ...overlayProgress + } + sessionStorage.setItem(this.progressStorageKey, JSON.stringify(progress)) + } catch (e) { + console.warn('SixSteps saveProgress failed:', e) + } + }, + restoreProgress() { + if (!this.progressStorageKey) { + this.taskBlockActive = true + this.activeStepIndex = 0 + this.sixStepsData.forEach(s => { s.active = false; s.completed = false }) + return + } + try { + const raw = sessionStorage.getItem(this.progressStorageKey) + const progress = raw ? JSON.parse(raw) : null + if (progress) { + this.taskBlockActive = progress.taskBlockActive !== false + this.activeStepIndex = Math.min(progress.activeStepIndex || 0, 5) + if (Array.isArray(progress.sixStepsData) && progress.sixStepsData.length === this.sixStepsData.length) { + progress.sixStepsData.forEach((s, i) => { + if (this.sixStepsData[i]) { + this.sixStepsData[i].active = s.active + this.sixStepsData[i].completed = s.completed + } + }) + } + this.savedProgress = { + activeUnderstandingSubIndex: progress.activeUnderstandingSubIndex || 0, + activeTaskSubIndex: progress.activeTaskSubIndex || 0, + activeStepSubIndex: progress.activeStepSubIndex || 0 + } + } else { + this.taskBlockActive = true + this.activeStepIndex = 0 + this.sixStepsData.forEach(s => { s.active = false; s.completed = false }) + this.savedProgress = {} + } + } catch (e) { + console.warn('SixSteps restoreProgress failed:', e) + this.taskBlockActive = true + this.activeStepIndex = 0 + this.savedProgress = {} + } + }, + onSixStepsClose(overlayProgress) { + this.saveProgress(overlayProgress || {}) + this.closeBoth() + }, updateBottomPanelVisible() { this.$emit('bottom-panel-visible', this.showTimelineBar || this.showSixStepsBar) }, @@ -127,9 +202,17 @@ export default { this.closeBoth() }, hideSixStepsBar() { + const overlay = this.$refs.sixStepsOverlay + if (overlay && overlay.getProgress) { + this.saveProgress(overlay.getProgress()) + } this.closeBoth() }, closeBoth() { + const overlay = this.$refs.sixStepsOverlay + if (overlay && overlay.getProgress) { + this.saveProgress(overlay.getProgress()) + } this.showTimelineBar = false this.showSixStepsBar = false this.showSixStepsOverlay = false diff --git a/ruoyi-ui/src/views/childRoom/BottomTimeline.vue b/ruoyi-ui/src/views/childRoom/BottomTimeline.vue index 580997a..70fcb96 100644 --- a/ruoyi-ui/src/views/childRoom/BottomTimeline.vue +++ b/ruoyi-ui/src/views/childRoom/BottomTimeline.vue @@ -26,12 +26,13 @@ :class="{ active: segment.active, passed: segment.passed }" :style="{ left: segment.position + '%' }" @click="openSegmentDialog(segment)" - :title="segment.time + ' - ' + segment.name" + :title="segment.time + ' - ' + segment.name + (segment.roomName ? ' [' + segment.roomName + ']' : '')" >
{{ segment.time }}
{{ segment.name }}
+
{{ segment.roomName }}
@@ -107,6 +108,7 @@
{{ segment.time }}
{{ segment.name }}
+
{{ segment.roomName }}
{{ segment.description }}
@@ -173,6 +175,10 @@ {{ currentSegment.name }}
+
+ + {{ currentSegment.roomName }} +
{{ currentSegment.description }} @@ -194,6 +200,7 @@ + + + + diff --git a/ruoyi-ui/src/views/childRoom/TaskPageContent.vue b/ruoyi-ui/src/views/childRoom/TaskPageContent.vue index 1ff4f5b..1c053ae 100644 --- a/ruoyi-ui/src/views/childRoom/TaskPageContent.vue +++ b/ruoyi-ui/src/views/childRoom/TaskPageContent.vue @@ -64,23 +64,23 @@ @mousedown="onTextBoxMouseDown(box, $event)" >
- -
- - - - - - - +
+
{{ box.placeholder }}
+
-
+ +
+ + + + + + + +
+ + diff --git a/ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue b/ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue new file mode 100644 index 0000000..06b1ae6 --- /dev/null +++ b/ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index ae9c140..e89aeba 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -9,9 +9,11 @@ @drop="handleMapDrop" > - + @platform-style-saved="onPlatformStyleSaved" + @whiteboard-draw-complete="handleWhiteboardDrawComplete" + @whiteboard-platform-updated="handleWhiteboardPlatformUpdated" + @whiteboard-entity-deleted="handleWhiteboardEntityDeleted" + @whiteboard-drawing-updated="handleWhiteboardDrawingUpdated" />
- + +
+ + + + + + + + + this.getList()); } } }, @@ -725,6 +795,7 @@ export default { ); }, canSetKTime() { + if (this.roomDetail && this.roomDetail.parentId == null) return false; return this.isRoomOwner || this.isAdmin; }, /** 格式化的 K 时(基准时刻),供右上角显示 */ @@ -745,6 +816,31 @@ export default { if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。'; return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`; }, + /** 白板模式下当前时间块应显示的实体(继承逻辑:当前时刻 = 上一时刻 + 本时刻差异) */ + whiteboardDisplayEntities() { + if (!this.showWhiteboardPanel || !this.currentWhiteboard || !this.currentWhiteboardTimeBlock) return [] + const wb = this.currentWhiteboard + const contentByTime = wb.contentByTime || {} + const timeBlocks = (wb.timeBlocks || []).slice().sort((a, b) => this.compareWhiteboardTimeBlock(a, b)) + const idx = timeBlocks.indexOf(this.currentWhiteboardTimeBlock) + if (idx < 0) return [] + const merged = {} + for (let i = 0; i <= idx; i++) { + const tb = timeBlocks[i] + const ents = (contentByTime[tb] && contentByTime[tb].entities) || [] + ents.forEach(e => { + const id = e.id || (e.data && e.data.id) + if (id) merged[id] = e + else merged['_noid_' + Math.random()] = e + }) + } + return Object.values(merged) + }, + + /** 所有平台(用于导入航线时选择默认平台) */ + allPlatformsForImport() { + return [...(this.airPlatforms || []), ...(this.seaPlatforms || []), ...(this.groundPlatforms || [])]; + }, /** 被其他成员编辑锁定的航线 ID 列表,供地图禁止拖拽等 */ routeLockedByOtherRouteIds() { const myId = this.currentUserId; @@ -785,9 +881,12 @@ export default { created() { this.currentRoomId = this.$route.query.roomId; console.log("从路由接收到的真实房间 ID:", this.currentRoomId); - this.getList(); this.getPlatformList(); - if (this.currentRoomId) this.getRoomDetail(); + if (this.currentRoomId) { + this.getRoomDetail(() => this.getList()); + } else { + this.getList(); + } }, methods: { handleBottomPanelVisible(visible) { @@ -1504,9 +1603,8 @@ export default { const newer = existing.filter(m => (m.timestamp || 0) > maxTs); this.$set(this.privateChatMessages, targetUserId, [...history, ...newer]); }, - onSyncRouteVisibility: (routeId, visible, senderSessionId) => { - if (this.isMySyncSession(senderSessionId)) return; - this.applySyncRouteVisibility(routeId, visible); + onSyncRouteVisibility: () => { + // 小房间内每人展示各自内容,不应用他人的航线显隐变更 }, onSyncWaypoints: (routeId, senderSessionId) => { if (this.isMySyncSession(senderSessionId)) return; @@ -1524,8 +1622,8 @@ export default { if (this.isMySyncSession(senderSessionId)) return; this.applySyncPlatformStyles(); }, - onRoomState: (visibleRouteIds) => { - this.applyRoomStateVisibleRoutes(visibleRouteIds); + onRoomState: () => { + // 小房间内每人展示各自内容,新加入时不从他人同步可见航线 }, onConnected: () => {}, onDisconnected: () => { @@ -1678,9 +1776,13 @@ export default { if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data); }).catch(() => {}); }, - /** 收到其他设备的空域图形变更同步:拉取最新房间数据并重绘 */ + /** 收到其他设备的空域图形变更同步:拉取最新房间数据并重绘;大房间时重新合并子房间标绘 */ applySyncRoomDrawings() { if (!this.currentRoomId || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadFrontendDrawings !== 'function') return; + if (this.roomDetail && this.roomDetail.parentId == null) { + this.loadRoomDrawings(); + return; + } getRooms(this.currentRoomId).then(res => { if (res.code === 200 && res.data) { this.roomDetail = this.roomDetail ? { ...this.roomDetail, frontendDrawings: res.data.frontendDrawings } : { ...res.data }; @@ -1996,19 +2098,33 @@ export default { const { skipRoomPlatformIcons = false } = opts; try { const roomId = this.$route.query.roomId || this.currentRoomId; - const scenarioRes = await listScenario({ roomId: roomId }); + const isParentRoom = this.roomDetail && this.roomDetail.parentId == null; + const scenarioParams = isParentRoom + ? { parentRoomId: roomId, pageNum: 1, pageSize: 9999 } + : { roomId: roomId, pageNum: 1, pageSize: 9999 }; + const scenarioRes = await listScenario(scenarioParams); if (scenarioRes.code === 200) { this.plans = scenarioRes.rows.map(s => ({ id: s.id, name: s.name, + roomId: s.roomId, frontendDrawings: s.frontendDrawings || null, routes: [] })); } - // 获取所有航线 - const routeRes = await listRoutes({ pageNum: 1, pageSize: 9999 }); + // 获取航线:仅请求当前房间方案下的航线(大房间、小房间均按 planIds 限定) + const planIds = this.plans.map(p => p.id); + const routeParams = planIds.length > 0 + ? { scenarioIdsStr: planIds.join(','), pageNum: 1, pageSize: 9999 } + : { pageNum: 1, pageSize: 9999 }; + const routeRes = await listRoutes(routeParams); if (routeRes.code === 200) { - const allRoutes = routeRes.rows.map(item => ({ + let routeRows = routeRes.rows || []; + // 大房间和小房间均只保留当前房间方案下的航线(小房间时 API 可能返回全部,需按 planIds 过滤) + if (planIds.length > 0) { + routeRows = routeRows.filter(r => planIds.includes(r.scenarioId)); + } + const allRoutes = routeRows.map(item => ({ id: item.id, name: item.callSign, platformId: item.platformId, @@ -2025,15 +2141,17 @@ export default { }); this.routes = allRoutes; - // 先预取所有展示中航线的平台样式,再渲染,避免平台图标先黑后变色 + // 先预取所有展示中航线的平台样式,再渲染,避免平台图标先黑后变色(大房间时用方案所属子房间的 roomId) if (this.activeRouteIds.length > 0 && this.$refs.cesiumMap) { const roomId = this.currentRoomId; await Promise.all(this.activeRouteIds.map(async (id) => { const route = this.routes.find(r => r.id === id); if (!route || !route.waypoints || route.waypoints.length === 0) return; - if (roomId && route.platformId) { + const plan = this.plans.find(p => p.id === route.scenarioId); + const styleRoomId = (isParentRoom && plan && plan.roomId) ? plan.roomId : roomId; + if (styleRoomId && route.platformId) { try { - const res = await getPlatformStyle({ roomId, routeId: id, platformId: route.platformId }); + const res = await getPlatformStyle({ roomId: styleRoomId, routeId: id, platformId: route.platformId }); if (res.data) this.$refs.cesiumMap.setPlatformStyle(id, res.data); } catch (_) {} } @@ -2053,13 +2171,24 @@ export default { }); }); } - // 加载该房间下保存的地图平台图标(删除航线等操作时跳过,避免清空再重画导致平台图标闪一下) + // 加载该房间下保存的地图平台图标(大房间时加载所有子房间的图标) if (!skipRoomPlatformIcons) { const rId = roomId || this.currentRoomId; if (rId && this.$refs.cesiumMap && typeof this.$refs.cesiumMap.loadRoomPlatformIcons === 'function') { - listRoomPlatformIcons(rId).then(res => { - if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data); - }).catch(() => {}); + if (isParentRoom) { + listRooms({ pageNum: 1, pageSize: 999, parentId: rId }).then(roomsRes => { + const childRooms = roomsRes.rows || roomsRes || []; + childRooms.forEach(child => { + listRoomPlatformIcons(child.id).then(res => { + if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(child.id, res.data); + }).catch(() => {}); + }); + }).catch(() => {}); + } else { + listRoomPlatformIcons(rId).then(res => { + if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data); + }).catch(() => {}); + } } } this.$nextTick(() => this.applyRoomStatePending()); @@ -2427,22 +2556,68 @@ export default { const min = parseInt(m[3], 10); return sign * (h * 60 + min); }, - getRoomDetail() { - if (!this.currentRoomId) return; + 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 = []; + } } - }).catch(() => {}); + callback && callback(); + }).catch(() => { callback && callback(); }); }, - /** 加载当前房间的空域/威力区图形(与房间 ID 绑定,进入该房间即显示) */ - loadRoomDrawings() { + /** 加载当前房间的空域/威力区图形(与房间 ID 绑定,进入该房间即显示);大房间时合并所有子房间的 frontend_drawings */ + async loadRoomDrawings() { if (!this.roomDetail || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadFrontendDrawings !== 'function') return; - if (this.roomDetail.frontendDrawings) { - this.$refs.cesiumMap.loadFrontendDrawings(this.roomDetail.frontendDrawings); + const isParentRoom = this.roomDetail.parentId == null; + if (isParentRoom) { + const roomsRes = await listRooms({ pageNum: 1, pageSize: 999, parentId: this.currentRoomId }).catch(() => ({ rows: [] })); + const childRooms = roomsRes.rows || roomsRes || []; + const allEntities = []; + for (const child of childRooms) { + const fd = child.frontendDrawings; + if (!fd) continue; + let payload = typeof fd === 'string' ? (() => { try { return JSON.parse(fd) } catch (_) { return null } })() : fd; + if (!payload || !Array.isArray(payload.entities) || payload.entities.length === 0) continue; + const prefix = `room_${child.id}_`; + payload.entities.forEach(e => { + const cloned = JSON.parse(JSON.stringify(e)); + if (cloned.id) cloned.id = prefix + cloned.id; + allEntities.push(cloned); + }); + } + if (this.roomDetail.frontendDrawings) { + let parentPayload = typeof this.roomDetail.frontendDrawings === 'string' + ? (() => { try { return JSON.parse(this.roomDetail.frontendDrawings) } catch (_) { return null } })() + : this.roomDetail.frontendDrawings; + if (parentPayload && Array.isArray(parentPayload.entities)) { + parentPayload.entities.forEach(e => { + if (e.id && !/^room_\d+_/.test(String(e.id))) allEntities.push(JSON.parse(JSON.stringify(e))); + }); + } + } + if (allEntities.length > 0) { + this.$refs.cesiumMap.loadFrontendDrawings({ entities: allEntities }); + } else { + this.$refs.cesiumMap.clearDrawingEntities(); + } } else { - this.$refs.cesiumMap.clearDrawingEntities(); + if (this.roomDetail.frontendDrawings) { + this.$refs.cesiumMap.loadFrontendDrawings(this.roomDetail.frontendDrawings); + } else { + this.$refs.cesiumMap.clearDrawingEntities(); + } } }, /** 地图 viewer 就绪时加载当前房间图形(可能 getRoomDetail 尚未返回,此处再试一次) */ @@ -2575,8 +2750,90 @@ export default { }, importRoute() { - this.$message.success('导入航线'); - // 这里可以添加导入航线的逻辑 + this.showImportRoutesDialog = true; + }, + openExportRoutesDialog() { + this.showExportRoutesDialog = true; + }, + /** 导出航线:获取完整数据并下载 JSON */ + async handleExportRoutes(selectedIds) { + if (!selectedIds || selectedIds.length === 0) return; + try { + const routeDataList = []; + for (const id of selectedIds) { + const res = await getRoutes(id); + if (res.code === 200 && res.data) { + const d = res.data; + const waypoints = (d.waypoints || []).map(wp => { + const { id: _id, routeId: _rid, ...rest } = wp; + return rest; + }); + routeDataList.push({ + route: { + callSign: d.callSign, + platformId: d.platformId, + platform: d.platform, + attributes: d.attributes + }, + waypoints + }); + } + } + const exportData = { + version: 1, + exportTime: new Date().toISOString(), + routes: routeDataList + }; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `航线导出_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + this.showExportRoutesDialog = false; + this.$message.success(`已导出 ${selectedIds.length} 条航线`); + } catch (err) { + console.error('导出航线失败:', err); + this.$message.error('导出失败,请重试'); + } + }, + /** 导入航线:从 JSON 创建新航线 */ + async handleImportRoutes({ routeItems, targetScenarioId, targetPlatformId }) { + if (!routeItems || routeItems.length === 0 || !targetScenarioId) return; + const importDialog = this.$refs.importRoutesDialog; + if (importDialog && importDialog.setImporting) importDialog.setImporting(true); + try { + let successCount = 0; + for (const item of routeItems) { + const route = item.route || item; + const waypoints = item.waypoints || route.waypoints || []; + const cleanWaypoints = waypoints.map((wp, idx) => { + const { id: _id, routeId: _rid, ...rest } = wp; + return { + ...rest, + seq: idx + 1 + }; + }); + const payload = { + callSign: route.callSign || route.name || `导入航线${successCount + 1}`, + scenarioId: targetScenarioId, + platformId: (targetPlatformId != null ? targetPlatformId : route.platformId) || 1, + attributes: route.attributes || this.getDefaultRouteAttributes(), + waypoints: cleanWaypoints + }; + const res = await addRoutes(payload); + if (res.code === 200) successCount++; + } + this.showImportRoutesDialog = false; + await this.getList(); + this.$message.success(`成功导入 ${successCount} 条航线`); + } catch (err) { + console.error('导入航线失败:', err); + this.$message.error('导入失败:' + (err.message || '请重试')); + } finally { + if (importDialog && importDialog.setImporting) importDialog.setImporting(false); + } }, exportPlan() { @@ -2754,6 +3011,222 @@ export default { this.$message.success('已触发下载,请在浏览器保存对话框中选择保存位置') }, + /** 白板:K+HH:MM:SS 时间块比较 */ + compareWhiteboardTimeBlock(a, b) { + const parse = (s) => { + const m = /K\+(\d+):(\d+):(\d+)/.exec(s) + if (!m) return 0 + return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + } + return parse(a) - parse(b) + }, + + /** 白板:进入/退出白板模式 */ + async toggleWhiteboardMode() { + if (this.showWhiteboardPanel) { + this.handleWhiteboardExit() + return + } + this.showWhiteboardPanel = true + this.drawDom = false + this.airspaceDrawDom = false + this.whiteboardAirspaceDraw = false + this.isRightPanelHidden = true + this.show4TPanel = false + await this.loadWhiteboards() + if (this.whiteboards.length === 0) { + await this.handleWhiteboardCreate() + } else { + this.currentWhiteboard = this.whiteboards[0] + const blocks = this.currentWhiteboard.timeBlocks || [] + this.currentWhiteboardTimeBlock = blocks.length > 0 ? blocks.sort((a, b) => this.compareWhiteboardTimeBlock(a, b))[0] : null + } + }, + + async loadWhiteboards() { + if (!this.currentRoomId) return + try { + const res = await listWhiteboards(this.currentRoomId) + this.whiteboards = (res.data || res) || [] + } catch (e) { + this.whiteboards = [] + } + }, + + handleWhiteboardSelect(id) { + const wb = this.whiteboards.find(w => w.id === id) + if (wb) { + this.currentWhiteboard = wb + const blocks = (wb.timeBlocks || []).slice().sort((a, b) => this.compareWhiteboardTimeBlock(a, b)) + this.currentWhiteboardTimeBlock = blocks.length > 0 ? blocks[0] : null + } + }, + + async handleWhiteboardCreate(name) { + if (!this.currentRoomId) { + this.$message.warning('请先进入任务房间') + return + } + try { + const res = await createWhiteboard(this.currentRoomId, { name: name || '白板方案' }) + const wb = res.data || res + this.whiteboards.push(wb) + this.currentWhiteboard = wb + this.currentWhiteboardTimeBlock = null + this.$message.success('已创建白板') + } catch (e) { + this.$message.error('创建白板失败') + } + }, + + async handleWhiteboardRename(id, name) { + if (!this.currentRoomId || !id || !name) return + const wb = this.whiteboards.find(w => w.id === id) + if (!wb) return + try { + const updated = { ...wb, name } + await updateWhiteboard(this.currentRoomId, id, updated) + wb.name = name + if (this.currentWhiteboard && this.currentWhiteboard.id === id) this.currentWhiteboard = updated + this.$message.success('已重命名') + } catch (e) { + this.$message.error('重命名失败') + } + }, + async handleWhiteboardDelete(id) { + if (!this.currentRoomId || !id) return + try { + await deleteWhiteboard(this.currentRoomId, id) + this.whiteboards = this.whiteboards.filter(w => w.id !== id) + if (this.currentWhiteboard && this.currentWhiteboard.id === id) { + this.currentWhiteboard = this.whiteboards[0] || null + this.currentWhiteboardTimeBlock = this.currentWhiteboard && (this.currentWhiteboard.timeBlocks || []).length > 0 + ? (this.currentWhiteboard.timeBlocks || []).sort((a, b) => this.compareWhiteboardTimeBlock(a, b))[0] + : null + } + this.$message.success('已删除') + } catch (e) { + this.$message.error('删除失败') + } + }, + handleWhiteboardExit() { + this.showWhiteboardPanel = false + this.whiteboardAirspaceDraw = false + this.currentWhiteboard = null + this.currentWhiteboardTimeBlock = null + this.loadRoomDrawings() + }, + + handleWhiteboardTimeBlockSelect(tb) { + this.currentWhiteboardTimeBlock = tb + }, + + async handleWhiteboardAddTimeBlock(tb) { + if (!this.currentWhiteboard) return + const blocks = [...(this.currentWhiteboard.timeBlocks || [])] + if (blocks.includes(tb)) { + this.$message.warning('该时间块已存在') + return + } + blocks.push(tb) + blocks.sort((a, b) => this.compareWhiteboardTimeBlock(a, b)) + const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) } + contentByTime[tb] = { entities: [] } + await this.saveCurrentWhiteboard({ timeBlocks: blocks, contentByTime }) + this.currentWhiteboardTimeBlock = tb + }, + + async handleWhiteboardRenameTimeBlock(oldTb, newTb) { + if (!this.currentWhiteboard || oldTb === newTb) return + const blocks = [...(this.currentWhiteboard.timeBlocks || [])] + const idx = blocks.indexOf(oldTb) + if (idx < 0) return + blocks[idx] = newTb + blocks.sort((a, b) => this.compareWhiteboardTimeBlock(a, b)) + const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) } + if (contentByTime[oldTb]) { + contentByTime[newTb] = contentByTime[oldTb] + delete contentByTime[oldTb] + } + await this.saveCurrentWhiteboard({ timeBlocks: blocks, contentByTime }) + this.currentWhiteboardTimeBlock = newTb + }, + + async handleWhiteboardDeleteTimeBlock(tb) { + if (!this.currentWhiteboard) return + const blocks = (this.currentWhiteboard.timeBlocks || []).filter(t => t !== tb) + const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) } + delete contentByTime[tb] + await this.saveCurrentWhiteboard({ timeBlocks: blocks, contentByTime }) + this.currentWhiteboardTimeBlock = blocks.length > 0 ? blocks.sort((a, b) => this.compareWhiteboardTimeBlock(a, b))[0] : null + }, + + handleWhiteboardDrawModeChange(mode) { + this.whiteboardAirspaceDraw = mode === 'airspace' + }, + + handleWhiteboardDrawComplete(entityData) { + if (!this.currentWhiteboard || !this.currentWhiteboardTimeBlock) return + const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) } + const currentContent = contentByTime[this.currentWhiteboardTimeBlock] || { entities: [] } + currentContent.entities = [...(currentContent.entities || []), entityData] + contentByTime[this.currentWhiteboardTimeBlock] = currentContent + this.saveCurrentWhiteboard({ 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) + 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 + ents[idx] = updated + contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents } + this.saveCurrentWhiteboard({ contentByTime }) + } + }, + + /** 白板平台从右键菜单删除后,从 contentByTime 移除并保存 */ + handleWhiteboardEntityDeleted(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 || []).filter(e => e.id !== entityData.id) + contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents } + this.saveCurrentWhiteboard({ contentByTime }) + }, + + /** 白板空域/图形编辑后(调整位置、修改属性等)更新 contentByTime 并保存 */ + handleWhiteboardDrawingUpdated(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) + if (idx >= 0) { + ents[idx] = entityData + contentByTime[this.currentWhiteboardTimeBlock] = { ...currentContent, entities: ents } + this.saveCurrentWhiteboard({ contentByTime }) + } + }, + + async saveCurrentWhiteboard(patch) { + if (!this.currentRoomId || !this.currentWhiteboard) return + const wb = { ...this.currentWhiteboard, ...patch } + try { + await updateWhiteboard(this.currentRoomId, wb.id, wb) + this.currentWhiteboard = wb + const i = this.whiteboards.findIndex(w => w.id === wb.id) + if (i >= 0) this.whiteboards[i] = wb + } catch (e) { + this.$message.error('保存白板失败') + } + }, + handleDeleteMenuItem(deletedItem) { const index = this.menuItems.findIndex(item => item.id === deletedItem.id) if (index > -1) { @@ -2888,14 +3361,48 @@ export default { if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy' }, - /** 将平台图标放置到地图上,并保存到当前房间 */ + /** 将平台图标放置到地图上,并保存到当前房间;白板模式下添加到白板当前时间块 */ async handleMapDrop(ev) { ev.preventDefault() const raw = ev.dataTransfer && ev.dataTransfer.getData('application/json') if (!raw) return try { - const platform = JSON.parse(raw) + const data = JSON.parse(raw) + const platform = data.type === 'whiteboardPlatform' ? data.platform : data const map = this.$refs.cesiumMap + + if (this.showWhiteboardPanel && data.type === 'whiteboardPlatform') { + if (!this.currentWhiteboard || !this.currentWhiteboardTimeBlock) { + this.$message.warning('请先选择或添加时间块') + return + } + const pos = map && typeof map.getLatLngFromScreen === 'function' ? map.getLatLngFromScreen(ev.clientX, ev.clientY) : null + if (!pos) { + this.$message.warning('请将图标放置到地图有效区域内') + return + } + const entityId = 'wb_' + Date.now() + '_' + Math.random().toString(36).slice(2) + const entity = { + id: entityId, + type: 'platformIcon', + platformId: platform.id, + platform, + platformName: platform.name || '', + lat: pos.lat, + lng: pos.lng, + heading: 0, + label: platform.name || '平台', + color: platform.color || '#008aff' + } + const contentByTime = { ...(this.currentWhiteboard.contentByTime || {}) } + const currentContent = contentByTime[this.currentWhiteboardTimeBlock] || { entities: [] } + currentContent.entities = [...(currentContent.entities || []), entity] + contentByTime[this.currentWhiteboardTimeBlock] = currentContent + await this.saveCurrentWhiteboard({ contentByTime }) + this.$message.success('已添加到白板') + return + } + if (!map || typeof map.addPlatformIconFromDrag !== 'function') return const entityData = map.addPlatformIconFromDrag(platform, ev.clientX, ev.clientY) if (!entityData || !this.currentRoomId) return @@ -3046,8 +3553,8 @@ export default { }, importRouteData(path) { - console.log('导入航路:', path) - this.$message.success('航路数据导入成功'); + // 统一打开导入航线弹窗(文件菜单、外部参数等入口均走此流程;浏览器无法读取本地路径,需用户选择文件) + this.showImportRoutesDialog = true; }, importLandmark(path) { @@ -3120,6 +3627,9 @@ export default { } else if (item.id === '4t') { // 4T:切换4T悬浮窗显示 this.show4TPanel = !this.show4TPanel; + } else if (item.id === 'whiteboard') { + // 白板:进入/退出白板模式 + this.toggleWhiteboardMode(); } else if (item.id === 'start') { // 如果当前已经是冲突标签页,则关闭右侧面板 if (this.activeRightTab === 'conflict' && !this.isRightPanelHidden) { @@ -3242,16 +3752,21 @@ export default { // 右上角作战时间随时与推演时间轴同步(无论是否展示航线) this.combatTime = this.currentTime; this.updateDeductionPositions(); - // 有房间且已选航线时,按房间+航线组合从 Redis 加载导弹(仅变化时加载一次) + // 有房间且已选航线时,按房间+航线组合从 Redis 加载导弹(仅变化时加载一次);大房间时用 plan.roomId const roomId = this.currentRoomId; const routeIds = (this.activeRouteIds || []).slice().sort((a, b) => (a - b)); + const isParentRoom = this.roomDetail && this.roomDetail.parentId == null; if (roomId != null && routeIds.length > 0 && this.$refs.cesiumMap && typeof this.$refs.cesiumMap.loadMissilesFromRedis === 'function') { - const loadKey = roomId + '-' + routeIds.join(','); + const routePlatforms = this.routes + .filter(r => routeIds.includes(r.id)) + .map(r => { + const plan = this.plans.find(p => p.id === r.scenarioId); + const rId = (isParentRoom && plan && plan.roomId) ? plan.roomId : roomId; + return { routeId: r.id, platformId: r.platformId != null ? r.platformId : 0, roomId: rId }; + }); + const loadKey = routePlatforms.map(r => `${r.roomId}-${r.routeId}`).sort().join(','); if (this._missilesLoadKey !== loadKey) { this._missilesLoadKey = loadKey; - const routePlatforms = this.routes - .filter(r => routeIds.includes(r.id)) - .map(r => ({ routeId: r.id, platformId: r.platformId != null ? r.platformId : 0 })); if (routePlatforms.length > 0) { this.$refs.cesiumMap.loadMissilesFromRedis(roomId, routePlatforms); } @@ -3300,14 +3815,20 @@ export default { this.reloadMissilesAfterRouteChange(); }, - /** 根据当前选中的航线重新加载导弹(删除航线后需调用以清除地图上的导弹) */ + /** 根据当前选中的航线重新加载导弹(删除航线后需调用以清除地图上的导弹);大房间时用 plan.roomId */ reloadMissilesAfterRouteChange() { const roomId = this.currentRoomId; if (roomId == null || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadMissilesFromRedis !== 'function') return; + const isParentRoom = this.roomDetail && this.roomDetail.parentId == null; const routeIds = (this.activeRouteIds || []).slice().sort((a, b) => (a - b)); const routePlatforms = this.routes .filter(r => routeIds.includes(r.id)) - .map(r => ({ routeId: r.id, platformId: r.platformId != null ? r.platformId : 0 })); + .map(r => { + const plan = this.plans.find(p => p.id === r.scenarioId); + const rId = (isParentRoom && plan && plan.roomId) ? plan.roomId : roomId; + return { routeId: r.id, platformId: r.platformId != null ? r.platformId : 0, roomId: rId }; + }); + this._missilesLoadKey = routePlatforms.map(r => `${r.roomId}-${r.routeId}`).sort().join(','); this.$refs.cesiumMap.loadMissilesFromRedis(roomId, routePlatforms); }, @@ -4191,7 +4712,7 @@ export default { this.selectedRouteDetails = null; } } - this.wsConnection?.sendSyncRouteVisibility?.(route.id, false); + // 航线显隐仅本地生效,不同步给他人 this.$message.info(`已取消航线: ${route.name}`); return; } @@ -4243,7 +4764,7 @@ export default { } else { this.$message.warning('该航线暂无坐标数据,无法在地图展示'); } - this.wsConnection?.sendSyncRouteVisibility?.(route.id, true); + // 航线显隐仅本地生效,不同步给他人 } } catch (error) { console.error("获取航线详情失败:", error); @@ -4368,7 +4889,7 @@ export default { if (this.$refs.cesiumMap) { this.$refs.cesiumMap.removeRouteById(route.id); } - if (!fromPlanSwitch) this.wsConnection?.sendSyncRouteVisibility?.(route.id, false); + // 航线显隐仅本地生效,不同步给他人 if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) { if (this.activeRouteIds.length > 0) { const lastId = this.activeRouteIds[this.activeRouteIds.length - 1]; @@ -4396,7 +4917,7 @@ export default { } else { // 航线已隐藏,显示它 await this.selectRoute(route); - if (!fromPlanSwitch) this.wsConnection?.sendSyncRouteVisibility?.(route.id, true); + // 航线显隐仅本地生效,不同步给他人 } }, diff --git a/ruoyi-ui/src/views/childRoom/rollCallTemplate.js b/ruoyi-ui/src/views/childRoom/rollCallTemplate.js new file mode 100644 index 0000000..0b96872 --- /dev/null +++ b/ruoyi-ui/src/views/childRoom/rollCallTemplate.js @@ -0,0 +1,264 @@ +/** + * 点名小标题的默认文本框模板:6 个文本框,靠近中心平均分布 + * 每个约容纳 10 个字,字号较大 + */ +let idCounter = 0 +function genId() { + return 'rollcall_' + (++idCounter) + '_' + Date.now() +} + +const DEFAULT_FONT = { fontFamily: '微软雅黑', color: '#333333' } +const TITLE_TOP = 16 +const TITLE_FONT_SIZE = 32 +const OFFSET_RIGHT = 2 +const CONTENT_WIDTH_REDUCE = 4 +const TITLE_WIDTH = 160 +const TITLE_HEIGHT = 72 +const CONTENT_FONT_SIZE = 22 +const SUBTITLE_FONT_SIZE = 26 + +export function createRollCallTextBoxes(canvasWidth, canvasHeight) { + const w = canvasWidth || 800 + const h = canvasHeight || 500 + const cols = 2 + const rows = 3 + const boxWidth = 240 - CONTENT_WIDTH_REDUCE + const boxHeight = 52 + const gapX = 80 + const gapY = 48 + const groupWidth = cols * boxWidth + (cols - 1) * gapX + const groupHeight = rows * boxHeight + (rows - 1) * gapY + const startX = Math.max(0, (w - groupWidth) / 2) + OFFSET_RIGHT + const startY = Math.max(0, (h - groupHeight) / 2) + + const boxes = [] + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + boxes.push({ + id: genId(), + x: startX + col * (boxWidth + gapX), + y: startY + row * (boxHeight + gapY), + width: boxWidth, + height: boxHeight, + text: '', + placeholder: '请输入点名对象', + rotation: 0, + fontSize: 22, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }) + } + } + return boxes +} + +/** + * 通用小标题模板:上边标题框固定小标题名称,下边大内容区占位「请输入xxx内容」 + * 适用于:任务目标、自身任务、对接任务、相关规定、敌情、威胁判断、职责分工、第一次进度检查、第二次进度检查、集体协同 + */ +export function createSubTitleTemplate(subTitleName, canvasWidth, canvasHeight) { + const w = canvasWidth || 800 + const h = canvasHeight || 500 + + const padding = 40 + const gap = 12 + const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE + const contentHeight = 600 + const titleWidth = ['第一次进度检查', '第二次进度检查'].includes(subTitleName) ? TITLE_WIDTH * 2 : TITLE_WIDTH + + const titleX = padding + OFFSET_RIGHT + const titleY = TITLE_TOP + const contentX = padding + OFFSET_RIGHT + const contentY = titleY + TITLE_HEIGHT + gap + + return [ + { + id: genId(), + x: titleX, + y: titleY, + width: titleWidth, + height: TITLE_HEIGHT, + text: subTitleName, + placeholder: undefined, + rotation: 0, + fontSize: TITLE_FONT_SIZE, + fontWeight: 'bold', + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }, + { + id: genId(), + x: contentX, + y: contentY, + width: contentWidth, + height: contentHeight, + text: '', + placeholder: '请输入' + subTitleName + '内容', + rotation: 0, + fontSize: CONTENT_FONT_SIZE, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + } + ] +} + +/** 使用任务目标样式的子标题列表 */ +export const SUB_TITLE_TEMPLATE_NAMES = [ + '任务目标', '自身任务', '对接任务', '相关规定', '敌情', '威胁判断', + '职责分工', '第一次进度检查', '第二次进度检查', '集体协同' +] + +/** 仅左上角小标题框的子标题:产品生成、任务执行、评估 */ +export const SIMPLE_TITLE_NAMES = ['产品生成', '任务执行', '评估'] + +/** + * 仅左上角小标题框的模板 + */ +export function createSimpleTitleTemplate(subTitleName, canvasWidth, canvasHeight) { + const padding = 40 + + return [ + { + id: genId(), + x: padding + OFFSET_RIGHT, + y: TITLE_TOP, + width: TITLE_WIDTH, + height: TITLE_HEIGHT, + text: subTitleName, + placeholder: undefined, + rotation: 0, + fontSize: TITLE_FONT_SIZE, + fontWeight: 'bold', + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + } + ] +} + +/** + * 意图通报专用模板:标题框 + 中间提示框「明确每日任务目标,风险等级」+ 大内容区 + */ +export function createIntentBriefingTemplate(canvasWidth, canvasHeight) { + const w = canvasWidth || 800 + const h = canvasHeight || 500 + + const padding = 40 + const gap = 12 + const middleHeight = 62 + const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE + const contentHeight = 500 + + const titleX = padding + OFFSET_RIGHT + const titleY = TITLE_TOP + const middleX = padding + OFFSET_RIGHT + const middleY = titleY + TITLE_HEIGHT + gap + const contentX = padding + OFFSET_RIGHT + const contentY = middleY + middleHeight + gap + + return [ + { + id: genId(), + x: titleX, + y: titleY, + width: TITLE_WIDTH, + height: TITLE_HEIGHT, + text: '意图通报', + placeholder: undefined, + rotation: 0, + fontSize: TITLE_FONT_SIZE, + fontWeight: 'bold', + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }, + { + id: genId(), + x: middleX, + y: middleY, + width: 420 - CONTENT_WIDTH_REDUCE, + height: middleHeight, + text: '明确每日任务目标,风险等级', + placeholder: undefined, + rotation: 0, + fontSize: SUBTITLE_FONT_SIZE, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }, + { + id: genId(), + x: contentX, + y: contentY, + width: contentWidth, + height: contentHeight, + text: '', + placeholder: '请输入意图通报内容', + rotation: 0, + fontSize: CONTENT_FONT_SIZE, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + } + ] +} + +/** + * 任务规划专用模板:标题框「任务规划」+ 附标题「XX规划:XXXXX」+ 大内容区 + */ +export function createTaskPlanningTemplate(canvasWidth, canvasHeight) { + const w = canvasWidth || 800 + const h = canvasHeight || 500 + + const padding = 40 + const gap = 12 + const middleHeight = 62 + const contentWidth = Math.max(300, w - (padding * 2)) - CONTENT_WIDTH_REDUCE + const contentHeight = 500 + + const titleX = padding + OFFSET_RIGHT + const titleY = TITLE_TOP + const middleX = padding + OFFSET_RIGHT + const middleY = titleY + TITLE_HEIGHT + gap + const contentX = padding + OFFSET_RIGHT + const contentY = middleY + middleHeight + gap + + return [ + { + id: genId(), + x: titleX, + y: titleY, + width: TITLE_WIDTH, + height: TITLE_HEIGHT, + text: '任务规划', + placeholder: undefined, + rotation: 0, + fontSize: TITLE_FONT_SIZE, + fontWeight: 'bold', + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }, + { + id: genId(), + x: middleX, + y: middleY, + width: 240 - CONTENT_WIDTH_REDUCE, + height: middleHeight, + text: 'XX规划:XXXXX', + placeholder: undefined, + rotation: 0, + fontSize: SUBTITLE_FONT_SIZE, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + }, + { + id: genId(), + x: contentX, + y: contentY, + width: contentWidth, + height: contentHeight, + text: '', + placeholder: '请输入任务规划内容', + rotation: 0, + fontSize: CONTENT_FONT_SIZE, + fontFamily: DEFAULT_FONT.fontFamily, + color: DEFAULT_FONT.color + } + ] +} diff --git a/ruoyi-ui/src/views/childRoom/subContentPages.js b/ruoyi-ui/src/views/childRoom/subContentPages.js new file mode 100644 index 0000000..80617bf --- /dev/null +++ b/ruoyi-ui/src/views/childRoom/subContentPages.js @@ -0,0 +1,29 @@ +/** + * 小标题内容的多页结构支持 + * subContent 结构: { pages: [{ icons, textBoxes }], currentPageIndex: 0 } + * 兼容旧格式: { icons, textBoxes } 视为单页 + */ + +export function ensurePagesStructure(subContent) { + if (!subContent) return { pages: [{ icons: [], textBoxes: [] }], currentPageIndex: 0 } + if (subContent.pages && Array.isArray(subContent.pages)) { + return subContent + } + Object.assign(subContent, { + pages: [{ icons: subContent.icons || [], textBoxes: subContent.textBoxes || [] }], + currentPageIndex: 0 + }) + delete subContent.icons + delete subContent.textBoxes + return subContent +} + +export function getPageContent(subContent) { + const sc = ensurePagesStructure(subContent) + const page = sc.pages[sc.currentPageIndex || 0] + return page || { icons: [], textBoxes: [] } +} + +export function createEmptySubContent() { + return { pages: [{ icons: [], textBoxes: [] }], currentPageIndex: 0 } +} diff --git a/ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue b/ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue new file mode 100644 index 0000000..9f4a970 --- /dev/null +++ b/ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue b/ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue index c774ccd..1e03ff7 100644 --- a/ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue +++ b/ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue @@ -174,12 +174,8 @@ export default { this.$emit('import-airport', this.formData.airportPath); }, importRoute() { - if (!this.formData.routePath) { - this.$message.warning('请先选择航路数据文件'); - return; - } - this.$message.success('航路数据导入成功'); - this.$emit('import-route-data', this.formData.routePath); + // 统一由父组件打开导入航线弹窗,用户选择 JSON 文件后导入(浏览器无法读取本地路径) + this.$emit('import-route-data', this.formData.routePath || ''); }, importLandmark() { if (!this.formData.landmarkPath) { diff --git a/ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue b/ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue new file mode 100644 index 0000000..8c71c13 --- /dev/null +++ b/ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/ruoyi-ui/src/views/selectRoom/index.vue b/ruoyi-ui/src/views/selectRoom/index.vue index 15a3e34..92d884a 100644 --- a/ruoyi-ui/src/views/selectRoom/index.vue +++ b/ruoyi-ui/src/views/selectRoom/index.vue @@ -21,7 +21,7 @@
@@ -90,8 +90,9 @@ @@ -169,6 +170,25 @@ export default { computed: { getParentRooms() { return this.rooms.filter(room => room.parentId === null) + }, + /** 选中的房间对象 */ + selectedRoomObj() { + if (!this.selectedRoom) return null + return this.rooms.find(r => r.id === this.selectedRoom) + }, + /** 是否可进入:小房间直接进;大房间需有子房间才能进 */ + canEnterRoom() { + if (!this.selectedRoom || !this.selectedRoomObj) return false + if (this.selectedRoomObj.parentId != null) return true // 小房间 + return this.getChildRooms(this.selectedRoom).length > 0 // 大房间需有子房间 + }, + /** 进入按钮禁用时的提示 */ + enterRoomDisabledTip() { + if (!this.selectedRoom) return '请先选择房间' + if (!this.selectedRoomObj) return '' + if (this.selectedRoomObj.parentId != null) return '' + if (this.getChildRooms(this.selectedRoom).length === 0) return '该大房间暂无子房间,无法进入' + return '' } }, methods: { @@ -185,8 +205,9 @@ export default { }).catch(() => {}) }, getList() { - listRooms().then(response => { - this.rooms = response.rows || response; + // 传 pageSize 拉取全部房间,避免分页只返回前 10 条导致新建房间不显示 + listRooms({ pageNum: 1, pageSize: 9999 }).then(response => { + this.rooms = response.rows || response || []; }).catch(err => { console.error("加载列表失败,请检查后端接口权限", err); }); @@ -194,12 +215,21 @@ export default { getChildRooms(parentId) { return this.rooms.filter(room => room.parentId === parentId) }, + /** 大房间点击:第一次展开,第二次选中 */ + handleParentRoomClick(room) { + const isExpanded = this.expandedRooms.includes(room.id) + if (!isExpanded) { + this.expandedRooms.push(room.id) + } else { + this.selectedRoom = room.id + } + }, toggleRoomExpansion(room) { const index = this.expandedRooms.indexOf(room.id) if (index > -1) { this.expandedRooms.splice(index, 1) } else { - this.expandedRooms = [room.id] + this.expandedRooms.push(room.id) } }, selectRoom(room) {