menghao 3 weeks ago
parent
commit
47ccdea3aa
  1. 39
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  2. 63
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java
  3. 37
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  4. 124
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java
  5. 13
      ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java
  6. 34
      ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java
  7. 3
      ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml
  8. 5
      ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml
  9. 19
      ruoyi-ui/src/api/system/routes.js
  10. 43
      ruoyi-ui/src/api/system/whiteboard.js
  11. 4
      ruoyi-ui/src/lang/en.js
  12. 4
      ruoyi-ui/src/lang/zh.js
  13. 14
      ruoyi-ui/src/utils/imageUrl.js
  14. 4
      ruoyi-ui/src/utils/request.js
  15. 63
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  16. 53
      ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue
  17. 945
      ruoyi-ui/src/views/cesiumMap/index.vue
  18. 95
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  19. 185
      ruoyi-ui/src/views/childRoom/BottomTimeline.vue
  20. 593
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  21. 1170
      ruoyi-ui/src/views/childRoom/StepCanvasContent.vue
  22. 343
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  23. 100
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  24. 650
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue
  25. 579
      ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue
  26. 625
      ruoyi-ui/src/views/childRoom/index.vue
  27. 264
      ruoyi-ui/src/views/childRoom/rollCallTemplate.js
  28. 29
      ruoyi-ui/src/views/childRoom/subContentPages.js
  29. 237
      ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue
  30. 8
      ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue
  31. 240
      ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue
  32. 42
      ruoyi-ui/src/views/selectRoom/index.vue

39
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<String, Object> 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')")

63
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<Object> list = whiteboardRoomService.listWhiteboards(roomId);
return success(list);
}
/** 获取单个白板详情 */
@GetMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult get(@PathVariable Long roomId, @PathVariable String whiteboardId) {
Object wb = whiteboardRoomService.getWhiteboard(roomId, whiteboardId);
if (wb == null) return error("白板不存在");
return success(wb);
}
/** 创建白板 */
@PostMapping("/{roomId}/whiteboard")
public AjaxResult create(@PathVariable Long roomId, @RequestBody Object whiteboard) {
Object created = whiteboardRoomService.createWhiteboard(roomId, whiteboard);
return success(created);
}
/** 更新白板 */
@PutMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult update(@PathVariable Long roomId, @PathVariable String whiteboardId,
@RequestBody Object whiteboard) {
boolean ok = whiteboardRoomService.updateWhiteboard(roomId, whiteboardId, whiteboard);
return ok ? success() : error("更新失败");
}
/** 删除白板 */
@DeleteMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult delete(@PathVariable Long roomId, @PathVariable String whiteboardId) {
boolean ok = whiteboardRoomService.deleteWhiteboard(roomId, whiteboardId);
return ok ? success() : error("删除失败");
}
}

37
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<Long> visibleRouteIds = roomRoomStateService.getVisibleRouteIds(roomId);
if (visibleRouteIds != null && !visibleRouteIds.isEmpty()) {
Map<String, Object> 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<String, Object> 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<String, Object> 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);
// 小房间内每人展示各自内容,航线显隐不再同步给他人、不再持久化
}
/** 广播航点变更,供其他设备实时同步 */

124
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<String, Object> redisTemplate;
private String whiteboardsKey(Long roomId) {
return ROOM_WHITEBOARDS_PREFIX + roomId + ROOM_WHITEBOARDS_SUFFIX;
}
/** 获取房间下所有白板列表 */
@SuppressWarnings("unchecked")
public List<Object> listWhiteboards(Long roomId) {
if (roomId == null) return new ArrayList<>();
String key = whiteboardsKey(roomId);
Object raw = redisTemplate.opsForValue().get(key);
if (raw == null) return new ArrayList<>();
if (raw instanceof List) return (List<Object>) raw;
if (raw instanceof String) {
try {
return JSON.parseArray((String) raw);
} catch (Exception e) {
return new ArrayList<>();
}
}
return new ArrayList<>();
}
/** 获取单个白板详情 */
public Object getWhiteboard(Long roomId, String whiteboardId) {
List<Object> list = listWhiteboards(roomId);
for (Object item : list) {
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
if (whiteboardId.equals(String.valueOf(id))) return item;
}
}
return null;
}
/** 创建白板 */
public Object createWhiteboard(Long roomId, Object whiteboard) {
if (roomId == null || whiteboard == null) return null;
List<Object> list = listWhiteboards(roomId);
String id = UUID.randomUUID().toString();
Object wb = whiteboard;
if (wb instanceof java.util.Map) {
((java.util.Map<String, Object>) wb).put("id", id);
if (!((java.util.Map<String, Object>) wb).containsKey("name")) {
((java.util.Map<String, Object>) wb).put("name", "草稿");
}
if (!((java.util.Map<String, Object>) wb).containsKey("timeBlocks")) {
((java.util.Map<String, Object>) wb).put("timeBlocks", new ArrayList<String>());
}
if (!((java.util.Map<String, Object>) wb).containsKey("contentByTime")) {
((java.util.Map<String, Object>) wb).put("contentByTime", new java.util.HashMap<String, Object>());
}
}
list.add(wb);
saveWhiteboards(roomId, list);
return wb;
}
/** 更新白板 */
public boolean updateWhiteboard(Long roomId, String whiteboardId, Object whiteboard) {
if (roomId == null || whiteboardId == null || whiteboard == null) return false;
List<Object> list = listWhiteboards(roomId);
for (int i = 0; i < list.size(); i++) {
Object item = list.get(i);
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
if (whiteboardId.equals(String.valueOf(id))) {
if (whiteboard instanceof java.util.Map) {
((java.util.Map<String, Object>) whiteboard).put("id", whiteboardId);
}
list.set(i, whiteboard);
saveWhiteboards(roomId, list);
return true;
}
}
}
return false;
}
/** 删除白板 */
public boolean deleteWhiteboard(Long roomId, String whiteboardId) {
if (roomId == null || whiteboardId == null) return false;
List<Object> list = listWhiteboards(roomId);
boolean removed = list.removeIf(item -> {
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
return whiteboardId.equals(String.valueOf(id));
}
return false;
});
if (removed) saveWhiteboards(roomId, list);
return removed;
}
private void saveWhiteboards(Long roomId, List<Object> list) {
String key = whiteboardsKey(roomId);
redisTemplate.opsForValue().set(key, list, EXPIRE_HOURS, TimeUnit.HOURS);
}
}

13
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)

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

@ -47,6 +47,12 @@ public class Routes extends BaseEntity {
private List<RouteWaypoints> waypoints;
/** 方案ID列表(查询用,非持久化):传入时查询 scenario_id IN (...) */
private java.util.List<Long> scenarioIds;
/** 方案ID逗号分隔(查询用,便于前端传参):如 "1,2,3" */
private String scenarioIdsStr;
/** 关联的平台信息(仅用于 API 返回,非数据库字段) */
private java.util.Map<String, Object> platform;
@ -98,6 +104,34 @@ public class Routes extends BaseEntity {
this.waypoints = waypoints;
}
public java.util.List<Long> getScenarioIds() {
return scenarioIds;
}
public void setScenarioIds(java.util.List<Long> 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<Long> 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<String, Object> getPlatform() {
return platform;
}

3
ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml

@ -20,7 +20,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectMissionScenarioList" parameterType="MissionScenario" resultMap="MissionScenarioResult">
<include refid="selectMissionScenarioVo"/>
<where>
<if test="roomId != null "> and room_id = #{roomId}</if>
<if test="parentRoomId != null"> and room_id in (select id from ry.rooms where parent_id = #{parentRoomId})</if>
<if test="parentRoomId == null and roomId != null "> and room_id = #{roomId}</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="version != null and version != ''"> and version = #{version}</if>
<if test="frontendDrawings != null and frontendDrawings != ''"> and frontend_drawings = #{frontendDrawings}</if>

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

@ -20,7 +20,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectRoutesList" parameterType="Routes" resultMap="RoutesResult">
<include refid="selectRoutesVo"/>
<where>
<if test="scenarioId != null "> and scenario_id = #{scenarioId}</if>
<if test="scenarioIds != null and scenarioIds.size() > 0"> and scenario_id in
<foreach collection="scenarioIds" item="sid" open="(" separator="," close=")">#{sid}</foreach>
</if>
<if test="(scenarioIds == null or scenarioIds.size() == 0) and scenarioId != null "> and scenario_id = #{scenarioId}</if>
<if test="platformId != null "> and platform_id = #{platformId}</if>
<if test="callSign != null and callSign != ''"> and call_sign = #{callSign}</if>
<if test="attributes != null and attributes != ''"> and attributes = #{attributes}</if>

19
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({

43
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'
})
}

4
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',

4
ruoyi-ui/src/lang/zh.js

@ -21,7 +21,9 @@ export default {
importATO: '导入ATO',
importLayer: '导入图层',
importRoute: '导入航线',
export: '导出'
export: '导出',
exportRoute: '导出航线',
exportPlan: '导出计划'
},
edit: {
routeEdit: '航线编辑',

14
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
}

4
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)

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

@ -173,7 +173,7 @@
</div>
<!-- 空域图形调整位置 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'arrow'">
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'auxiliaryLine' || entityData.type === 'arrow'">
<div class="menu-title">位置</div>
<div class="menu-item" @click="handleAdjustPosition">
<span class="menu-icon">📍</span>
@ -275,6 +275,46 @@
</div>
</div>
<!-- 辅助线特有选项 -->
<div class="menu-section" v-if="entityData.type === 'auxiliaryLine'">
<div class="menu-title">辅助线属性</div>
<div class="menu-item" @click="toggleColorPicker('color')">
<span class="menu-icon">🎨</span>
<span>颜色</span>
<span class="menu-preview" :style="{backgroundColor: entityData.color}"></span>
</div>
<div class="color-picker-container" v-if="showColorPickerFor === 'color'">
<div class="color-grid">
<div
v-for="color in presetColors"
:key="color"
class="color-item"
:style="{backgroundColor: color}"
@click="selectColor('color', color)"
:class="{ active: entityData.color === color }"
></div>
</div>
</div>
<div class="menu-item" @click="toggleWidthPicker">
<span class="menu-icon">📏</span>
<span>线宽</span>
<span class="menu-value">{{ entityData.width }}px</span>
</div>
<div class="width-picker-container" v-if="showWidthPicker">
<div class="width-grid">
<div
v-for="width in presetWidths"
:key="width"
class="width-item"
@click="selectWidth(width)"
:class="{ active: entityData.width === width }"
>
{{ width }}px
</div>
</div>
</div>
</div>
<!-- 箭头特有选项 -->
<div class="menu-section" v-if="entityData.type === 'arrow'">
<div class="menu-title">箭头属性</div>
@ -364,9 +404,13 @@
</div>
</div>
<!-- 平台图标拖拽到地图的图标特有选项 -->
<div class="menu-section" v-if="entityData.type === 'platformIcon'">
<!-- 平台图标拖拽到地图的图标特有选项白板平台不显示探测区/威力区/航线 -->
<div class="menu-section" v-if="entityData.type === 'platformIcon' && !entityData.isWhiteboard">
<div class="menu-title">平台图标</div>
<div class="menu-item" @click="handleShowTransformBox">
<span class="menu-icon">🔄</span>
<span>显示伸缩框</span>
</div>
<div class="menu-item" @click="handleDetectionZonePlatform">
<span class="menu-icon">🔍</span>
<span>探测区</span>
@ -393,6 +437,15 @@
<span>在此之后插入航线</span>
</div>
</div>
<!-- 白板平台仅显示伸缩框用于旋转 -->
<div class="menu-section" v-if="entityData.type === 'platformIcon' && entityData.isWhiteboard">
<div class="menu-title">白板平台</div>
<div class="menu-item" @click="handleShowTransformBox">
<span class="menu-icon">🔄</span>
<span>显示伸缩框</span>
</div>
</div>
</div>
</template>
@ -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')
},

53
ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue

@ -13,6 +13,19 @@
<i v-else :class="item.icon"></i>
</div>
</div>
<!-- 辅助线水平/竖直约束选项仅空域模式且选中辅助线时显示 -->
<div v-if="toolMode === 'airspace' && drawingMode === 'auxiliaryLine'" class="auxiliary-constraint-row">
<div
v-for="opt in auxiliaryConstraintOptions"
:key="opt.value"
class="constraint-option"
:class="{ active: auxiliaryLineConstraint === opt.value }"
@click.stop="$emit('auxiliary-line-constraint', opt.value)"
:title="opt.label"
>
{{ opt.label }}
</div>
</div>
</div>
</template>
@ -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;

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

File diff suppressed because it is too large

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

@ -5,6 +5,7 @@
ref="timeline"
@timeline-hidden="onTimelineHidden"
:room-id="roomId"
:is-parent-room="isParentRoom"
/>
<div class="bottom-left-panel" v-show="showPanel">
@ -22,12 +23,17 @@
<i class="el-icon-s-operation"></i>
<span>六步法</span>
</div>
<div class="panel-item" @click="toggleWhiteboard">
<i class="el-icon-edit"></i>
<span>白板</span>
</div>
</div>
</div>
<!-- 六步法弹窗覆盖地图右侧栏为任务+1-6点击可切换 -->
<six-steps-overlay
v-if="showSixStepsBar"
ref="sixStepsOverlay"
:visible="showSixStepsOverlay"
:room-id="roomId"
:current-step-index="activeStepIndex"
@ -35,7 +41,10 @@
:override-title="taskBlockActive ? '任务' : null"
:six-steps-data="sixStepsData"
:task-block-active="taskBlockActive"
@close="hideSixStepsBar"
:initial-active-understanding-sub-index="savedProgress.activeUnderstandingSubIndex || 0"
:initial-active-task-sub-index="savedProgress.activeTaskSubIndex || 0"
:initial-active-step-sub-index="savedProgress.activeStepSubIndex || 0"
@close="onSixStepsClose"
@select-task="selectTask"
@select-step="selectStep"
/>
@ -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

185
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 + ']' : '')"
>
<div class="marker-dot"></div>
<div class="marker-tooltip">
<div class="tooltip-time">{{ segment.time }}</div>
<div class="tooltip-name">{{ segment.name }}</div>
<div v-if="segment.roomName" class="tooltip-room">{{ segment.roomName }}</div>
</div>
</div>
</div>
@ -107,6 +108,7 @@
<div class="segment-time">{{ segment.time }}</div>
<div class="segment-info">
<div class="segment-name">{{ segment.name }}</div>
<div v-if="segment.roomName" class="segment-room">{{ segment.roomName }}</div>
<div class="segment-desc">{{ segment.description }}</div>
</div>
<div class="segment-status">
@ -173,6 +175,10 @@
<label>名称</label>
<span>{{ currentSegment.name }}</span>
</div>
<div v-if="currentSegment.roomName" class="detail-row">
<label>所属房间</label>
<span>{{ currentSegment.roomName }}</span>
</div>
<div class="detail-row">
<label>描述</label>
<span>{{ currentSegment.description }}</span>
@ -194,6 +200,7 @@
<script>
import { getTimelineSegmentsByRoomId, addTimelineSegment, updateTimelineSegment, delTimelineSegment } from '@/api/system/timeline'
import { listRooms } from '@/api/system/rooms'
export default {
name: 'BottomTimeline',
@ -201,6 +208,11 @@ export default {
roomId: {
type: [Number, String],
default: null
},
/** 是否为大房间(parent_id 为 null),大房间时合并展示所有子房间的时间段 */
isParentRoom: {
type: Boolean,
default: false
}
},
data() {
@ -246,6 +258,9 @@ export default {
}
},
immediate: true
},
isParentRoom() {
if (this.roomId) this.loadTimelineSegments()
}
},
methods: {
@ -286,21 +301,26 @@ export default {
try {
this.isLoading = true
const response = await getTimelineSegmentsByRoomId(this.roomId)
if (response.code === 200 && response.data && response.data.length > 0) {
this.timelineSegments = response.data.map(item => ({
segmentId: item.segmentId,
time: item.segmentTime,
name: item.segmentName,
description: item.segmentDesc,
active: false,
passed: false,
position: 0,
triggered: false
}))
this.updateTimeline()
if (this.isParentRoom) {
await this.loadMergedTimelineSegments()
} else {
this.initDefaultSegments()
const response = await getTimelineSegmentsByRoomId(this.roomId)
if (response.code === 200 && response.data && response.data.length > 0) {
this.timelineSegments = response.data.map(item => ({
segmentId: item.segmentId,
time: item.segmentTime,
name: item.segmentName,
description: item.segmentDesc,
roomName: null,
active: false,
passed: false,
position: 0,
triggered: false
}))
this.updateTimeline()
} else {
this.initDefaultSegments()
}
}
} catch (error) {
console.error('加载时间轴数据失败:', error)
@ -310,6 +330,81 @@ export default {
}
},
/** 大房间时:查询所有子房间的时间段,相同时间的合并为一条,按时间排序,并标注所属子房间名称 */
async loadMergedTimelineSegments() {
const roomsRes = await listRooms({ pageNum: 1, pageSize: 999, parentId: this.roomId })
const childRooms = roomsRes.rows || roomsRes || []
if (childRooms.length === 0) {
this.initDefaultSegments()
return
}
const allSegments = []
for (const child of childRooms) {
try {
const res = await getTimelineSegmentsByRoomId(child.id)
if (res.code === 200 && res.data && res.data.length > 0) {
const roomName = child.name || `房间${child.id}`
res.data.forEach(item => {
allSegments.push({
segmentId: item.segmentId,
time: item.segmentTime,
name: item.segmentName,
description: item.segmentDesc,
roomName,
active: false,
passed: false,
position: 0,
triggered: false
})
})
}
} catch (_) {}
}
if (allSegments.length > 0) {
this.timelineSegments = this.mergeSegmentsByTime(allSegments)
this.updateTimeline()
} else {
this.initDefaultSegments()
}
},
/** 将相同时间的多条事件合并为一条,roomName 为所属房间名用顿号连接;名称不同时用顿号连接 */
mergeSegmentsByTime(segments) {
const byTime = {}
segments.forEach(seg => {
const t = seg.time
if (!byTime[t]) {
byTime[t] = []
}
byTime[t].push(seg)
})
const merged = []
Object.keys(byTime).forEach(t => {
const list = byTime[t]
if (list.length === 1) {
merged.push(list[0])
} else {
const roomNames = [...new Set(list.map(s => s.roomName))].join('、')
const names = [...new Set(list.map(s => s.name).filter(Boolean))]
const mergedName = names.length > 1 ? names.join('、') : (list[0].name || '')
const descs = [...new Set(list.map(s => s.description).filter(Boolean))]
const mergedDesc = descs.length > 1 ? descs.join(';') : (list[0].description || '')
merged.push({
segmentId: list[0].segmentId,
time: list[0].time,
name: mergedName,
description: mergedDesc,
roomName: roomNames,
active: false,
passed: false,
position: 0,
triggered: false
})
}
})
return merged.sort((a, b) => this.timeToSeconds(a.time) - this.timeToSeconds(b.time))
},
initDefaultTime() {
const now = new Date()
this.startTime = new Date(now)
@ -319,53 +414,7 @@ export default {
},
initDefaultSegments() {
this.timelineSegments = [
{
time: '08:00:00',
name: '任务准备',
description: '开始准备任务所需的资源和设备',
active: false,
passed: false,
position: 0,
triggered: false
},
{
time: '10:00:00',
name: '资源调配',
description: '完成资源的调配和分配',
active: false,
passed: false,
position: 0,
triggered: false
},
{
time: '12:00:00',
name: '任务执行',
description: '开始执行主要任务',
active: false,
passed: false,
position: 0,
triggered: false
},
{
time: '14:00:00',
name: '任务监控',
description: '监控任务执行进度',
active: false,
passed: false,
position: 0,
triggered: false
},
{
time: '16:00:00',
name: '任务完成',
description: '任务完成,进行总结',
active: false,
passed: false,
position: 0,
triggered: false
}
]
this.timelineSegments = []
this.updateTimeline()
},
@ -781,6 +830,18 @@ export default {
font-weight: 500;
}
.tooltip-room {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
.segment-room {
font-size: 12px;
color: #64748b;
margin-bottom: 2px;
}
.timeline-current {
position: absolute;
top: 0;

593
ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue

@ -16,6 +16,7 @@
class="header-sub-title"
:class="{ active: activeUnderstandingSubIndex === idx }"
@click="activeUnderstandingSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('understanding', idx, $event)"
>
{{ sub }}
</div>
@ -24,9 +25,15 @@
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!sixStepsSharedBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
@ -36,15 +43,67 @@
</el-dropdown-menu>
</el-dropdown>
</template>
<!-- 任务页插入按钮 -->
<el-dropdown v-else-if="overrideTitle === '任务'" trigger="click" @command="handleInsertCommand" class="header-insert">
<!-- 后五步判断规划准备执行评估小标题 + 插入按钮 -->
<template v-else-if="currentStepIndex >= 1 && currentStepIndex <= 5">
<div
v-for="(sub, idx) in getStepContent(currentStepIndex).subTitles"
:key="idx"
class="header-sub-title"
:class="{ active: activeStepSubIndex === idx }"
@click="activeStepSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('step', idx, $event)"
>
{{ sub }}
</div>
<el-dropdown trigger="click" @command="handleStepInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!sixStepsSharedBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
<el-dropdown-item command="textbox">
<i class="el-icon-edit-outline"></i> 文本框
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<!-- 任务页小标题 + 插入按钮 -->
<template v-else-if="overrideTitle === '任务'">
<div
v-for="(sub, idx) in taskSubTitles"
:key="idx"
class="header-sub-title"
:class="{ active: activeTaskSubIndex === idx }"
@click="activeTaskSubIndex = idx"
@contextmenu.prevent="onSubTitleContextMenu('task', idx, $event)"
>
{{ sub }}
</div>
<el-dropdown trigger="click" @command="handleInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="subTitle">
<i class="el-icon-menu"></i> 小标题
</el-dropdown-item>
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="removeBackground" :disabled="!taskPageBackground">
<i class="el-icon-delete"></i> 删除背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
@ -53,17 +112,26 @@
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</div>
</div>
<div class="overlay-body" :style="overlayBodyStyle">
<div class="overlay-content" :class="{ 'task-page': overrideTitle === '任务', 'understanding-page': currentStepIndex === 0 && !overrideTitle }">
<div class="overlay-content" :class="{
'task-page': overrideTitle === '任务',
'understanding-page': currentStepIndex === 0 && !overrideTitle,
'step-page': currentStepIndex >= 1 && currentStepIndex <= 5
}">
<!-- 任务页插入工具栏 + 可编辑画布 -->
<task-page-content
v-if="overrideTitle === '任务'"
ref="taskPage"
:room-id="roomId"
:background-image="taskPageBackground"
:task-sub-titles="taskSubTitles"
@background-change="taskPageBackground = $event"
@task-sub-titles-change="taskSubTitles = $event"
@save-request="debouncedSave"
@task-page-data="lastTaskPageData = $event; saveToRedis()"
class="task-page-body"
/>
<!-- 理解步骤4 子标题 + 可编辑画布 -->
@ -72,10 +140,23 @@
ref="understandingStep"
:background-image="sixStepsSharedBackground"
:active-sub-index="activeUnderstandingSubIndex"
:sub-titles="understandingSubTitles"
@background-change="sixStepsSharedBackground = $event"
@save-request="debouncedSave"
@understanding-data="lastUnderstandingData = $event; saveToRedis()"
class="understanding-page-body"
/>
<!-- 判断规划准备执行评估使用共享背景 -->
<!-- 判断规划准备执行评估可编辑画布六步共享背景 -->
<step-canvas-content
v-else-if="currentStepIndex >= 1 && currentStepIndex <= 5"
ref="stepCanvas"
:key="currentStepIndex"
:content="getStepContent(currentStepIndex)"
:active-sub-index="activeStepSubIndex"
:background-image="sixStepsSharedBackground"
@background-change="sixStepsSharedBackground = $event"
class="step-canvas-body"
/>
<div v-else class="blank-placeholder">
<i class="el-icon-document"></i>
<p>{{ currentStepTitle }} - 内容区域</p>
@ -84,6 +165,27 @@
</div>
</div>
</div>
<!-- 小标题右键菜单 -->
<div
v-if="subTitleContextMenu.visible"
ref="subTitleContextMenuRef"
class="sub-title-context-menu"
:style="{ left: subTitleContextMenu.x + 'px', top: subTitleContextMenu.y + 'px' }"
@click.stop
>
<div class="context-menu-item" @click="editSubTitle">
<i class="el-icon-edit"></i>
<span>编辑</span>
</div>
<div class="context-menu-item" @click="insertNewPage">
<i class="el-icon-document-add"></i>
<span>插入新的一页</span>
</div>
<div class="context-menu-item context-menu-item-danger" @click="deleteSubTitle">
<i class="el-icon-delete"></i>
<span>删除</span>
</div>
</div>
<!-- 右侧栏任务 + 1-6 垂直排列 -->
<div class="overlay-sidebar">
<div class="sidebar-steps">
@ -112,10 +214,22 @@
<script>
import TaskPageContent from './TaskPageContent.vue'
import UnderstandingStepContent from './UnderstandingStepContent.vue'
import StepCanvasContent from './StepCanvasContent.vue'
import {
createRollCallTextBoxes,
createSubTitleTemplate,
createIntentBriefingTemplate,
createTaskPlanningTemplate,
createSimpleTitleTemplate,
SUB_TITLE_TEMPLATE_NAMES,
SIMPLE_TITLE_NAMES
} from './rollCallTemplate'
import { ensurePagesStructure, createEmptySubContent } from './subContentPages'
import { getSixStepsData, saveSixStepsData, getTaskPageData } from '@/api/system/routes'
export default {
name: 'SixStepsOverlay',
components: { TaskPageContent, UnderstandingStepContent },
components: { TaskPageContent, UnderstandingStepContent, StepCanvasContent },
props: {
roomId: {
type: [Number, String],
@ -146,6 +260,9 @@ export default {
type: Boolean,
default: false
},
initialActiveUnderstandingSubIndex: { type: Number, default: 0 },
initialActiveTaskSubIndex: { type: Number, default: 0 },
initialActiveStepSubIndex: { type: Number, default: 0 },
draggable: {
type: Boolean,
default: true
@ -155,10 +272,108 @@ export default {
return {
taskPageBackground: null,
sixStepsSharedBackground: null,
understandingSubTitles: ['点名', '接收解析任务', 'XXXX', 'XXXX'],
activeUnderstandingSubIndex: 0
understandingSubTitles: ['点名', '任务目标', '自身任务', '对接任务'],
activeUnderstandingSubIndex: this.initialActiveUnderstandingSubIndex,
taskSubTitles: [],
activeTaskSubIndex: this.initialActiveTaskSubIndex,
activeStepSubIndex: this.initialActiveStepSubIndex,
stepContents: {},
subTitleContextMenu: {
visible: false,
x: 0,
y: 0,
target: null,
index: -1
},
_saveTimer: null,
lastTaskPageData: null,
lastUnderstandingData: null,
taskPageLoadComplete: false,
_isRestoring: false
}
},
created() {
this._isRestoring = this.initialActiveUnderstandingSubIndex !== 0 || this.initialActiveTaskSubIndex !== 0 || this.initialActiveStepSubIndex !== 0
},
watch: {
currentStepIndex(val) {
if (this._isRestoring) {
this.$nextTick(() => { this._isRestoring = false })
return
}
this.activeStepSubIndex = 0
if (val === 0 && !this.overrideTitle) {
this.$nextTick(() => {
if (this.$refs.understandingStep && this.lastUnderstandingData) {
this.$refs.understandingStep.loadFromData(this.lastUnderstandingData)
}
})
}
},
overrideTitle(val) {
if (this._isRestoring) {
this.$nextTick(() => { this._isRestoring = false })
return
}
this.activeTaskSubIndex = 0
if (val === '任务') {
this.taskPageLoadComplete = false
this.$nextTick(() => {
if (this.$refs.taskPage) {
if (this.lastTaskPageData) {
this.$refs.taskPage.loadFromData(this.lastTaskPageData)
}
this.loadFromRedis()
}
})
}
if (!val && this.currentStepIndex === 0) {
this.$nextTick(() => {
if (this.$refs.understandingStep) {
if (this.lastUnderstandingData) {
this.$refs.understandingStep.loadFromData(this.lastUnderstandingData)
}
this.loadFromRedis()
}
})
}
},
roomId: {
handler(val) {
if (val != null && this.visible) this.loadFromRedis()
},
immediate: false
},
visible: {
handler(val) {
if (val && this.roomId != null) {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = false
this.loadFromRedis()
}
},
immediate: false
},
taskPageBackground: { handler() { this.debouncedSave() }, deep: false },
sixStepsSharedBackground: { handler() { this.debouncedSave() }, deep: false },
understandingSubTitles: { handler() { this.debouncedSave() }, deep: true },
taskSubTitles: { handler() { this.debouncedSave() }, deep: true },
stepContents: { handler() { this.debouncedSave() }, deep: true }
},
mounted() {
document.addEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this.roomId != null && this.visible) {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = false
this.loadFromRedis()
}
},
beforeDestroy() {
document.removeEventListener('click', this.onDocumentClickForSubTitleMenu)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
},
computed: {
currentStepTitle() {
if (this.overrideTitle) return this.overrideTitle
@ -166,30 +381,337 @@ export default {
},
overlayBodyStyle() {
if (this.overrideTitle === '任务') return {}
if (this.currentStepIndex >= 1 && this.sixStepsSharedBackground) {
return {
backgroundImage: `url(${this.sixStepsSharedBackground})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}
}
if (this.currentStepIndex >= 1 && this.currentStepIndex <= 5) return {}
return {}
}
},
methods: {
getProgress() {
return {
activeUnderstandingSubIndex: this.activeUnderstandingSubIndex,
activeTaskSubIndex: this.activeTaskSubIndex,
activeStepSubIndex: this.activeStepSubIndex
}
},
close() {
this.$emit('close')
this.$emit('close', this.getProgress())
},
async loadFromRedis() {
if (this.roomId == null) return
try {
let res = await getSixStepsData({ roomId: this.roomId })
let data = res && res.data
if (!data) {
res = await getTaskPageData({ roomId: this.roomId })
data = res && res.data
if (data) {
const tp = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (tp) data = { taskPage: tp }
}
}
if (!data) return
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw) return
if (raw.taskPage) {
this.taskPageBackground = raw.taskPage.background || null
if (Array.isArray(raw.taskPage.taskSubTitles)) this.taskSubTitles = raw.taskPage.taskSubTitles
this.lastTaskPageData = raw.taskPage
this.taskPageLoadComplete = true
this.$nextTick(() => {
if (this.$refs.taskPage) this.$refs.taskPage.loadFromData(raw.taskPage)
})
} else if (this.overrideTitle === '任务') {
this.taskPageLoadComplete = true
}
if (raw.sixStepsSharedBackground) this.sixStepsSharedBackground = raw.sixStepsSharedBackground
if (Array.isArray(raw.understanding?.subTitles)) this.understandingSubTitles = raw.understanding.subTitles
if (raw.understanding?.subContents) {
this.lastUnderstandingData = raw.understanding
this.$nextTick(() => {
if (this.$refs.understandingStep) this.$refs.understandingStep.loadFromData(raw.understanding)
})
}
if (raw.steps && typeof raw.steps === 'object') {
Object.keys(raw.steps).forEach(k => {
const step = raw.steps[k]
if (step && (step.subTitles || step.subContents)) {
this.$set(this.stepContents, parseInt(k, 10), {
subTitles: step.subTitles || [],
subContents: (step.subContents || []).map(sc => {
ensurePagesStructure(sc)
return sc
})
})
}
})
}
} catch (e) {
console.warn('SixSteps loadFromRedis failed:', e)
} finally {
if (this.overrideTitle === '任务') this.taskPageLoadComplete = true
}
},
saveToRedis() {
if (this.roomId == null) return
let taskPageData
if (this.$refs.taskPage) {
if (this.taskPageLoadComplete) {
taskPageData = this.lastTaskPageData = this.$refs.taskPage.getDataForSave()
} else {
taskPageData = this.lastTaskPageData || { background: this.taskPageBackground, icons: [], textBoxes: [], taskSubTitles: this.taskSubTitles }
}
} else {
taskPageData = this.lastTaskPageData || { background: this.taskPageBackground, icons: [], textBoxes: [], taskSubTitles: this.taskSubTitles }
}
const payload = {
taskPage: taskPageData,
sixStepsSharedBackground: this.sixStepsSharedBackground,
understanding: (() => {
if (this.$refs.understandingStep) {
this.lastUnderstandingData = { subTitles: this.understandingSubTitles, subContents: this.$refs.understandingStep.getDataForSave() }
}
return {
subTitles: this.understandingSubTitles,
subContents: this.lastUnderstandingData?.subContents || []
}
})(),
steps: {}
}
Object.keys(this.stepContents).forEach(k => {
const step = this.stepContents[k]
if (step && (step.subTitles || step.subContents)) {
payload.steps[k] = {
subTitles: step.subTitles || [],
subContents: (step.subContents || []).map(sc => {
ensurePagesStructure(sc)
return {
pages: (sc.pages || []).map(p => ({
icons: (p.icons || []).map(i => ({ id: i.id, x: i.x, y: i.y, width: i.width, height: i.height, rotation: i.rotation || 0, src: i.src })),
textBoxes: (p.textBoxes || []).map(t => ({ id: t.id, x: t.x, y: t.y, width: t.width, height: t.height, text: t.text || '', placeholder: t.placeholder, rotation: t.rotation || 0, fontSize: t.fontSize, fontFamily: t.fontFamily, color: t.color, fontWeight: t.fontWeight }))
})),
currentPageIndex: sc.currentPageIndex || 0
}
})
}
}
})
saveSixStepsData({
roomId: this.roomId,
data: JSON.stringify(payload)
}).catch(e => {
console.warn('SixSteps saveToRedis failed:', e)
})
},
debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
this.saveToRedis()
}, 300)
},
getStepContent(stepIndex) {
let step = this.stepContents[stepIndex]
if (step) {
if (!step.subContents && step.subTitles) {
step.subContents = step.subTitles.map(() => createEmptySubContent())
if (step.icons?.length || step.textBoxes?.length) {
step.subContents[0] = createEmptySubContent()
step.subContents[0].pages[0] = { icons: step.icons || [], textBoxes: step.textBoxes || [] }
}
}
return step
}
{
const defaultSubTitles = stepIndex === 1
? ['相关规定', '敌情', '意图通报', '威胁判断']
: stepIndex === 2
? ['任务规划', '职责分工', '点名', '第一次进度检查', '点名', '第二次进度检查', '点名', '产品生成']
: stepIndex === 3
? ['点名', '集体协同']
: stepIndex === 4
? ['任务执行']
: stepIndex === 5
? ['评估']
: []
this.$set(this.stepContents, stepIndex, {
subTitles: defaultSubTitles,
subContents: defaultSubTitles.map(() => createEmptySubContent())
})
return this.stepContents[stepIndex]
}
},
async addSubTitle(target) {
try {
const { value } = await this.$prompt('请输入小标题名称', '插入小标题', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: '新标题',
inputPattern: /\S+/,
inputErrorMessage: '请输入小标题名称'
})
if (value && value.trim()) {
if (target === 'understanding') {
this.understandingSubTitles.push(value.trim())
} else if (target === 'task') {
this.taskSubTitles.push(value.trim())
} else if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
step.subTitles.push(value.trim())
step.subContents.push(createEmptySubContent())
}
}
} catch (_) {}
},
handleInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('task')
return
}
if (this.$refs.taskPage) {
this.$refs.taskPage.handleInsertCommand(cmd)
}
},
handleUnderstandingInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('understanding')
return
}
if (this.$refs.understandingStep) {
this.$refs.understandingStep.handleInsertCommand(cmd)
}
},
handleStepInsertCommand(cmd) {
if (cmd === 'subTitle') {
this.addSubTitle('step')
return
}
if (this.$refs.stepCanvas) {
this.$refs.stepCanvas.handleInsertCommand(cmd)
}
},
onSubTitleContextMenu(target, index, event) {
const arr = target === 'understanding' ? this.understandingSubTitles
: target === 'task' ? this.taskSubTitles
: this.getStepContent(this.currentStepIndex).subTitles
const sourceName = arr && arr[index] ? arr[index] : ''
this.subTitleContextMenu = {
visible: true,
x: event.clientX,
y: event.clientY,
target,
index,
sourceName
}
},
closeSubTitleContextMenu() {
this.subTitleContextMenu.visible = false
},
onDocumentClickForSubTitleMenu(e) {
if (!this.subTitleContextMenu.visible) return
const el = this.$refs.subTitleContextMenuRef
if (el && el.contains(e.target)) return
this.closeSubTitleContextMenu()
},
getSubTitleArray(target) {
const t = target ?? this.subTitleContextMenu?.target
if (t === 'understanding') return this.understandingSubTitles
if (t === 'task') return this.taskSubTitles
if (t === 'step') return this.getStepContent(this.currentStepIndex).subTitles
return []
},
async editSubTitle() {
const { target, index } = this.subTitleContextMenu
const arr = this.getSubTitleArray(target)
this.closeSubTitleContextMenu()
if (index < 0 || index >= arr.length) return
try {
const { value } = await this.$prompt('请输入小标题名称', '编辑小标题', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: arr[index],
inputPattern: /\S+/,
inputErrorMessage: '请输入小标题名称'
})
if (value && value.trim()) {
this.$set(arr, index, value.trim())
}
} catch (_) {}
},
getTemplateBoxes(sourceName, newName, canvas) {
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
if (sourceName === '点名') return createRollCallTextBoxes(w, h)
if (sourceName === '意图通报') return createIntentBriefingTemplate(w, h)
if (sourceName === '任务规划') return createTaskPlanningTemplate(w, h)
if (SIMPLE_TITLE_NAMES.includes(sourceName)) return createSimpleTitleTemplate(newName, w, h)
if (SUB_TITLE_TEMPLATE_NAMES.includes(sourceName)) return createSubTitleTemplate(newName, w, h)
return []
},
async insertNewPage() {
const { target, index, sourceName } = this.subTitleContextMenu
this.closeSubTitleContextMenu()
if (!sourceName) return
const subIdx = index
if (target === 'task') {
this.$message.info('任务页暂不支持多页')
return
}
if (target === 'understanding') {
this.$nextTick(() => {
const canvas = this.$refs.understandingStep?.$refs?.canvas
const boxes = this.getTemplateBoxes(sourceName, sourceName, canvas)
if (this.$refs.understandingStep) {
this.$refs.understandingStep.addPageToSubIndex(subIdx, boxes)
}
})
} else if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
const sub = step.subContents[subIdx]
if (!sub) return
ensurePagesStructure(sub)
const canvas = this.$refs.stepCanvas?.$refs?.canvas
const boxes = this.getTemplateBoxes(sourceName, sourceName, canvas)
sub.pages.push({ icons: [], textBoxes: [...boxes] })
sub.currentPageIndex = sub.pages.length - 1
}
},
deleteSubTitle() {
const { target, index } = { ...this.subTitleContextMenu }
this.closeSubTitleContextMenu()
const arr = this.getSubTitleArray(target)
if (index < 0 || index >= arr.length) return
arr.splice(index, 1)
if (target === 'step') {
const step = this.getStepContent(this.currentStepIndex)
if (step.subContents && step.subContents.length > index) {
step.subContents.splice(index, 1)
}
}
if (target === 'understanding') {
if (this.activeUnderstandingSubIndex >= arr.length && arr.length > 0) {
this.activeUnderstandingSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeUnderstandingSubIndex = 0
} else if (this.activeUnderstandingSubIndex > index) {
this.activeUnderstandingSubIndex--
}
} else if (target === 'task') {
if (this.activeTaskSubIndex >= arr.length && arr.length > 0) {
this.activeTaskSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeTaskSubIndex = 0
} else if (this.activeTaskSubIndex > index) {
this.activeTaskSubIndex--
}
} else if (target === 'step') {
if (this.activeStepSubIndex >= arr.length && arr.length > 0) {
this.activeStepSubIndex = arr.length - 1
} else if (arr.length === 0) {
this.activeStepSubIndex = 0
} else if (this.activeStepSubIndex > index) {
this.activeStepSubIndex--
}
}
}
}
}
@ -353,6 +875,7 @@ export default {
display: flex;
align-items: center;
gap: 16px;
margin-left: 2px;
}
.header-insert {
@ -401,13 +924,15 @@ export default {
height: 100%;
}
.overlay-content.understanding-page {
.overlay-content.understanding-page,
.overlay-content.step-page {
padding: 0;
height: 100%;
}
.task-page-body,
.understanding-page-body {
.understanding-page-body,
.step-canvas-body {
height: 100%;
}
@ -436,4 +961,36 @@ export default {
font-size: 12px;
color: #cbd5e1;
}
.sub-title-context-menu {
position: fixed;
z-index: 10000;
min-width: 120px;
padding: 4px 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.sub-title-context-menu .context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
color: #1e293b;
cursor: pointer;
transition: background 0.2s;
}
.sub-title-context-menu .context-menu-item:hover {
background: rgba(0, 138, 255, 0.08);
color: #008aff;
}
.sub-title-context-menu .context-menu-item-danger:hover {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
}
</style>

1170
ruoyi-ui/src/views/childRoom/StepCanvasContent.vue

File diff suppressed because it is too large

343
ruoyi-ui/src/views/childRoom/TaskPageContent.vue

@ -64,23 +64,23 @@
@mousedown="onTextBoxMouseDown(box, $event)"
>
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div>
<!-- Office 风格格式工具栏选中时显示在文本框上方 -->
<div v-if="selectedId === box.id" class="textbox-format-toolbar" @mousedown.stop>
<el-select v-model="box.fontFamily" size="mini" placeholder="字体" class="format-font" @change="debouncedSave">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="box.fontSize" size="mini" placeholder="字号" class="format-size" @change="debouncedSave">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="box.color" class="format-color" @change="debouncedSave" />
<div class="textbox-input-wrapper">
<div
v-if="box.placeholder && !box.text && focusedBoxId !== box.id"
class="textbox-placeholder"
:style="getTextBoxPlaceholderStyle(box)"
@mousedown="focusTextBox(box, $event)"
>{{ box.placeholder }}</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@focus="onTextBoxFocus(box)"
@blur="onTextBoxBlur(box, $event)"
@mousedown.stop="selectedId = box.id"
@contextmenu="onTextBoxContextMenu(box, $event)"
></div>
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
@ -103,11 +103,27 @@
:style="drawingTextBoxStyle"
></div>
</div>
<!-- 格式工具栏仅选中文字后右键时显示固定定位在右键位置 -->
<div
v-if="formatToolbarBox"
class="textbox-format-toolbar-fixed"
:style="{ left: formatToolbarPosition.x + 'px', top: formatToolbarPosition.y + 'px' }"
@mousedown.stop
>
<el-select v-model="formatToolbarBox.fontFamily" size="mini" placeholder="字体" class="format-font" @change="debouncedSave">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="formatToolbarBox.fontSize" size="mini" placeholder="字号" class="format-size" @change="debouncedSave">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="formatToolbarBox.color" class="format-color" @change="debouncedSave" />
</div>
</div>
</template>
<script>
import { getTaskPageData, saveTaskPageData } from '@/api/system/routes'
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
let idCounter = 0
function genId() {
@ -117,6 +133,8 @@ function genId() {
const FONT_OPTIONS = ['宋体', '黑体', '微软雅黑', '楷体', '仿宋', 'Arial', 'Times New Roman', 'Verdana', 'Georgia']
const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
const DEFAULT_FONT = { fontSize: 14, fontFamily: '微软雅黑', color: '#333333' }
const MAIN_TITLE_ID = 'task_default_main_title'
const SUB_TITLE_ID = 'task_default_sub_title'
export default {
name: 'TaskPageContent',
@ -128,6 +146,10 @@ export default {
backgroundImage: {
type: String,
default: null
},
taskSubTitles: {
type: Array,
default: () => []
}
},
data() {
@ -147,36 +169,55 @@ export default {
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null
}
},
watch: {
backgroundImage: { handler() { this.debouncedSave() }, immediate: false },
icons: { handler() { this.debouncedSave() }, deep: true },
textBoxes: { handler() { this.debouncedSave() }, deep: true }
textBoxes: { handler() { this.debouncedSave() }, deep: true },
taskSubTitles: { handler() { this.debouncedSave() }, deep: true }
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
this._clickHandler = (e) => {
if (this.formatToolbarBoxId && !e.target.closest('.textbox-format-toolbar-fixed')) {
this.formatToolbarBoxId = null
}
}
document.addEventListener('keydown', this._keydownHandler)
this.loadFromRedis()
this.$nextTick(() => this.syncTextBoxContent())
document.addEventListener('click', this._clickHandler)
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
this.syncFromDomToData()
this.$emit('task-page-data', this.getDataForSave())
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
this.$emit('save-request')
},
computed: {
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
return this.textBoxes.find(t => t.id === this.formatToolbarBoxId) || null
},
canvasStyle() {
const style = {}
if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})`
style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
@ -198,6 +239,10 @@ export default {
},
methods: {
handleInsertCommand(cmd) {
if (cmd === 'removeBackground') {
this.$emit('background-change', null)
return
}
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
@ -207,16 +252,24 @@ export default {
this.$refs.iconImageInput.click()
}
},
handleBackgroundSelect(e) {
async handleBackgroundSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null
e.target.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const res = await request.post('/common/upload', formData)
if (res && (res.code === 200 || res.fileName)) {
const path = res.fileName || res.url
if (path) this.$emit('background-change', path)
} else {
this.$message.error(res?.msg || '背景图上传失败')
}
} catch (err) {
this.$message.error(err?.response?.data?.msg || err?.message || '背景图上传失败')
}
},
handleIconImageSelect(e) {
const file = e.target.files?.[0]
@ -301,6 +354,32 @@ export default {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
focusTextBox(box, e) {
e.preventDefault()
e.stopPropagation()
this.focusedBoxId = box.id
this.selectedId = box.id
this.$nextTick(() => {
const wrapper = e.target.closest('.textbox-input-wrapper')
const input = wrapper && wrapper.querySelector('.textbox-input')
if (input) input.focus()
})
},
onTextBoxFocus(box) {
this.focusedBoxId = box.id
},
onTextBoxBlur(box, e) {
box.text = (e.target.innerText || '').trim()
this.focusedBoxId = null
},
onTextBoxContextMenu(box, e) {
const sel = window.getSelection()
const hasSelection = sel && sel.toString().trim().length > 0
if (!hasSelection) return
e.preventDefault()
this.formatToolbarBoxId = box.id
this.formatToolbarPosition = { x: e.clientX, y: e.clientY }
},
selectElement(id, e) {
this.selectedId = id
const icon = this.icons.find(i => i.id === id)
@ -467,6 +546,13 @@ export default {
color: box.color || DEFAULT_FONT.color
}
},
getTextBoxPlaceholderStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: '#999'
}
},
deleteIcon(id) {
const idx = this.icons.findIndex(i => i.id === id)
if (idx >= 0) this.icons.splice(idx, 1)
@ -482,55 +568,101 @@ export default {
}
})
},
async loadFromRedis() {
if (this.roomId == null) return
try {
const res = await getTaskPageData({ roomId: this.roomId })
let data = res && res.data
if (!data) return
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (_) {
return
}
}
const raw = data
if (raw.icons && Array.isArray(raw.icons)) {
this.icons = raw.icons.map(i => ({
id: i.id || genId(),
x: Number(i.x) || 0,
y: Number(i.y) || 0,
width: Number(i.width) || 60,
height: Number(i.height) || 60,
rotation: Number(i.rotation) || 0,
src: i.src || ''
}))
}
if (raw.textBoxes && Array.isArray(raw.textBoxes)) {
this.textBoxes = raw.textBoxes.map(t => ({
id: t.id || genId(),
syncFromDomToData() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
this.textBoxes.forEach((box, idx) => {
const el = inputs[idx]
if (el) box.text = (el.innerText || '').trim()
})
},
ensureDefaultTitleBoxes() {
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
const hasMain = this.textBoxes.some(t => t.id === MAIN_TITLE_ID)
const hasSub = this.textBoxes.some(t => t.id === SUB_TITLE_ID)
if (!hasMain) {
this.textBoxes.unshift({
id: MAIN_TITLE_ID,
x: Math.max(0, (w - 600) / 2) + 2,
y: Math.max(0, (h - 180) / 2 - 55),
width: 600,
height: 90,
text: '',
placeholder: '在此输入大标题',
rotation: 0,
fontSize: 56,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
if (!hasSub) {
const insertIdx = this.textBoxes.findIndex(t => t.id === MAIN_TITLE_ID)
const at = insertIdx >= 0 ? insertIdx + 1 : 0
this.textBoxes.splice(at, 0, {
id: SUB_TITLE_ID,
x: Math.max(0, (w - 500) / 2) + 2,
y: Math.max(0, (h - 180) / 2 + 55),
width: 500,
height: 52,
text: '',
placeholder: '在此输入副标题',
rotation: 0,
fontSize: 20,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
}
},
loadFromData(data) {
if (!data) return
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw) return
if (raw.icons && Array.isArray(raw.icons)) {
this.icons = raw.icons.map(i => ({
id: i.id || genId(),
x: Number(i.x) || 0,
y: Number(i.y) || 0,
width: Number(i.width) || 60,
height: Number(i.height) || 60,
rotation: Number(i.rotation) || 0,
src: i.src || ''
}))
}
if (raw.textBoxes && Array.isArray(raw.textBoxes)) {
this.textBoxes = raw.textBoxes.map(t => {
const id = t.id || genId()
const isMain = id === MAIN_TITLE_ID
const isSub = id === SUB_TITLE_ID
return {
id,
x: Number(t.x) || 0,
y: Number(t.y) || 0,
width: Number(t.width) || 100,
height: Number(t.height) || 60,
width: Number(t.width) || (isMain ? 600 : isSub ? 500 : 100),
height: Number(t.height) || (isMain ? 90 : isSub ? 52 : 60),
text: String(t.text || ''),
placeholder: isMain ? '在此输入大标题' : isSub ? '在此输入副标题' : undefined,
rotation: Number(t.rotation) || 0,
fontSize: Number(t.fontSize) || DEFAULT_FONT.fontSize,
fontSize: Number(t.fontSize) || (isMain ? 56 : isSub ? 20 : DEFAULT_FONT.fontSize),
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}))
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
} catch (e) {
console.warn('TaskPage loadFromRedis failed:', e)
}
})
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
if (raw.taskSubTitles && Array.isArray(raw.taskSubTitles)) {
this.$emit('task-sub-titles-change', raw.taskSubTitles)
}
this.$nextTick(() => {
this.ensureDefaultTitleBoxes()
this.syncTextBoxContent()
})
},
saveToRedis() {
if (this.roomId == null) return
const payload = {
getDataForSave() {
return {
background: this.backgroundImage || null,
icons: this.icons.map(i => ({
id: i.id,
@ -548,24 +680,20 @@ export default {
width: t.width,
height: t.height,
text: t.text || '',
placeholder: t.placeholder || undefined,
rotation: t.rotation || 0,
fontSize: t.fontSize || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}))
})),
taskSubTitles: this.taskSubTitles || []
}
saveTaskPageData({
roomId: this.roomId,
data: JSON.stringify(payload)
}).catch(e => {
console.warn('TaskPage saveToRedis failed:', e)
})
},
debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
this.saveToRedis()
this.$emit('save-request')
}, 300)
},
deleteTextBox(id) {
@ -793,32 +921,24 @@ export default {
}
/* Office 风格格式工具栏:选中文本框时显示在上方 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
/* Office 风格格式工具栏:仅选中文字右键时显示,固定定位在右键位置 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
}
.format-font {
width: 120px;
}
.format-size {
width: 70px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
margin-top: 4px;
}
.format-color {
vertical-align: middle;
}
.format-color ::v-deep .el-color-picker__trigger {
.textbox-format-toolbar-fixed .format-font { width: 120px; }
.textbox-format-toolbar-fixed .format-size { width: 70px; }
.textbox-format-toolbar-fixed .format-color { vertical-align: middle; }
.textbox-format-toolbar-fixed .format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
@ -834,12 +954,35 @@ export default {
background: transparent;
}
.textbox-input {
.textbox-input-wrapper {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
}
.textbox-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
outline: none;
overflow: hidden;
background: transparent;
color: #999;
cursor: text;
pointer-events: auto;
}
.textbox-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
@ -847,6 +990,10 @@ export default {
background: transparent;
color: #333;
}
.textbox-input::selection {
background: #c8c8c8;
color: inherit;
}
.drawing-textbox {
position: absolute;

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

@ -56,7 +56,20 @@
</el-dropdown>
</el-dropdown-item>
<el-dropdown-item @click.native="exportPlan">{{ $t('topHeader.file.export') }}</el-dropdown-item>
<el-dropdown-item class="submenu-item">
<span>{{ $t('topHeader.file.export') }}</span>
<el-dropdown
trigger="hover"
placement="right-start"
class="submenu-dropdown"
>
<div class="submenu-trigger"></div>
<el-dropdown-menu slot="dropdown" class="submenu">
<el-dropdown-item @click.native="exportRoute">{{ $t('topHeader.file.exportRoute') }}</el-dropdown-item>
<el-dropdown-item @click.native="exportPlan">{{ $t('topHeader.file.exportPlan') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@ -255,12 +268,25 @@
<div
class="info-box combat-time-box"
:class="{ 'clickable': true }"
@click="$emit('set-k-time')"
:class="{ 'clickable': !(childRoomKTimes && childRoomKTimes.length > 0) && canSetKTime }"
>
<i class="el-icon-timer info-icon"></i>
<div class="info-content combat-time-content">
<div v-if="kTimeDisplay" class="combat-k-time">
<template v-if="childRoomKTimes && childRoomKTimes.length > 0">
<el-dropdown trigger="click" @command="handleSelectChildKTime" class="k-time-dropdown">
<div class="combat-k-time k-time-selectable">
<span class="k-time-label">{{ currentChildKTimeItem.name }}:</span>
<span class="k-time-value">{{ formatChildKTime(currentChildKTimeItem.kAnchorTime) }}</span>
<i class="el-icon-arrow-down k-time-arrow"></i>
</div>
<el-dropdown-menu slot="dropdown" class="k-time-dropdown-menu">
<el-dropdown-item v-for="(item, idx) in childRoomKTimes" :key="idx" :command="idx">
{{ item.name }}: {{ formatChildKTime(item.kAnchorTime) }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<div v-else-if="kTimeDisplay" class="combat-k-time" @click="canSetKTime && $emit('set-k-time')">
<span class="k-time-label">K </span>
<span class="k-time-value">{{ kTimeDisplay }}</span>
</div>
@ -334,11 +360,21 @@ export default {
type: String,
default: 'K+01:30:45'
},
/** 格式化的 K 时基准时刻,如 "2025-02-06 08:00:00" */
/** 格式化的 K 时基准时刻,如 "2025-02-06 08:00:00";小房间时使用 */
kTimeDisplay: {
type: String,
default: ''
},
/** 大房间时多个子房间的 K 时,格式 [{ name, kAnchorTime }],有值时优先展示此列表 */
childRoomKTimes: {
type: Array,
default: () => []
},
/** 大房间时当前选中的子房间索引 */
selectedChildKTimeIndex: {
type: Number,
default: 0
},
astroTime: {
type: String,
default: ''
@ -388,10 +424,17 @@ export default {
}
},
computed: {
/** 显示数据库房间名,无则回退为房间编号 */
/** 大房间时当前选中的子房间 K 时项 */
currentChildKTimeItem() {
if (!this.childRoomKTimes || this.childRoomKTimes.length === 0) return { name: '-', kAnchorTime: null };
const idx = Math.max(0, Math.min(this.selectedChildKTimeIndex, this.childRoomKTimes.length - 1));
return this.childRoomKTimes[idx];
},
/** 显示数据库房间名,无则回退为房间编号;大房间时追加标识 */
roomDisplayName() {
if (this.roomDetail && this.roomDetail.name) return this.roomDetail.name;
return this.roomCode;
const name = (this.roomDetail && this.roomDetail.name) ? this.roomDetail.name : this.roomCode;
const isParentRoom = this.roomDetail && this.roomDetail.parentId == null;
return isParentRoom ? `${name}(大房间)` : name;
},
topNavItems() {
return [
@ -467,6 +510,9 @@ export default {
exportRoute() {
this.$emit('export-routes')
},
exportPlan() {
this.$emit('export-plan')
},
@ -624,6 +670,22 @@ export default {
this.$emit('show-online-members')
},
handleSelectChildKTime(idx) {
this.$emit('select-child-k-time', idx);
},
formatChildKTime(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return '-'
const y = d.getFullYear()
const m = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
const h = d.getHours().toString().padStart(2, '0')
const min = d.getMinutes().toString().padStart(2, '0')
const s = d.getSeconds().toString().padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}:${s}`
},
//
savePowerZone(powerZone) {
this.$emit('save-power-zone', powerZone)
@ -953,8 +1015,30 @@ export default {
.combat-time-box {
min-width: 180px;
}
.k-time-dropdown {
width: 100%;
}
.k-time-selectable {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
}
.k-time-selectable:hover {
color: #008aff;
}
.k-time-arrow {
font-size: 12px;
margin-left: 4px;
color: #008aff;
}
.k-time-dropdown-menu .el-dropdown-menu__item {
padding: 8px 16px;
}
.combat-time-content {
gap: 6px;
flex-wrap: wrap;
}
.combat-k-time {
display: flex;

650
ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue

@ -1,5 +1,63 @@
<template>
<div class="understanding-step-content">
<div
class="understanding-step-content"
@mousemove="onPaginationAreaMouseMove"
@mouseleave="onPaginationAreaMouseLeave"
>
<!-- 翻页控件多页时鼠标悬停左右边缘才显示 -->
<div
v-show="currentPageCount > 1 && showLeftPagination"
class="pagination-left"
@click="canPrevPage && prevPage()"
>
<div class="pagination-btn pagination-btn-circle" :class="{ disabled: !canPrevPage }">
<i class="el-icon-arrow-left"></i>
</div>
</div>
<div
v-show="currentPageCount > 1 && showRightPagination"
class="pagination-right"
@click="canNextPage && nextPage()"
>
<div class="pagination-btn pagination-btn-circle" :class="{ disabled: !canNextPage }">
<i class="el-icon-arrow-right"></i>
</div>
</div>
<!-- 页码指示多页时可点击预览并删除页面 -->
<el-popover
v-if="currentPageCount > 1"
ref="pagePreviewPopover"
placement="top"
width="320"
trigger="click"
popper-class="page-preview-popover"
>
<div slot="reference" class="pagination-indicator pagination-indicator-clickable">
{{ currentPageIndex + 1 }} / {{ currentPageCount }}
</div>
<div class="page-preview-list">
<div class="page-preview-title">本小标题下的所有页面</div>
<div
v-for="(page, idx) in allPagesForPreview"
:key="idx"
class="page-preview-item"
:class="{ active: currentPageIndex === idx }"
>
<span class="page-preview-label" @click="goToPage(idx)">
{{ idx + 1 }}
<span class="page-preview-meta">{{ (page.icons && page.icons.length) || 0 }} 图标 · {{ (page.textBoxes && page.textBoxes.length) || 0 }} 文本框</span>
</span>
<el-button
type="text"
size="mini"
icon="el-icon-delete"
class="page-delete-btn"
:disabled="currentPageCount <= 1"
@click="deletePage(idx)"
>删除</el-button>
</div>
</div>
</el-popover>
<input
ref="bgInput"
type="file"
@ -64,23 +122,23 @@
@mousedown="onTextBoxMouseDown(box, $event)"
>
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div>
<!-- Office 风格格式工具栏 -->
<div v-if="selectedId === box.id" class="textbox-format-toolbar" @mousedown.stop>
<el-select v-model="box.fontFamily" size="mini" placeholder="字体" class="format-font">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="box.fontSize" size="mini" placeholder="字号" class="format-size">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="box.color" class="format-color" />
<div class="textbox-input-wrapper">
<div
v-if="box.placeholder && !box.text && focusedBoxId !== box.id"
class="textbox-placeholder"
:style="getTextBoxPlaceholderStyle(box)"
@mousedown="focusTextBox(box, $event)"
>{{ box.placeholder }}</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@focus="onTextBoxFocus(box)"
@blur="onTextBoxBlur(box, $event)"
@mousedown.stop="selectedId = box.id"
@contextmenu="onTextBoxContextMenu(box, $event)"
></div>
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
@ -103,10 +161,30 @@
:style="drawingTextBoxStyle"
></div>
</div>
<!-- 格式工具栏仅选中文字右键时显示 -->
<div
v-if="formatToolbarBox"
class="textbox-format-toolbar-fixed"
:style="{ left: formatToolbarPosition.x + 'px', top: formatToolbarPosition.y + 'px' }"
@mousedown.stop
>
<el-select v-model="formatToolbarBox.fontFamily" size="mini" placeholder="字体" class="format-font">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="formatToolbarBox.fontSize" size="mini" placeholder="字号" class="format-size">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="formatToolbarBox.color" class="format-color" />
</div>
</div>
</template>
<script>
import request from '@/utils/request'
import { resolveImageUrl } from '@/utils/imageUrl'
import { createRollCallTextBoxes, createSubTitleTemplate, SUB_TITLE_TEMPLATE_NAMES } from './rollCallTemplate'
import { getPageContent, createEmptySubContent, ensurePagesStructure } from './subContentPages'
let idCounter = 0
function genId() {
return 'el_' + (++idCounter) + '_' + Date.now()
@ -126,6 +204,10 @@ export default {
activeSubIndex: {
type: Number,
default: 0
},
subTitles: {
type: Array,
default: () => ['点名', '任务目标', '自身任务', '对接任务']
}
},
data() {
@ -133,10 +215,10 @@ export default {
fontOptions: FONT_OPTIONS,
fontSizeOptions: FONT_SIZE_OPTIONS,
subContents: [
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] }
createEmptySubContent(),
createEmptySubContent(),
createEmptySubContent(),
createEmptySubContent()
],
insertMode: null,
pendingIconImage: null,
@ -147,20 +229,61 @@ export default {
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
drawCurrentY: 0,
formatToolbarBoxId: null,
formatToolbarPosition: { x: 0, y: 0 },
focusedBoxId: null,
showLeftPagination: false,
showRightPagination: false,
_loadingFromData: false
}
},
computed: {
formatToolbarBox() {
if (!this.formatToolbarBoxId) return null
const boxes = this.currentTextBoxes
return boxes.find(t => t.id === this.formatToolbarBoxId) || null
},
currentSubContent() {
return this.subContents[this.activeSubIndex]
},
currentPage() {
return getPageContent(this.currentSubContent)
},
currentIcons() {
return this.subContents[this.activeSubIndex]?.icons || []
return this.currentPage?.icons || []
},
currentTextBoxes() {
return this.subContents[this.activeSubIndex]?.textBoxes || []
return this.currentPage?.textBoxes || []
},
currentPageCount() {
const sc = this.currentSubContent
if (!sc) return 0
ensurePagesStructure(sc)
return sc.pages?.length || 0
},
currentPageIndex() {
const sc = this.currentSubContent
if (!sc) return 0
ensurePagesStructure(sc)
return sc.currentPageIndex || 0
},
canPrevPage() {
return this.currentPageIndex > 0
},
canNextPage() {
return this.currentPageIndex < this.currentPageCount - 1
},
allPagesForPreview() {
const sc = this.currentSubContent
if (!sc) return []
ensurePagesStructure(sc)
return sc.pages || []
},
canvasStyle() {
const style = {}
if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})`
style.backgroundImage = `url(${resolveImageUrl(this.backgroundImage)})`
style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
@ -183,21 +306,64 @@ export default {
watch: {
activeSubIndex() {
this.selectedId = null
this.$nextTick(() => {
this.ensureRollCallTextBoxes()
this.ensureSubTitleTemplate()
})
},
subContents: {
handler() {
if (this._loadingFromData) return
this._saveReqTimer && clearTimeout(this._saveReqTimer)
this._saveReqTimer = setTimeout(() => {
this._saveReqTimer = null
this.$emit('save-request')
}, 300)
},
deep: true
},
subTitles: {
handler(titles) {
while (this.subContents.length < (titles?.length || 0)) {
this.subContents.push(createEmptySubContent())
}
while (this.subContents.length > (titles?.length || 0)) {
this.subContents.pop()
}
},
immediate: true
}
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
this._clickHandler = (e) => {
if (this.formatToolbarBoxId && !e.target.closest('.textbox-format-toolbar-fixed')) {
this.formatToolbarBoxId = null
}
}
document.addEventListener('keydown', this._keydownHandler)
this.$nextTick(() => this.syncTextBoxContent())
document.addEventListener('click', this._clickHandler)
this.$nextTick(() => {
this.ensureRollCallTextBoxes()
this.ensureSubTitleTemplate()
this.syncTextBoxContent()
})
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
this.syncFromDomToData()
this.$emit('understanding-data', { subTitles: this.subTitles, subContents: this.getDataForSave() })
document.removeEventListener('keydown', this._keydownHandler)
document.removeEventListener('click', this._clickHandler)
},
methods: {
handleInsertCommand(cmd) {
if (cmd === 'removeBackground') {
this.$emit('background-change', null)
return
}
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
@ -207,16 +373,24 @@ export default {
this.$refs.iconImageInput.click()
}
},
handleBackgroundSelect(e) {
async handleBackgroundSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null
e.target.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const res = await request.post('/common/upload', formData)
if (res && (res.code === 200 || res.fileName)) {
const path = res.fileName || res.url
if (path) this.$emit('background-change', path)
} else {
this.$message.error(res?.msg || '背景图上传失败')
}
} catch (err) {
this.$message.error(err?.response?.data?.msg || err?.message || '背景图上传失败')
}
},
handleIconImageSelect(e) {
const file = e.target.files?.[0]
@ -234,8 +408,8 @@ export default {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const sub = this.subContents[this.activeSubIndex]
sub.icons.push({
const page = this.currentPage
if (page) page.icons.push({
id: genId(),
x,
y,
@ -280,8 +454,8 @@ export default {
const y = Math.min(this.drawStartY, this.drawCurrentY)
const w = Math.max(Math.abs(this.drawCurrentX - this.drawStartX), 20)
const h = Math.max(Math.abs(this.drawCurrentY - this.drawStartY), 20)
const sub = this.subContents[this.activeSubIndex]
sub.textBoxes.push({
const page = this.currentPage
if (page) page.textBoxes.push({
id: genId(),
x,
y,
@ -303,6 +477,14 @@ export default {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
onTextBoxContextMenu(box, e) {
const sel = window.getSelection()
const hasSelection = sel && sel.toString().trim().length > 0
if (!hasSelection) return
e.preventDefault()
this.formatToolbarBoxId = box.id
this.formatToolbarPosition = { x: e.clientX, y: e.clientY }
},
selectElement(id, e) {
this.selectedId = id
const icon = this.currentIcons.find(i => i.id === id)
@ -463,16 +645,199 @@ export default {
}
},
getTextBoxInputStyle(box) {
return {
const style = {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: box.color || DEFAULT_FONT.color
}
if (box.fontWeight) style.fontWeight = box.fontWeight
return style
},
getTextBoxPlaceholderStyle(box) {
const style = {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: '#999'
}
if (box.fontWeight) style.fontWeight = box.fontWeight
return style
},
focusTextBox(box, e) {
e.preventDefault()
e.stopPropagation()
this.focusedBoxId = box.id
this.selectedId = box.id
this.$nextTick(() => {
const wrapper = e.target.closest('.textbox-input-wrapper')
const input = wrapper && wrapper.querySelector('.textbox-input')
if (input) input.focus()
})
},
onTextBoxFocus(box) {
this.focusedBoxId = box.id
},
onTextBoxBlur(box, e) {
box.text = (e.target.innerText || '').trim()
this.focusedBoxId = null
},
ensureRollCallTextBoxes() {
const subTitle = this.subTitles[this.activeSubIndex]
if (subTitle !== '点名') return
const page = this.currentPage
if (!page || !page.textBoxes || page.textBoxes.length > 0) return
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
page.textBoxes.push(...createRollCallTextBoxes(w, h))
},
ensureSubTitleTemplate() {
const subTitle = this.subTitles[this.activeSubIndex]
if (!subTitle || subTitle === '点名' || !SUB_TITLE_TEMPLATE_NAMES.includes(subTitle)) return
const page = this.currentPage
if (!page || !page.textBoxes || page.textBoxes.length > 0) return
const canvas = this.$refs.canvas
const w = canvas ? canvas.offsetWidth : 800
const h = canvas ? canvas.offsetHeight : 500
page.textBoxes.push(...createSubTitleTemplate(subTitle, w, h))
},
addPageToSubIndex(index, boxes) {
const sub = this.subContents[index]
if (!sub) return
ensurePagesStructure(sub)
sub.pages.push({ icons: [], textBoxes: boxes || [] })
sub.currentPageIndex = sub.pages.length - 1
},
loadFromData(data) {
if (!data) return
this._loadingFromData = true
const raw = typeof data === 'string' ? (() => { try { return JSON.parse(data) } catch (_) { return null } })() : data
if (!raw || !Array.isArray(raw.subContents)) {
this._loadingFromData = false
return
}
const normalized = raw.subContents.map(sc => {
ensurePagesStructure(sc)
const pages = (sc.pages || []).map(p => ({
icons: (p.icons || []).map(ic => ({
id: ic.id || genId(),
x: Number(ic.x) || 0,
y: Number(ic.y) || 0,
width: Number(ic.width) || 60,
height: Number(ic.height) || 60,
rotation: Number(ic.rotation) || 0,
src: ic.src || ''
})),
textBoxes: (p.textBoxes || []).map(t => ({
id: t.id || genId(),
x: Number(t.x) || 0,
y: Number(t.y) || 0,
width: Number(t.width) || 100,
height: Number(t.height) || 60,
text: String(t.text || ''),
placeholder: t.placeholder,
rotation: Number(t.rotation) || 0,
fontSize: Number(t.fontSize) || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color,
fontWeight: t.fontWeight
}))
}))
return { pages: pages.length > 0 ? pages : [{ icons: [], textBoxes: [] }], currentPageIndex: sc.currentPageIndex || 0 }
})
this.subContents.splice(0, this.subContents.length, ...normalized)
this.$nextTick(() => {
this._loadingFromData = false
this.ensureRollCallTextBoxes()
this.ensureSubTitleTemplate()
})
},
getDataForSave() {
return this.subContents.map(sc => {
ensurePagesStructure(sc)
return {
pages: (sc.pages || []).map(p => ({
icons: (p.icons || []).map(i => ({
id: i.id,
x: i.x,
y: i.y,
width: i.width,
height: i.height,
rotation: i.rotation || 0,
src: i.src
})),
textBoxes: (p.textBoxes || []).map(t => ({
id: t.id,
x: t.x,
y: t.y,
width: t.width,
height: t.height,
text: t.text || '',
placeholder: t.placeholder,
rotation: t.rotation || 0,
fontSize: t.fontSize,
fontFamily: t.fontFamily,
color: t.color,
fontWeight: t.fontWeight
}))
})),
currentPageIndex: sc.currentPageIndex || 0
}
})
},
prevPage() {
const sub = this.currentSubContent
if (!sub || !this.canPrevPage) return
ensurePagesStructure(sub)
sub.currentPageIndex = Math.max(0, (sub.currentPageIndex || 0) - 1)
this.selectedId = null
},
onPaginationAreaMouseMove(e) {
if (this.currentPageCount <= 1) return
const el = this.$el
if (!el) return
const rect = el.getBoundingClientRect()
const x = e.clientX - rect.left
const edgeZone = 80
this.showLeftPagination = x < edgeZone
this.showRightPagination = x > rect.width - edgeZone
},
onPaginationAreaMouseLeave() {
this.showLeftPagination = false
this.showRightPagination = false
},
nextPage() {
const sub = this.currentSubContent
if (!sub || !this.canNextPage) return
ensurePagesStructure(sub)
sub.currentPageIndex = Math.min((sub.pages?.length || 1) - 1, (sub.currentPageIndex || 0) + 1)
this.selectedId = null
},
goToPage(idx) {
const sub = this.currentSubContent
if (!sub) return
ensurePagesStructure(sub)
sub.currentPageIndex = Math.max(0, Math.min(idx, (sub.pages?.length || 1) - 1))
this.selectedId = null
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose()
},
deletePage(idx) {
const sub = this.currentSubContent
if (!sub || this.currentPageCount <= 1) return
ensurePagesStructure(sub)
const pages = sub.pages
if (!pages || idx < 0 || idx >= pages.length) return
pages.splice(idx, 1)
const cur = sub.currentPageIndex || 0
if (cur >= pages.length) sub.currentPageIndex = Math.max(0, pages.length - 1)
else if (idx < cur) sub.currentPageIndex = cur - 1
this.selectedId = null
this.$refs.pagePreviewPopover && this.$refs.pagePreviewPopover.doClose()
},
deleteIcon(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.icons.findIndex(i => i.id === id)
if (idx >= 0) sub.icons.splice(idx, 1)
const page = this.currentPage
if (!page) return
const idx = page.icons.findIndex(i => i.id === id)
if (idx >= 0) page.icons.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
syncTextBoxContent() {
@ -486,10 +851,20 @@ export default {
}
})
},
syncFromDomToData() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
const boxes = this.currentTextBoxes
boxes.forEach((box, idx) => {
const el = inputs[idx]
if (el) box.text = (el.innerText || '').trim()
})
},
deleteTextBox(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) sub.textBoxes.splice(idx, 1)
const page = this.currentPage
if (!page) return
const idx = page.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) page.textBoxes.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
onKeydown(e) {
@ -527,10 +902,88 @@ export default {
</script>
<style scoped>
.pagination-left,
.pagination-right {
position: absolute;
top: 0;
bottom: 0;
width: 48px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
cursor: pointer;
pointer-events: auto;
}
.pagination-left {
left: 0;
}
.pagination-right {
right: 0;
}
.pagination-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 8px;
font-size: 12px;
color: #008aff;
transition: all 0.2s;
}
.pagination-btn:hover:not(.disabled) {
background: rgba(0, 138, 255, 0.1);
border-color: rgba(0, 138, 255, 0.3);
}
.pagination-btn.disabled {
color: #cbd5e1;
cursor: not-allowed;
opacity: 0.6;
}
.pagination-btn i {
font-size: 18px;
}
.pagination-btn-circle {
width: 36px;
height: 36px;
padding: 0;
border-radius: 50%;
flex-direction: row;
justify-content: center;
}
.pagination-btn-circle i {
font-size: 16px;
}
.pagination-indicator,
.pagination-indicator-clickable {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
font-size: 12px;
color: #666;
z-index: 99;
}
.pagination-indicator-clickable {
cursor: pointer;
}
.pagination-indicator-clickable:hover {
background: rgba(255, 255, 255, 1);
border-color: rgba(0, 138, 255, 0.3);
color: #008aff;
}
.understanding-step-content {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.task-canvas {
@ -709,26 +1162,24 @@ export default {
pointer-events: auto;
}
/* Office 风格格式工具栏 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
/* 格式工具栏:仅选中文字右键时显示 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
margin-top: 4px;
}
.format-font { width: 120px; }
.format-size { width: 70px; }
.format-color { vertical-align: middle; }
.format-color ::v-deep .el-color-picker__trigger {
.textbox-format-toolbar-fixed .format-font { width: 120px; }
.textbox-format-toolbar-fixed .format-size { width: 70px; }
.textbox-format-toolbar-fixed .format-color { vertical-align: middle; }
.textbox-format-toolbar-fixed .format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
@ -744,12 +1195,35 @@ export default {
background: transparent;
}
.textbox-input {
.textbox-input-wrapper {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
}
.textbox-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
outline: none;
overflow: hidden;
background: transparent;
color: #999;
cursor: text;
pointer-events: auto;
}
.textbox-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
@ -757,6 +1231,10 @@ export default {
background: transparent;
color: #333;
}
.textbox-input::selection {
background: #c8c8c8;
color: inherit;
}
.drawing-textbox {
position: absolute;
@ -765,3 +1243,61 @@ export default {
pointer-events: none;
}
</style>
<style lang="scss">
/* 页码预览弹窗(popper 在 body,需非 scoped) */
.page-preview-popover {
.page-preview-list {
max-height: 280px;
overflow-y: auto;
}
.page-preview-title {
font-size: 13px;
color: #64748b;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.page-preview-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
margin-bottom: 4px;
transition: background 0.2s;
}
.page-preview-item:hover {
background: #f1f5f9;
}
.page-preview-item.active {
background: rgba(0, 138, 255, 0.1);
color: #008aff;
}
.page-preview-label {
flex: 1;
cursor: pointer;
font-size: 13px;
}
.page-preview-meta {
margin-left: 8px;
font-size: 11px;
color: #94a3b8;
}
.page-preview-item.active .page-preview-meta {
color: rgba(0, 138, 255, 0.7);
}
.page-delete-btn {
padding: 4px 8px;
color: #f56c6c;
}
.page-delete-btn:hover:not(:disabled) {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}
.page-delete-btn:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
}
</style>

579
ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue

@ -0,0 +1,579 @@
<template>
<div class="whiteboard-panel" v-if="visible">
<!-- 第一行时间选择 | 绘制空域 | 白板方案 | 新建 | 退出 -->
<div class="wb-row wb-row-main">
<!-- 时间选择左侧 -->
<div class="wb-time-section">
<span class="wb-label">时间选择</span>
<div class="wb-time-blocks">
<el-tag
v-for="tb in sortedTimeBlocks"
:key="tb"
:type="currentTimeBlock === tb ? 'primary' : 'info'"
size="small"
class="wb-time-tag"
@click="selectTimeBlock(tb)"
>
{{ tb }}
</el-tag>
<el-button type="text" size="mini" @click="showAddTimeBlock = true" title="添加时间">
<i class="el-icon-plus"></i>
</el-button>
</div>
<el-popover placement="bottom" width="200" trigger="click" v-if="currentTimeBlock">
<div class="wb-time-edit">
<el-button type="text" size="small" @click="openModifyTimeBlock">修改时间</el-button>
<el-button type="text" size="small" style="color: #F56C6C;" @click="deleteCurrentTimeBlock">删除</el-button>
</div>
<el-button slot="reference" type="text" size="mini"><i class="el-icon-more"></i></el-button>
</el-popover>
</div>
<!-- 绘制空域时间选择右侧 -->
<div class="wb-tools-section">
<span class="wb-label">绘制</span>
<el-button size="mini" :type="drawMode === 'airspace' ? 'primary' : 'default'" @click="toggleAirspaceDraw">
空域
</el-button>
</div>
<!-- 白板方案选择新建退出与时间选择同高 -->
<div class="wb-draft-actions">
<span class="wb-label">白板方案</span>
<div class="wb-draft-select-wrap">
<el-select
v-model="currentWhiteboardId"
placeholder="选择白板方案"
size="small"
filterable
allow-create
default-first-option
class="wb-draft-select"
@change="onWhiteboardChange"
@create="(name) => $emit('create-whiteboard', name)"
>
<el-option
v-for="wb in whiteboards"
:key="wb.id"
:label="wb.name || '未命名'"
:value="wb.id"
/>
</el-select>
<el-dropdown v-if="currentWhiteboard" trigger="click" @command="onDraftCommand">
<el-button type="text" size="mini" class="wb-draft-more-btn"><i class="el-icon-more"></i></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="rename"><i class="el-icon-edit"></i> 重命名</el-dropdown-item>
<el-dropdown-item command="delete" divided><i class="el-icon-delete"></i> 删除白板方案</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<el-button type="text" size="small" @click="$emit('create-whiteboard')" title="新建白板">
<i class="el-icon-plus"></i> 新建
</el-button>
<el-button type="text" size="small" @click="exitWhiteboard" title="退出白板">
<i class="el-icon-close"></i> 退出
</el-button>
</div>
</div>
<!-- 第二行平台空中 | 海上 | 地面 -->
<div class="wb-row wb-row-platform">
<span class="wb-label">平台</span>
<el-radio-group v-model="platformFilter" size="mini" class="wb-platform-filter">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="air">空中</el-radio-button>
<el-radio-button label="sea">海上</el-radio-button>
<el-radio-button label="ground">地面</el-radio-button>
</el-radio-group>
<div class="wb-platform-grid">
<div
v-for="p in filteredPlatforms"
:key="p.id"
class="wb-platform-item"
draggable="true"
@dragstart="onPlatformDragStart($event, p)"
>
<div class="wb-platform-icon" :style="{ color: p.color || '#008aff' }">
<img v-if="isImg(p.imageUrl || p.iconUrl)" :src="formatImg(p.imageUrl || p.iconUrl)" class="wb-platform-img" />
<i v-else :class="p.icon || 'el-icon-picture-outline'"></i>
</div>
<span class="wb-platform-name">{{ p.name }}</span>
</div>
</div>
</div>
<!-- 添加时间块弹窗 -->
<el-dialog title="添加时间块" :visible.sync="showAddTimeBlock" width="400px" append-to-body @close="newTimeBlockValue = null; newTimeBlockInput = ''">
<el-form label-width="100px" size="small">
<el-form-item label="快捷选择">
<div class="time-presets">
<el-tag
v-for="preset in timeBlockPresets"
:key="preset.value"
class="preset-tag"
@click="selectTimePreset(preset.value)"
>
{{ preset.label }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="选择时间">
<el-time-picker
v-model="newTimeBlockValue"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择 K+ 后的时间"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="或手动输入">
<el-input v-model="newTimeBlockInput" placeholder="如 K+00:05:00" size="small" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showAddTimeBlock = false">取消</el-button>
<el-button type="primary" @click="addTimeBlock">确定</el-button>
</span>
</el-dialog>
<!-- 重命名白板方案弹窗 -->
<el-dialog title="重命名白板方案" :visible.sync="showRenameWhiteboardDialog" width="400px" append-to-body @open="initRenameWhiteboardDialog" @close="renameWhiteboardName = ''">
<el-form label-width="80px" size="small">
<el-form-item label="方案名称">
<el-input v-model="renameWhiteboardName" placeholder="请输入白板方案名称" @keyup.enter.native="commitRenameWhiteboardDialog" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showRenameWhiteboardDialog = false">取消</el-button>
<el-button type="primary" @click="commitRenameWhiteboardDialog">确定</el-button>
</span>
</el-dialog>
<!-- 修改时间弹窗与新建时间块同结构 -->
<el-dialog title="修改时间" :visible.sync="showRenameTimeBlock" width="400px" append-to-body @open="initModifyTimeBlock" @close="renameTimeBlockValue = null; renameTimeBlockInput = ''">
<el-form label-width="100px" size="small">
<el-form-item label="快捷选择">
<div class="time-presets">
<el-tag
v-for="preset in timeBlockPresets"
:key="'rename-' + preset.value"
class="preset-tag"
@click="selectRenamePreset(preset.value)"
>
{{ preset.label }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="选择时间">
<el-time-picker
v-model="renameTimeBlockValue"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择 K+ 后的时间"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="或手动输入">
<el-input v-model="renameTimeBlockInput" placeholder="如 K+00:10:00" size="small" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showRenameTimeBlock = false">取消</el-button>
<el-button type="primary" @click="renameTimeBlock">确定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'WhiteboardPanel',
props: {
visible: {
type: Boolean,
default: false
},
roomId: {
type: [String, Number],
default: null
},
whiteboards: {
type: Array,
default: () => []
},
currentWhiteboard: {
type: Object,
default: null
},
airPlatforms: {
type: Array,
default: () => []
},
seaPlatforms: {
type: Array,
default: () => []
},
groundPlatforms: {
type: Array,
default: () => []
}
},
data() {
return {
currentWhiteboardId: null,
currentTimeBlock: null,
drawMode: null,
platformFilter: 'all',
showRenameWhiteboardDialog: false,
renameWhiteboardName: '',
showAddTimeBlock: false,
showRenameTimeBlock: false,
newTimeBlockValue: null,
newTimeBlockInput: '',
renameTimeBlockValue: null,
renameTimeBlockInput: ''
}
},
computed: {
timeBlockPresets() {
return [
{ label: 'K+0', value: 'K+00:00:00' },
{ label: 'K+5', value: 'K+00:05:00' },
{ label: 'K+10', value: 'K+00:10:00' },
{ label: 'K+15', value: 'K+00:15:00' },
{ label: 'K+30', value: 'K+00:30:00' },
{ label: 'K+60', value: 'K+01:00:00' },
{ label: 'K+2h', value: 'K+02:00:00' }
]
},
sortedTimeBlocks() {
const wb = this.currentWhiteboard
if (!wb || !Array.isArray(wb.timeBlocks)) return []
return [...wb.timeBlocks].sort((a, b) => this.compareTimeBlock(a, b))
},
allPlatforms() {
return [
...(this.airPlatforms || []),
...(this.seaPlatforms || []),
...(this.groundPlatforms || [])
]
},
filteredPlatforms() {
if (this.platformFilter === 'all') return this.allPlatforms
if (this.platformFilter === 'air') return this.airPlatforms || []
if (this.platformFilter === 'sea') return this.seaPlatforms || []
if (this.platformFilter === 'ground') return this.groundPlatforms || []
return this.allPlatforms
}
},
watch: {
currentWhiteboard: {
handler(wb) {
if (wb) {
this.currentWhiteboardId = wb.id
const blocks = wb.timeBlocks || []
if (blocks.length > 0 && !blocks.includes(this.currentTimeBlock)) {
this.currentTimeBlock = this.sortedTimeBlocks[0] || blocks[0]
} else if (blocks.length === 0) {
this.currentTimeBlock = null
}
} else {
this.currentWhiteboardId = null
this.currentTimeBlock = null
}
},
immediate: true
}
},
methods: {
/** 与 RightPanel 一致:判断是否为图片路径(支持 /profile/upload/ 等相对路径) */
isImg(url) {
if (!url || typeof url !== 'string') return false
return url.includes('/') || url.includes('data:image') || /\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/i.test(url)
},
/** 与 RightPanel 一致:拼接后端地址,图片需通过完整 URL 加载 */
formatImg(url) {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('//') || url.startsWith('data:')) return url
const cleanPath = (url || '').replace(/\/+/g, '/')
const backendUrl = process.env.VUE_APP_BACKEND_URL || process.env.VUE_APP_BASE_API || ''
return backendUrl ? backendUrl + cleanPath : url
},
compareTimeBlock(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)
},
onWhiteboardChange(id) {
this.$emit('select-whiteboard', id)
},
initRenameWhiteboardDialog() {
this.renameWhiteboardName = this.currentWhiteboard ? (this.currentWhiteboard.name || '白板方案') : ''
},
commitRenameWhiteboardDialog() {
const name = (this.renameWhiteboardName || '').trim()
if (!name || !this.currentWhiteboard) {
this.$message.warning('请输入方案名称')
return
}
this.$emit('rename-whiteboard', this.currentWhiteboard.id, name)
this.showRenameWhiteboardDialog = false
this.renameWhiteboardName = ''
},
onDraftCommand(cmd) {
if (cmd === 'rename' && this.currentWhiteboard) {
this.showRenameWhiteboardDialog = true
} else if (cmd === 'delete' && this.currentWhiteboard) {
this.$confirm('确定删除该白板方案吗?', '提示', {
type: 'warning'
}).then(() => this.$emit('delete-whiteboard', this.currentWhiteboard.id)).catch(() => {})
}
},
createNewWhiteboard() {
this.$emit('create-whiteboard')
},
exitWhiteboard() {
this.$emit('exit-whiteboard')
},
selectTimeBlock(tb) {
this.currentTimeBlock = tb
this.$emit('select-time-block', tb)
},
selectTimePreset(value) {
this.$emit('add-time-block', value)
this.newTimeBlockValue = null
this.newTimeBlockInput = ''
this.showAddTimeBlock = false
},
addTimeBlock() {
let timeStr = ''
if (this.newTimeBlockValue) {
timeStr = 'K+' + this.newTimeBlockValue
} else {
const input = (this.newTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请选择时间或输入格式,如 K+00:05:00')
return
}
if (!/^K\+\d+:\d{2}:\d{2}$/.test(input)) {
this.$message.warning('格式应为 K+HH:MM:SS,如 K+00:05:00')
return
}
timeStr = input
}
this.$emit('add-time-block', timeStr)
this.newTimeBlockValue = null
this.newTimeBlockInput = ''
this.showAddTimeBlock = false
},
openModifyTimeBlock() {
this.showRenameTimeBlock = true
},
initModifyTimeBlock() {
if (!this.currentTimeBlock) return
const m = /K\+(\d+):(\d{2}):(\d{2})/.exec(this.currentTimeBlock)
if (m) {
this.renameTimeBlockValue = `${String(m[1]).padStart(2, '0')}:${m[2]}:${m[3]}`
this.renameTimeBlockInput = this.currentTimeBlock
} else {
this.renameTimeBlockValue = null
this.renameTimeBlockInput = this.currentTimeBlock
}
},
selectRenamePreset(value) {
this.$emit('rename-time-block', this.currentTimeBlock, value)
this.renameTimeBlockValue = null
this.renameTimeBlockInput = ''
this.showRenameTimeBlock = false
},
renameTimeBlock() {
let timeStr = ''
if (this.renameTimeBlockValue) {
timeStr = 'K+' + this.renameTimeBlockValue
} else {
const input = (this.renameTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请选择时间或输入格式,如 K+00:05:00')
return
}
if (!/^K\+\d+:\d{2}:\d{2}$/.test(input)) {
this.$message.warning('格式应为 K+HH:MM:SS,如 K+00:05:00')
return
}
timeStr = input
}
if (!this.currentTimeBlock) return
this.$emit('rename-time-block', this.currentTimeBlock, timeStr)
this.renameTimeBlockValue = null
this.renameTimeBlockInput = ''
this.showRenameTimeBlock = false
},
deleteCurrentTimeBlock() {
if (!this.currentTimeBlock) return
this.$emit('delete-time-block', this.currentTimeBlock)
},
onPlatformDragStart(evt, platform) {
evt.dataTransfer.setData('application/json', JSON.stringify({
type: 'whiteboardPlatform',
platform: platform
}))
evt.dataTransfer.effectAllowed = 'copy'
},
toggleAirspaceDraw() {
this.drawMode = this.drawMode === 'airspace' ? null : 'airspace'
this.$emit('draw-mode-change', this.drawMode)
}
}
}
</script>
<style scoped>
.whiteboard-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(0, 138, 255, 0.2);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
z-index: 85;
padding: 10px 16px;
max-height: 200px;
overflow-y: auto;
}
.wb-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
}
.wb-row-main {
margin-bottom: 10px;
}
.wb-row-platform {
align-items: flex-start;
width: 100%;
}
.wb-time-section,
.wb-tools-section,
.wb-draft-actions {
display: flex;
align-items: center;
gap: 6px;
}
.wb-draft-actions {
margin-left: auto;
}
.wb-draft-select-wrap {
display: flex;
align-items: center;
gap: 2px;
}
.wb-draft-select {
width: 140px;
}
.wb-draft-more-btn {
padding: 4px;
}
.wb-label {
font-size: 12px;
color: #606266;
flex-shrink: 0;
}
.wb-time-blocks {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.wb-time-tag {
cursor: pointer;
}
.wb-platform-filter {
flex-shrink: 0;
}
.wb-platform-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 6px;
flex: 1;
min-width: 0;
max-height: 70px;
overflow-y: auto;
}
.wb-platform-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px;
border-radius: 4px;
cursor: grab;
background: rgba(0, 138, 255, 0.06);
transition: background 0.2s;
}
.wb-platform-item:hover {
background: rgba(0, 138, 255, 0.15);
}
.wb-platform-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.wb-platform-img {
width: 24px;
height: 24px;
object-fit: contain;
}
.wb-platform-name {
font-size: 10px;
color: #606266;
max-width: 44px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-time-edit {
display: flex;
gap: 8px;
}
.time-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-tag {
cursor: pointer;
}
.preset-tag:hover {
opacity: 0.85;
}
</style>

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

@ -9,9 +9,11 @@
@drop="handleMapDrop"
>
<!-- cesiummap组件 -->
<cesiumMap ref="cesiumMap" :drawDomClick="drawDom || airspaceDrawDom"
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')"
<cesiumMap ref="cesiumMap" :drawDomClick="drawDom || airspaceDrawDom || (showWhiteboardPanel && whiteboardAirspaceDraw)"
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom || (showWhiteboardPanel && whiteboardAirspaceDraw) ? 'airspace' : 'airspace')"
:scaleConfig="scaleConfig"
:whiteboard-mode="showWhiteboardPanel"
:whiteboard-entities="whiteboardDisplayEntities"
:coordinateFormat="coordinateFormat"
:bottomPanelVisible="bottomPanelVisible"
:map-drag-enabled="mapDragEnabled"
@ -39,7 +41,11 @@
@platform-icon-removed="onPlatformIconRemoved"
@viewer-ready="onViewerReady"
@drawing-entities-changed="onDrawingEntitiesChanged"
@platform-style-saved="onPlatformStyleSaved" />
@platform-style-saved="onPlatformStyleSaved"
@whiteboard-draw-complete="handleWhiteboardDrawComplete"
@whiteboard-platform-updated="handleWhiteboardPlatformUpdated"
@whiteboard-entity-deleted="handleWhiteboardEntityDeleted"
@whiteboard-drawing-updated="handleWhiteboardDrawingUpdated" />
<div v-show="!screenshotMode" class="map-overlay-text">
<!-- <i class="el-icon-location-outline text-3xl mb-2 block"></i> -->
<!-- <p>二维GIS地图区域</p>
@ -94,6 +100,9 @@
:online-count="onlineCount"
:combat-time="combatTime"
:k-time-display="kTimeDisplay"
:child-room-k-times="childRoomKTimes"
:selected-child-k-time-index="selectedChildKTimeIndex"
@select-child-k-time="selectedChildKTimeIndex = $event"
:astro-time="astroTime"
:room-detail="roomDetail"
:can-set-k-time="canSetKTime"
@ -110,6 +119,7 @@
@import-ato="importATO"
@import-layer="importLayer"
@import-route="importRoute"
@export-routes="openExportRoutesDialog"
@export-plan="exportPlan"
@route-edit="routeEdit"
@military-marking="militaryMarking"
@ -210,8 +220,8 @@
@delete-platform="handleDeletePlatform"
@open-import-dialog="showImportDialog = true"
/>
<!-- 左下角工具面板 -->
<bottom-left-panel v-show="!screenshotMode" @bottom-panel-visible="handleBottomPanelVisible" @six-steps-overlay-visible="sixStepsOverlayVisible = $event" :room-id="currentRoomId" />
<!-- 左下角工具面板白板模式下隐藏避免遮挡白板 -->
<bottom-left-panel v-show="!screenshotMode && !showWhiteboardPanel" @bottom-panel-visible="handleBottomPanelVisible" @six-steps-overlay-visible="sixStepsOverlayVisible = $event" @open-whiteboard="toggleWhiteboardMode" :room-id="currentRoomId" :is-parent-room="!!(roomDetail && roomDetail.parentId == null)" />
<!-- 底部时间轴最初版本的样式- 蓝色主题 -->
<div
v-show="!screenshotMode"
@ -369,6 +379,23 @@
@confirm="handleImportConfirm"
/>
<!-- 导出航线弹窗 -->
<export-routes-dialog
v-model="showExportRoutesDialog"
:routes="routes"
:plans="plans"
@export="handleExportRoutes"
/>
<!-- 导入航线弹窗 -->
<import-routes-dialog
ref="importRoutesDialog"
v-model="showImportRoutesDialog"
:plans="plans"
:all-platforms="allPlatformsForImport"
@import="handleImportRoutes"
/>
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击4T图标时打开 -->
<four-t-panel
v-if="show4TPanel && !screenshotMode"
@ -376,6 +403,28 @@
:room-id="currentRoomId"
/>
<!-- 白板面板底部 -->
<whiteboard-panel
v-show="showWhiteboardPanel && !screenshotMode"
:visible="showWhiteboardPanel"
:room-id="currentRoomId"
:whiteboards="whiteboards"
:current-whiteboard="currentWhiteboard"
:air-platforms="airPlatforms"
:sea-platforms="seaPlatforms"
:ground-platforms="groundPlatforms"
@select-whiteboard="handleWhiteboardSelect"
@create-whiteboard="handleWhiteboardCreate"
@rename-whiteboard="handleWhiteboardRename"
@delete-whiteboard="handleWhiteboardDelete"
@exit-whiteboard="handleWhiteboardExit"
@select-time-block="handleWhiteboardTimeBlockSelect"
@add-time-block="handleWhiteboardAddTimeBlock"
@rename-time-block="handleWhiteboardRenameTimeBlock"
@delete-time-block="handleWhiteboardDeleteTimeBlock"
@draw-mode-change="handleWhiteboardDrawModeChange"
/>
<el-dialog
title="新建方案"
:visible.sync="showPlanNameDialog"
@ -435,19 +484,25 @@ import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
import TopHeader from './TopHeader'
import FourTPanel from './FourTPanel'
import WhiteboardPanel from './WhiteboardPanel'
import { createRoomWebSocket } from '@/utils/websocket';
import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle, getMissileParams, updateMissilePositions } from "@/api/system/routes";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms } from "@/api/system/rooms";
import { getRooms, updateRooms, listRooms } from "@/api/system/rooms";
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig";
import { listByRoomId as listRoomPlatformIcons, addRoomPlatformIcon, updateRoomPlatformIcon, delRoomPlatformIcon } from "@/api/system/roomPlatformIcon";
import { listWhiteboards, getWhiteboard, createWhiteboard, updateWhiteboard, deleteWhiteboard } from "@/api/system/whiteboard";
import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue";
import ExportRoutesDialog from "@/views/dialogs/ExportRoutesDialog.vue";
import ImportRoutesDialog from "@/views/dialogs/ImportRoutesDialog.vue";
export default {
name: 'MissionPlanningView',
components: {
PlatformImportDialog,
ExportRoutesDialog,
ImportRoutesDialog,
cesiumMap,
OnlineMembersDialog,
PlatformEditDialog,
@ -461,7 +516,8 @@ export default {
RightPanel,
BottomLeftPanel,
TopHeader,
FourTPanel
FourTPanel,
WhiteboardPanel
},
data() {
return {
@ -504,6 +560,9 @@ export default {
platformIconSaveTimer: null,
//
showImportDialog: false,
// /线
showExportRoutesDialog: false,
showImportRoutesDialog: false,
// /
bottomPanelVisible: false,
//
@ -529,6 +588,10 @@ export default {
combatTime: 'K+00:00:00', //
astroTime: '',
roomDetail: null,
/** 大房间时子房间的 K 时列表 [{ name, kAnchorTime }] */
childRoomKTimes: [],
/** 大房间时当前选中的子房间 K 时索引,用于切换展示 */
selectedChildKTimeIndex: 0,
showKTimeSetDialog: false,
kTimeForm: { dateTime: null },
saveRoomDrawingsTimer: null,
@ -609,6 +672,13 @@ export default {
// 4T4T/
show4TPanel: false,
//
showWhiteboardPanel: false,
whiteboards: [],
currentWhiteboard: null,
currentWhiteboardTimeBlock: null,
whiteboardAirspaceDraw: false,
// /
showAirport: true,
showLandmark: true,
@ -673,7 +743,7 @@ export default {
if (newRoomId != null && String(newRoomId) !== String(this.currentRoomId)) {
this.currentRoomId = newRoomId;
this.connectRoomWebSocket();
if (newRoomId) this.getRoomDetail();
if (newRoomId) this.getRoomDetail(() => 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') {
// 4T4T
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);
// 线
}
},

264
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
}
]
}

29
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 }
}

237
ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue

@ -0,0 +1,237 @@
<template>
<el-dialog
title="导出航线"
:visible.sync="visible"
width="720px"
top="52vh"
append-to-body
class="export-routes-dialog"
@close="handleClose"
>
<div v-if="routes.length === 0" class="empty-tip">
<i class="el-icon-warning-outline"></i>
<p>暂无航线可导出请先创建航线</p>
</div>
<div v-else>
<div class="select-actions">
<el-button type="text" size="small" @click="selectAll">全选</el-button>
<el-button type="text" size="small" @click="selectNone">全不选</el-button>
</div>
<div class="tree-list">
<div
v-for="plan in plansWithRoutes"
:key="plan.id"
class="tree-item plan-item"
>
<div class="tree-item-header" @click="togglePlan(plan.id)">
<i :class="expandedPlans.includes(plan.id) ? 'el-icon-folder-opened' : 'el-icon-folder'" class="tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ plan.name }}</div>
<div class="tree-item-meta">{{ planRoutes(plan.id).length }} 个航线</div>
</div>
</div>
<div v-if="expandedPlans.includes(plan.id)" class="tree-children route-children">
<div
v-for="route in planRoutes(plan.id)"
:key="route.id"
class="tree-item route-item"
:class="{ selected: selectedIds.includes(route.id) }"
@click.stop="toggleRouteSelect(route.id)"
>
<el-checkbox
:value="selectedIds.includes(route.id)"
@change="(v) => setRouteSelected(route.id, v)"
@click.native.stop
>
<span class="route-name">{{ route.name }}</span>
<span class="route-meta">{{ route.points || (route.waypoints && route.waypoints.length) || 0 }} 个航点</span>
</el-checkbox>
</div>
</div>
</div>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" :disabled="selectedIds.length === 0" @click="handleExport">
导出 {{ selectedIds.length > 0 ? `(${selectedIds.length} 条)` : '' }}
</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ExportRoutesDialog',
props: {
value: {
type: Boolean,
default: false
},
routes: {
type: Array,
default: () => []
},
plans: {
type: Array,
default: () => []
}
},
data() {
return {
selectedIds: [],
expandedPlans: []
};
},
computed: {
visible: {
get() {
return this.value;
},
set(v) {
this.$emit('input', v);
}
},
/** 有航线的方案列表(用于展示) */
plansWithRoutes() {
return this.plans.filter(p => this.planRoutes(p.id).length > 0);
}
},
watch: {
value(v) {
if (v) {
this.selectedIds = this.routes.map(r => r.id);
this.expandedPlans = this.plansWithRoutes.map(p => p.id);
}
}
},
methods: {
planRoutes(planId) {
return this.routes.filter(r => r.scenarioId === planId);
},
togglePlan(planId) {
const idx = this.expandedPlans.indexOf(planId);
if (idx >= 0) {
this.expandedPlans.splice(idx, 1);
} else {
this.expandedPlans.push(planId);
}
},
toggleRouteSelect(routeId) {
const idx = this.selectedIds.indexOf(routeId);
if (idx >= 0) {
this.selectedIds.splice(idx, 1);
} else {
this.selectedIds.push(routeId);
}
},
setRouteSelected(routeId, selected) {
if (selected) {
if (!this.selectedIds.includes(routeId)) this.selectedIds.push(routeId);
} else {
this.selectedIds = this.selectedIds.filter(id => id !== routeId);
}
},
selectAll() {
this.selectedIds = this.routes.map(r => r.id);
},
selectNone() {
this.selectedIds = [];
},
handleExport() {
this.$emit('export', this.selectedIds);
},
handleClose() {
this.selectedIds = [];
this.expandedPlans = [];
}
}
};
</script>
<style scoped>
.export-routes-dialog .empty-tip {
text-align: center;
padding: 32px 0;
color: #909399;
}
.export-routes-dialog .empty-tip i {
font-size: 48px;
margin-bottom: 12px;
display: block;
}
.export-routes-dialog .select-actions {
margin-bottom: 12px;
}
.export-routes-dialog .tree-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 8px;
}
.export-routes-dialog .tree-item {
user-select: none;
}
.export-routes-dialog .tree-item-header {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.export-routes-dialog .tree-item-header:hover {
background: #f5f7fa;
}
.export-routes-dialog .plan-item .tree-item-header {
font-weight: 500;
}
.export-routes-dialog .tree-icon {
margin-right: 8px;
color: #909399;
font-size: 16px;
}
.export-routes-dialog .tree-item-info {
flex: 1;
min-width: 0;
}
.export-routes-dialog .tree-item-name {
font-size: 14px;
color: #303133;
}
.export-routes-dialog .tree-item-meta {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.export-routes-dialog .tree-children {
padding-left: 24px;
}
.export-routes-dialog .route-children {
margin-bottom: 4px;
}
.export-routes-dialog .route-item {
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.export-routes-dialog .route-item:hover {
background: #f5f7fa;
}
.export-routes-dialog .route-item .route-name {
font-weight: 500;
}
.export-routes-dialog .route-item .route-meta {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
.export-routes-dialog >>> .route-item .el-checkbox {
display: flex;
align-items: center;
width: 100%;
}
.export-routes-dialog >>> .route-item .el-checkbox__label {
flex: 1;
}
</style>

8
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) {

240
ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue

@ -0,0 +1,240 @@
<template>
<el-dialog
title="导入航线"
:visible.sync="visible"
width="520px"
append-to-body
class="import-routes-dialog"
:modal-append-to-body="true"
@close="handleClose"
>
<div v-if="!parsedData" class="empty-tip">
<i class="el-icon-upload"></i>
<p>请选择要导入的航线 JSON 文件</p>
<el-button type="primary" size="small" @click="triggerFileInput">选择文件</el-button>
<input
ref="fileInput"
type="file"
accept=".json"
style="display:none"
@change="onFileChange"
/>
</div>
<div v-else>
<div class="import-preview">
<div class="preview-header">
<i class="el-icon-document"></i>
<span> {{ routeItems.length }} 条航线待导入</span>
</div>
<div class="route-preview-list">
<div v-for="(item, idx) in routeItems" :key="idx" class="route-preview-item">
<span class="route-name">{{ item.callSign || item.name || `航线${idx + 1}` }}</span>
<span class="route-meta">{{ (item.waypoints || []).length }} 个航点</span>
</div>
</div>
</div>
<el-form label-width="100px" size="small" class="import-form">
<el-form-item label="目标方案" required>
<el-select v-model="targetScenarioId" placeholder="请选择方案" style="width:100%" clearable>
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<div v-if="plans.length === 0" class="el-form-item__error" style="margin-top:4px;">暂无方案请先创建方案后再导入</div>
</el-form-item>
<el-form-item label="默认平台">
<el-select v-model="targetPlatformId" placeholder="导入时使用的平台(可后续在编辑中修改)" style="width:100%" clearable>
<el-option
v-for="p in allPlatforms"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button v-if="parsedData" @click="resetFile">重新选择</el-button>
<el-button @click="visible = false"> </el-button>
<el-button
type="primary"
:disabled="!canImport"
:loading="importing"
@click="handleImport"
>
导入
</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ImportRoutesDialog',
props: {
value: {
type: Boolean,
default: false
},
plans: {
type: Array,
default: () => []
},
allPlatforms: {
type: Array,
default: () => []
}
},
data() {
return {
parsedData: null,
targetScenarioId: null,
targetPlatformId: null,
importing: false
};
},
computed: {
visible: {
get() {
return this.value;
},
set(v) {
this.$emit('input', v);
}
},
routeItems() {
if (!this.parsedData) return [];
const d = this.parsedData;
if (Array.isArray(d.routes)) return d.routes;
if (d.route && d.waypoints) return [{ ...d.route, waypoints: d.waypoints }];
return [];
},
canImport() {
return this.parsedData && this.targetScenarioId && this.routeItems.length > 0;
}
},
watch: {
value(v) {
if (v && !this.parsedData) {
this.targetScenarioId = this.plans[0] && this.plans[0].id;
this.targetPlatformId = this.allPlatforms[0] && this.allPlatforms[0].id;
}
},
plans: {
immediate: true,
handler(plans) {
if (plans.length > 0 && !this.targetScenarioId) {
this.targetScenarioId = plans[0].id;
}
}
},
allPlatforms: {
immediate: true,
handler(platforms) {
if (platforms.length > 0 && !this.targetPlatformId) {
this.targetPlatformId = platforms[0].id;
}
}
}
},
methods: {
triggerFileInput() {
this.$refs.fileInput && this.$refs.fileInput.click();
},
onFileChange(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const text = ev.target.result;
const data = JSON.parse(text);
if (!data.routes && !(data.route && data.waypoints)) {
this.$message.error('文件格式不正确,缺少 routes 或 route/waypoints 数据');
return;
}
this.parsedData = data;
if (this.plans.length > 0 && !this.targetScenarioId) {
this.targetScenarioId = this.plans[0].id;
}
if (this.allPlatforms.length > 0 && !this.targetPlatformId) {
this.targetPlatformId = this.allPlatforms[0].id;
}
} catch (err) {
this.$message.error('JSON 解析失败:' + (err.message || '格式错误'));
}
e.target.value = '';
};
reader.readAsText(file, 'UTF-8');
},
resetFile() {
this.parsedData = null;
this.triggerFileInput();
},
handleImport() {
if (!this.canImport) return;
this.$emit('import', {
routeItems: this.routeItems,
targetScenarioId: this.targetScenarioId,
targetPlatformId: this.targetPlatformId
});
},
handleClose() {
this.parsedData = null;
this.targetScenarioId = null;
this.targetPlatformId = null;
},
setImporting(v) {
this.importing = v;
}
}
};
</script>
<style scoped>
.import-routes-dialog >>> .el-dialog__body {
min-height: 180px;
}
.import-routes-dialog .empty-tip {
text-align: center;
padding: 32px 0;
color: #606266;
min-height: 120px;
}
.import-routes-dialog .empty-tip i {
font-size: 48px;
margin-bottom: 12px;
display: block;
}
.import-routes-dialog .empty-tip p {
margin-bottom: 16px;
}
.import-routes-dialog .import-preview {
margin-bottom: 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
}
.import-routes-dialog .preview-header {
margin-bottom: 8px;
font-weight: 500;
}
.import-routes-dialog .preview-header i {
margin-right: 8px;
}
.import-routes-dialog .route-preview-list {
max-height: 160px;
overflow-y: auto;
}
.import-routes-dialog .route-preview-item {
padding: 6px 0;
display: flex;
justify-content: space-between;
}
.import-routes-dialog .route-preview-item .route-name {
font-weight: 500;
}
.import-routes-dialog .route-preview-item .route-meta {
font-size: 12px;
color: #909399;
}
</style>

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

@ -21,7 +21,7 @@
<div
class="room-item parent-room"
:class="{ 'active': selectedRoom === room.id }"
@click="toggleRoomExpansion(room)"
@click="handleParentRoomClick(room)"
@contextmenu.prevent="showContextMenu($event, room)"
>
<div class="room-info">
@ -90,8 +90,9 @@
<button
@click="enterRoom"
class="btn-primary"
:disabled="!selectedRoom"
:class="{ 'disabled': !selectedRoom }"
:disabled="!canEnterRoom"
:class="{ 'disabled': !canEnterRoom }"
:title="enterRoomDisabledTip"
>
<i class="fa fa-sign-in"></i> 进入房间
</button>
@ -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) {

Loading…
Cancel
Save