Compare commits

...

7 Commits

  1. 3
      .vscode/settings.json
  2. 99
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/ObjectOperationLogController.java
  3. 79
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/PlatformLibController.java
  4. 78
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
  5. 120
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  6. 63
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java
  7. 37
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  8. 124
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java
  9. 13
      ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java
  10. 88
      ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java
  11. 34
      ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java
  12. 23
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/ObjectOperationLogMapper.java
  13. 8
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PlatformLibMapper.java
  14. 33
      ruoyi-system/src/main/java/com/ruoyi/system/service/IObjectOperationLogService.java
  15. 219
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java
  16. 3
      ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml
  17. 56
      ruoyi-system/src/main/resources/mapper/system/ObjectOperationLogMapper.xml
  18. 19
      ruoyi-system/src/main/resources/mapper/system/PlatformLibMapper.xml
  19. 5
      ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml
  20. 2
      ruoyi-ui/package.json
  21. 19
      ruoyi-ui/src/api/system/lib.js
  22. 23
      ruoyi-ui/src/api/system/objectLog.js
  23. 38
      ruoyi-ui/src/api/system/routes.js
  24. 19
      ruoyi-ui/src/api/system/waypoints.js
  25. 43
      ruoyi-ui/src/api/system/whiteboard.js
  26. 7
      ruoyi-ui/src/lang/en.js
  27. 7
      ruoyi-ui/src/lang/zh.js
  28. 6
      ruoyi-ui/src/plugins/dialogDrag.js
  29. 14
      ruoyi-ui/src/utils/imageUrl.js
  30. 4
      ruoyi-ui/src/utils/request.js
  31. 63
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  32. 53
      ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue
  33. 951
      ruoyi-ui/src/views/cesiumMap/index.vue
  34. 95
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  35. 185
      ruoyi-ui/src/views/childRoom/BottomTimeline.vue
  36. 1047
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  37. 593
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  38. 1170
      ruoyi-ui/src/views/childRoom/StepCanvasContent.vue
  39. 343
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  40. 100
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  41. 650
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue
  42. 579
      ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue
  43. 810
      ruoyi-ui/src/views/childRoom/index.vue
  44. 264
      ruoyi-ui/src/views/childRoom/rollCallTemplate.js
  45. 29
      ruoyi-ui/src/views/childRoom/subContentPages.js
  46. 237
      ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue
  47. 8
      ruoyi-ui/src/views/dialogs/ExternalParamsDialog.vue
  48. 240
      ruoyi-ui/src/views/dialogs/ImportRoutesDialog.vue
  49. 412
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  50. 2
      ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue
  51. 42
      ruoyi-ui/src/views/selectRoom/index.vue
  52. 19
      sql/object_operation_log.sql

3
.vscode/settings.json

@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

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

@ -0,0 +1,99 @@
package com.ruoyi.web.controller;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.service.IObjectOperationLogService;
/**
* 对象级操作日志航线/航点/平台分页查询回滚
*/
@RestController
@RequestMapping("/system/object-log")
public class ObjectOperationLogController extends BaseController {
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 分页查询对象级操作日志按房间
*/
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(
@RequestParam(required = false) Long roomId,
@RequestParam(required = false) String operatorName,
@RequestParam(required = false) Integer operationType,
@RequestParam(required = false) String objectType) {
startPage();
ObjectOperationLog query = new ObjectOperationLog();
query.setRoomId(roomId);
query.setOperatorName(operatorName);
query.setOperationType(operationType);
query.setObjectType(objectType);
List<ObjectOperationLog> list = objectOperationLogService.selectPage(query);
return getDataTable(list);
}
/**
* 回滚到指定操作数据库 + Redis 同步
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/rollback")
public AjaxResult rollback(@RequestParam Long id) {
ObjectOperationLog origin = objectOperationLogService.selectById(id);
if (origin == null) {
return error("回滚失败:原始日志不存在");
}
boolean ok = objectOperationLogService.rollback(id);
if (!ok) {
return error("回滚失败:无快照或对象类型不支持");
}
// 记录一条“回滚”操作日志,便于审计
ObjectOperationLog rollbackLog = new ObjectOperationLog();
rollbackLog.setRoomId(origin.getRoomId());
rollbackLog.setOperatorId(getUserId());
rollbackLog.setOperatorName(getUsername());
rollbackLog.setOperationType(ObjectOperationLog.TYPE_ROLLBACK);
rollbackLog.setObjectType(origin.getObjectType());
rollbackLog.setObjectId(origin.getObjectId());
rollbackLog.setObjectName(origin.getObjectName());
rollbackLog.setDetail("回滚操作:基于日志ID=" + origin.getId() + " 的" + toOpText(origin.getOperationType()));
// 简要记录被回滚日志的快照,方便排查(可选)
rollbackLog.setSnapshotBefore(origin.getSnapshotBefore());
rollbackLog.setSnapshotAfter(origin.getSnapshotAfter());
objectOperationLogService.saveLog(rollbackLog);
return success();
}
private String toOpText(Integer type) {
if (type == null) return "";
switch (type) {
case ObjectOperationLog.TYPE_INSERT:
return "新增";
case ObjectOperationLog.TYPE_UPDATE:
return "修改";
case ObjectOperationLog.TYPE_DELETE:
return "删除";
case ObjectOperationLog.TYPE_SELECT:
return "选择";
case ObjectOperationLog.TYPE_ROLLBACK:
return "回滚";
default:
return "";
}
}
}

79
ruoyi-admin/src/main/java/com/ruoyi/web/controller/PlatformLibController.java

@ -10,7 +10,10 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.PlatformLib;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IPlatformLibService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -31,6 +34,9 @@ public class PlatformLibController extends BaseController
@Autowired
private IPlatformLibService platformLibService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 查询平台模版库列表
*/
@ -72,7 +78,9 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:add')")
@Log(title = "平台模版库", businessType = BusinessType.INSERT)
@PostMapping("/add")
public AjaxResult add(PlatformLib platformLib, @RequestParam("file") MultipartFile file) throws IOException
public AjaxResult add(PlatformLib platformLib,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "roomId", required = false) Long roomId) throws IOException
{
// 判断前端是否有文件传过来
if (file != null && !file.isEmpty())
@ -83,8 +91,25 @@ public class PlatformLibController extends BaseController
platformLib.setIconUrl(fileName);
}
// 执行原有的插入逻辑
return toAjax(platformLibService.insertPlatformLib(platformLib));
int rows = platformLibService.insertPlatformLib(platformLib);
if (rows > 0) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_INSERT);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(platformLib.getId() != null ? String.valueOf(platformLib.getId()) : null);
log.setObjectName(platformLib.getName());
log.setDetail("新增平台模板:" + platformLib.getName());
log.setSnapshotAfter(JSON.toJSONString(platformLib));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台新增操作日志失败", e);
}
}
return toAjax(rows);
}
/**
@ -93,9 +118,30 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:edit')")
@Log(title = "平台模版库", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PlatformLib platformLib)
public AjaxResult edit(@RequestBody PlatformLib platformLib,
@RequestParam(value = "roomId", required = false) Long roomId)
{
return toAjax(platformLibService.updatePlatformLib(platformLib));
PlatformLib before = platformLib.getId() != null ? platformLibService.selectPlatformLibById(platformLib.getId()) : null;
int rows = platformLibService.updatePlatformLib(platformLib);
if (rows > 0 && before != null) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_UPDATE);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(String.valueOf(platformLib.getId()));
log.setObjectName(platformLib.getName());
log.setDetail("修改平台模板:" + platformLib.getName());
log.setSnapshotBefore(JSON.toJSONString(before));
log.setSnapshotAfter(JSON.toJSONString(platformLib));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台修改操作日志失败", e);
}
}
return toAjax(rows);
}
/**
@ -104,8 +150,29 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:remove')")
@Log(title = "平台模版库", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids,
@RequestParam(value = "roomId", required = false) Long roomId)
{
for (Long id : ids) {
PlatformLib before = platformLibService.selectPlatformLibById(id);
if (before != null) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_DELETE);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(String.valueOf(id));
log.setObjectName(before.getName());
log.setDetail("删除平台模板:" + before.getName());
log.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台删除操作日志失败", e);
}
}
}
return toAjax(platformLibService.deletePlatformLibByIds(ids));
}
}

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

@ -12,12 +12,16 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRouteWaypointsService;
import org.springframework.web.bind.annotation.RequestParam;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -34,6 +38,9 @@ public class RouteWaypointsController extends BaseController
@Autowired
private IRouteWaypointsService routeWaypointsService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 查询航线具体航点明细列表
*/
@ -71,34 +78,95 @@ public class RouteWaypointsController extends BaseController
/**
* 新增航线具体航点明细
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:add')")
@Log(title = "航线具体航点明细", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints)
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
return toAjax(routeWaypointsService.insertRouteWaypoints(routeWaypoints));
int rows = routeWaypointsService.insertRouteWaypoints(routeWaypoints);
if (rows > 0) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_INSERT);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(routeWaypoints.getId() != null ? String.valueOf(routeWaypoints.getId()) : null);
opLog.setObjectName(routeWaypoints.getName() != null ? routeWaypoints.getName() : "航点");
opLog.setDetail("新增航点:" + (routeWaypoints.getName() != null ? routeWaypoints.getName() : ""));
opLog.setSnapshotAfter(JSON.toJSONString(routeWaypoints));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 修改航线具体航点明细复用航线编辑权限航点属于航线的一部分
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@Log(title = "航线具体航点明细", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints)
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
return toAjax(routeWaypointsService.updateRouteWaypoints(routeWaypoints));
RouteWaypoints before = routeWaypoints.getId() != null ? routeWaypointsService.selectRouteWaypointsById(routeWaypoints.getId()) : null;
int rows = routeWaypointsService.updateRouteWaypoints(routeWaypoints);
if (rows > 0 && before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(String.valueOf(routeWaypoints.getId()));
opLog.setObjectName(routeWaypoints.getName() != null ? routeWaypoints.getName() : "航点");
opLog.setDetail("修改航点:" + (routeWaypoints.getName() != null ? routeWaypoints.getName() : routeWaypoints.getId()));
opLog.setSnapshotBefore(JSON.toJSONString(before));
opLog.setSnapshotAfter(JSON.toJSONString(routeWaypoints));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 删除航线具体航点明细
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:remove')")
@Log(title = "航线具体航点明细", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
for (Long id : ids) {
RouteWaypoints before = routeWaypointsService.selectRouteWaypointsById(id);
if (before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(before.getName() != null ? before.getName() : "航点");
opLog.setDetail("删除航点:" + (before.getName() != null ? before.getName() : id));
opLog.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
}
return toAjax(routeWaypointsService.deleteRouteWaypointsByIds(ids));
}
}

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

@ -17,7 +17,9 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRoutesService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -45,6 +47,9 @@ public class RoutesController extends BaseController
private IRoutesService routesService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
@ -163,6 +168,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')")
@ -298,39 +342,101 @@ public class RoutesController extends BaseController
/**
* 新增实体部署与航线
* @param roomId 可选房间ID时记录到对象级操作日志便于按房间分页与回滚
*/
@PreAuthorize("@ss.hasPermi('system:routes:add')")
@Log(title = "实体部署与航线", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody Routes routes)
public AjaxResult add(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
// 1. 执行插入,MyBatis 会通过 useGeneratedKeys="true" 自动将新 ID 注入 routes 对象
int rows = routesService.insertRoutes(routes);
// 2. 不要用 toAjax,直接返回 success 并带上 routes 对象
// 这样前端 response.data 就会包含这个带有 ID 的完整对象
if (rows > 0) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_INSERT);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(routes.getId()));
opLog.setObjectName(routes.getCallSign() != null ? routes.getCallSign() : "航线");
opLog.setDetail("新增航线:" + (routes.getCallSign() != null ? routes.getCallSign() : routes.getId()));
opLog.setSnapshotAfter(JSON.toJSONString(routes));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return rows > 0 ? AjaxResult.success(routes) : AjaxResult.error("新增航线失败");
}
/**
* 修改实体部署与航线
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@Log(title = "实体部署与航线", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody Routes routes)
public AjaxResult edit(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
return toAjax(routesService.updateRoutes(routes));
Routes before = null;
if (routes.getId() != null) {
before = routesService.selectRoutesById(routes.getId());
}
int rows = routesService.updateRoutes(routes);
if (rows > 0 && before != null) {
try {
// 操作后快照用“更新后重新查库”的完整数据,避免请求体缺字段导致日志里误显示为“已清空”
Routes after = routesService.selectRoutesById(routes.getId());
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(routes.getId()));
opLog.setObjectName(routes.getCallSign() != null ? routes.getCallSign() : "航线");
opLog.setDetail("修改航线:" + (routes.getCallSign() != null ? routes.getCallSign() : routes.getId()));
opLog.setSnapshotBefore(JSON.toJSONString(before));
opLog.setSnapshotAfter(after != null ? JSON.toJSONString(after) : JSON.toJSONString(routes));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 删除实体部署与航线同时清除该航线在所有房间下的 Redis 导弹数据
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:remove')")
@Log(title = "实体部署与航线", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
for (Long id : ids) {
Routes before = routesService.selectRoutesById(id);
if (before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(before.getCallSign() != null ? before.getCallSign() : "航线");
opLog.setDetail("删除航线:" + (before.getCallSign() != null ? before.getCallSign() : id));
opLog.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
}
int rows = routesService.deleteRoutesByIds(ids);
if (rows > 0) {
for (Long routeId : ids) {

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)

88
ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java

@ -0,0 +1,88 @@
package com.ruoyi.system.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 对象级操作日志航线航点平台等支持回滚
*
* @author ruoyi
*/
public class ObjectOperationLog extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 操作类型:新增 */
public static final int TYPE_INSERT = 1;
/** 操作类型:修改 */
public static final int TYPE_UPDATE = 2;
/** 操作类型:删除 */
public static final int TYPE_DELETE = 3;
/** 操作类型:选择 */
public static final int TYPE_SELECT = 4;
/** 操作类型:回滚 */
public static final int TYPE_ROLLBACK = 5;
/** 对象类型:航线 */
public static final String OBJ_ROUTE = "route";
/** 对象类型:航点 */
public static final String OBJ_WAYPOINT = "waypoint";
/** 对象类型:平台 */
public static final String OBJ_PLATFORM = "platform";
/** 主键 */
private Long id;
/** 房间ID */
private Long roomId;
/** 操作人用户ID */
private Long operatorId;
/** 操作人姓名 */
private String operatorName;
/** 操作类型 1新增 2修改 3删除 4选择 */
private Integer operationType;
/** 操作对象类型 route/waypoint/platform */
private String objectType;
/** 业务对象ID */
private String objectId;
/** 对象显示名 */
private String objectName;
/** 详细操作描述 */
private String detail;
/** 操作前快照JSON,用于回滚 */
private String snapshotBefore;
/** 操作后快照JSON */
private String snapshotAfter;
/** 相对时间 K+00:00:00 */
private String kTime;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getRoomId() { return roomId; }
public void setRoomId(Long roomId) { this.roomId = roomId; }
public Long getOperatorId() { return operatorId; }
public void setOperatorId(Long operatorId) { this.operatorId = operatorId; }
public String getOperatorName() { return operatorName; }
public void setOperatorName(String operatorName) { this.operatorName = operatorName; }
public Integer getOperationType() { return operationType; }
public void setOperationType(Integer operationType) { this.operationType = operationType; }
public String getObjectType() { return objectType; }
public void setObjectType(String objectType) { this.objectType = objectType; }
public String getObjectId() { return objectId; }
public void setObjectId(String objectId) { this.objectId = objectId; }
public String getObjectName() { return objectName; }
public void setObjectName(String objectName) { this.objectName = objectName; }
public String getDetail() { return detail; }
public void setDetail(String detail) { this.detail = detail; }
public String getSnapshotBefore() { return snapshotBefore; }
public void setSnapshotBefore(String snapshotBefore) { this.snapshotBefore = snapshotBefore; }
public String getSnapshotAfter() { return snapshotAfter; }
public void setSnapshotAfter(String snapshotAfter) { this.snapshotAfter = snapshotAfter; }
public String getkTime() { return kTime; }
public void setkTime(String kTime) { this.kTime = kTime; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}

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

23
ruoyi-system/src/main/java/com/ruoyi/system/mapper/ObjectOperationLogMapper.java

@ -0,0 +1,23 @@
package com.ruoyi.system.mapper;
import java.util.List;
import com.ruoyi.system.domain.ObjectOperationLog;
/**
* 对象级操作日志 Mapper
*
* @author ruoyi
*/
public interface ObjectOperationLogMapper {
int insert(ObjectOperationLog log);
ObjectOperationLog selectById(Long id);
List<ObjectOperationLog> selectPage(ObjectOperationLog query);
int deleteById(Long id);
/** 删除某条之后的所有日志(回滚时清理后续日志,可选策略) */
int deleteByRoomIdAfterId(Long roomId, Long afterId);
}

8
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PlatformLibMapper.java

@ -36,6 +36,14 @@ public interface PlatformLibMapper
public int insertPlatformLib(PlatformLib platformLib);
/**
* 新增平台模版库带指定 ID用于删除回滚时恢复原主键
*
* @param platformLib 平台模版库
* @return 结果
*/
public int insertPlatformLibWithId(PlatformLib platformLib);
/**
* 修改平台模版库
*
* @param platformLib 平台模版库

33
ruoyi-system/src/main/java/com/ruoyi/system/service/IObjectOperationLogService.java

@ -0,0 +1,33 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.system.domain.ObjectOperationLog;
/**
* 对象级操作日志 服务接口
*
* @author ruoyi
*/
public interface IObjectOperationLogService {
/**
* 记录一条操作日志同时写库并推送到 Redis 缓存
*/
void saveLog(ObjectOperationLog log);
/**
* 分页查询优先从 Redis 取第一页近期数据以减轻数据库压力
*/
List<ObjectOperationLog> selectPage(ObjectOperationLog query);
/**
* 根据ID查询回滚时需要快照
*/
ObjectOperationLog selectById(Long id);
/**
* 回滚到指定日志 snapshot_before 恢复数据并同步 Redis 缓存
* @return 是否成功
*/
boolean rollback(Long logId);
}

219
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java

@ -0,0 +1,219 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.ruoyi.system.domain.PlatformLib;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.mapper.PlatformLibMapper;
import com.ruoyi.system.mapper.RouteWaypointsMapper;
import com.ruoyi.system.mapper.RoutesMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.mapper.ObjectOperationLogMapper;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRouteWaypointsService;
/**
* 对象级操作日志 服务实现写库 + Redis 缓存回滚时同步 DB Redis
*/
@Service
public class ObjectOperationLogServiceImpl implements IObjectOperationLogService {
private static final String REDIS_KEY_PREFIX = "object_log:room:";
private static final int REDIS_LIST_MAX = 500;
private static final long REDIS_EXPIRE_HOURS = 24;
@Autowired
private ObjectOperationLogMapper objectOperationLogMapper;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RoutesMapper routesMapper;
@Autowired
private RouteWaypointsMapper routeWaypointsMapper;
@Autowired
private IRouteWaypointsService routeWaypointsService;
@Autowired
private PlatformLibMapper platformLibMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void saveLog(ObjectOperationLog log) {
objectOperationLogMapper.insert(log);
if (log.getRoomId() != null) {
String key = REDIS_KEY_PREFIX + log.getRoomId();
redisTemplate.opsForList().rightPush(key, log);
redisTemplate.opsForList().trim(key, -REDIS_LIST_MAX, -1);
redisTemplate.expire(key, REDIS_EXPIRE_HOURS, TimeUnit.HOURS);
}
}
@Override
public List<ObjectOperationLog> selectPage(ObjectOperationLog query) {
return objectOperationLogMapper.selectPage(query);
}
@Override
public ObjectOperationLog selectById(Long id) {
return objectOperationLogMapper.selectById(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean rollback(Long logId) {
ObjectOperationLog log = objectOperationLogMapper.selectById(logId);
if (log == null) return false;
Integer opType = log.getOperationType();
if (opType == null) return false;
if (ObjectOperationLog.TYPE_INSERT == opType) {
return rollbackInsert(log);
}
if (ObjectOperationLog.TYPE_DELETE == opType) {
return rollbackDelete(log);
}
if (ObjectOperationLog.TYPE_UPDATE == opType && log.getSnapshotBefore() != null && !log.getSnapshotBefore().isEmpty()) {
return rollbackUpdate(log);
}
return false;
}
private boolean rollbackUpdate(ObjectOperationLog log) {
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteUpdate(log);
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointUpdate(log);
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformUpdate(log);
}
return false;
}
private boolean rollbackDelete(ObjectOperationLog log) {
if (log.getSnapshotBefore() == null || log.getSnapshotBefore().isEmpty()) return false;
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteReinsert(log);
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointReinsert(log);
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformReinsert(log);
}
return false;
}
private boolean rollbackInsert(ObjectOperationLog log) {
if (log.getSnapshotAfter() == null || log.getSnapshotAfter().isEmpty()) return false;
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) {
Routes r = JSON.parseObject(log.getSnapshotAfter(), Routes.class);
if (r != null && r.getId() != null) {
routeWaypointsService.deleteRouteWaypointsByRouteId(r.getId());
routesMapper.deleteRoutesById(r.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) {
RouteWaypoints wp = JSON.parseObject(log.getSnapshotAfter(), RouteWaypoints.class);
if (wp != null && wp.getId() != null) {
routeWaypointsMapper.deleteRouteWaypointsById(wp.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
PlatformLib lib = JSON.parseObject(log.getSnapshotAfter(), PlatformLib.class);
if (lib != null && lib.getId() != null) {
platformLibMapper.deletePlatformLibById(lib.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
return false;
}
private boolean rollbackRouteUpdate(ObjectOperationLog log) {
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class);
if (before == null || before.getId() == null) return false;
routeWaypointsService.deleteRouteWaypointsByRouteId(before.getId());
routesMapper.updateRoutes(before);
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) {
for (RouteWaypoints wp : before.getWaypoints()) {
wp.setRouteId(before.getId());
routeWaypointsMapper.insertRouteWaypoints(wp);
}
}
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackWaypointUpdate(ObjectOperationLog log) {
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class);
if (before == null || before.getId() == null) return false;
routeWaypointsMapper.updateRouteWaypoints(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackRouteReinsert(ObjectOperationLog log) {
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class);
if (before == null) return false;
before.setId(null);
routesMapper.insertRoutes(before);
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) {
for (RouteWaypoints wp : before.getWaypoints()) {
wp.setId(null);
wp.setRouteId(before.getId());
routeWaypointsMapper.insertRouteWaypoints(wp);
}
}
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackWaypointReinsert(ObjectOperationLog log) {
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class);
if (before == null) return false;
before.setId(null);
routeWaypointsMapper.insertRouteWaypoints(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackPlatformUpdate(ObjectOperationLog log) {
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class);
if (before == null || before.getId() == null) return false;
platformLibMapper.updatePlatformLib(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackPlatformReinsert(ObjectOperationLog log) {
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class);
if (before == null || before.getId() == null) return false;
// 如果该 ID 已经存在(例如之前已手动恢复过),则视为已回滚成功,避免主键冲突
PlatformLib existed = platformLibMapper.selectPlatformLibById(before.getId());
if (existed == null) {
platformLibMapper.insertPlatformLibWithId(before);
}
invalidateRoomCache(log.getRoomId());
return true;
}
private void invalidateRoomCache(Long roomId) {
if (roomId == null) return;
String key = REDIS_KEY_PREFIX + roomId;
redisTemplate.delete(key);
}
}

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>

56
ruoyi-system/src/main/resources/mapper/system/ObjectOperationLogMapper.xml

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.ObjectOperationLogMapper">
<resultMap type="ObjectOperationLog" id="ObjectOperationLogResult">
<id property="id" column="id" />
<result property="roomId" column="room_id" />
<result property="operatorId" column="operator_id" />
<result property="operatorName" column="operator_name" />
<result property="operationType" column="operation_type"/>
<result property="objectType" column="object_type" />
<result property="objectId" column="object_id" />
<result property="objectName" column="object_name" />
<result property="detail" column="detail" />
<result property="snapshotBefore" column="snapshot_before"/>
<result property="snapshotAfter" column="snapshot_after" />
<result property="kTime" column="k_time" />
<result property="createdAt" column="created_at" />
</resultMap>
<sql id="selectVo">
select id, room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name,
detail, snapshot_before, snapshot_after, k_time, created_at
from object_operation_log
</sql>
<insert id="insert" parameterType="ObjectOperationLog" useGeneratedKeys="true" keyProperty="id">
insert into object_operation_log (room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name, detail, snapshot_before, snapshot_after, k_time)
values (#{roomId}, #{operatorId}, #{operatorName}, #{operationType}, #{objectType}, #{objectId}, #{objectName}, #{detail}, #{snapshotBefore}, #{snapshotAfter}, #{kTime})
</insert>
<select id="selectById" resultMap="ObjectOperationLogResult">
<include refid="selectVo"/> where id = #{id}
</select>
<select id="selectPage" parameterType="ObjectOperationLog" resultMap="ObjectOperationLogResult">
<include refid="selectVo"/>
<where>
<if test="roomId != null"> and room_id = #{roomId} </if>
<if test="operatorName != null and operatorName != ''"> and operator_name like concat('%', #{operatorName}, '%') </if>
<if test="operationType != null"> and operation_type = #{operationType} </if>
<if test="objectType != null and objectType != ''"> and object_type = #{objectType} </if>
<if test="params != null and params.beginTime != null and params.beginTime != ''"> and created_at &gt;= #{params.beginTime} </if>
<if test="params != null and params.endTime != null and params.endTime != ''"> and created_at &lt;= #{params.endTime} </if>
</where>
order by created_at desc
</select>
<delete id="deleteById" parameterType="Long">
delete from object_operation_log where id = #{id}
</delete>
<delete id="deleteByRoomIdAfterId">
delete from object_operation_log where room_id = #{roomId} and id > #{afterId}
</delete>
</mapper>

19
ruoyi-system/src/main/resources/mapper/system/PlatformLibMapper.xml

@ -68,4 +68,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id}
</foreach>
</delete>
<!-- 删除回滚场景下,按原 ID 重新插入平台模版库 -->
<insert id="insertPlatformLibWithId" parameterType="PlatformLib">
insert into platform_lib
<trim prefix="(" suffix=")" suffixOverrides=",">
id,
<if test="name != null and name != ''">name,</if>
<if test="type != null and type != ''">type,</if>
<if test="specsJson != null and specsJson != ''">specs_json,</if>
<if test="iconUrl != null and iconUrl != ''">icon_url,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
#{id},
<if test="name != null and name != ''">#{name},</if>
<if test="type != null and type != ''">#{type},</if>
<if test="specsJson != null and specsJson != ''">#{specsJson},</if>
<if test="iconUrl != null and iconUrl != ''">#{iconUrl},</if>
</trim>
</insert>
</mapper>

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>

2
ruoyi-ui/package.json

@ -36,9 +36,11 @@
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"html2canvas": "^1.4.1",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"jspdf": "^2.5.2",
"mammoth": "^1.11.0",
"nprogress": "0.2.0",
"quill": "2.0.2",

19
ruoyi-ui/src/api/system/lib.js

@ -21,31 +21,34 @@ export function getLib(id) {
})
}
// 新增平台模版库
export function addLib(data) {
// 新增平台模版库(可选 params.roomId 用于对象级操作日志)
export function addLib(data, params = {}) {
return request({
url: '/system/lib/add',
method: 'post',
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 修改平台模版库
export function updateLib(data) {
// 修改平台模版库(可选 params.roomId)
export function updateLib(data, params = {}) {
return request({
url: '/system/lib',
method: 'put',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 删除平台模版库
export function delLib(id) {
// 删除平台模版库(可选 params.roomId)
export function delLib(id, params = {}) {
return request({
url: '/system/lib/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}

23
ruoyi-ui/src/api/system/objectLog.js

@ -0,0 +1,23 @@
import request from '@/utils/request'
/**
* 对象级操作日志分页查询按房间操作人类型等
*/
export function listObjectLog(params) {
return request({
url: '/system/object-log/list',
method: 'get',
params
})
}
/**
* 回滚到指定操作数据库 + Redis 同步
*/
export function rollbackObjectLog(id) {
return request({
url: '/system/object-log/rollback',
method: 'post',
params: { id }
})
}

38
ruoyi-ui/src/api/system/routes.js

@ -17,30 +17,33 @@ export function getRoutes(id) {
})
}
// 新增实体部署与航线
export function addRoutes(data) {
// 新增实体部署与航线(可选 params.roomId 用于对象级操作日志按房间记录)
export function addRoutes(data, params = {}) {
return request({
url: '/system/routes',
method: 'post',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 修改实体部署与航线(禁用防重复提交,航线编辑可能快速连续触发
export function updateRoutes(data) {
// 修改实体部署与航线(可选 params.roomId;禁用防重复提交)
export function updateRoutes(data, params = {}) {
return request({
url: '/system/routes',
method: 'put',
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: { repeatSubmit: false }
})
}
// 删除实体部署与航线
export function delRoutes(id) {
// 删除实体部署与航线(可选 params.roomId)
export function delRoutes(id, params = {}) {
return request({
url: '/system/routes/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
@ -100,6 +103,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({

19
ruoyi-ui/src/api/system/waypoints.js

@ -17,29 +17,32 @@ export function getWaypoints(id) {
})
}
// 新增航线具体航点明细
export function addWaypoints(data) {
// 新增航线具体航点明细(可选 params.roomId 用于对象级操作日志)
export function addWaypoints(data, params = {}) {
return request({
url: '/system/waypoints',
method: 'post',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 修改航线具体航点明细(禁用防重复提交,拖拽/批量保存可能快速连续触发
export function updateWaypoints(data) {
// 修改航线具体航点明细(可选 params.roomId;禁用防重复提交)
export function updateWaypoints(data, params = {}) {
return request({
url: '/system/waypoints',
method: 'put',
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: { repeatSubmit: false }
})
}
// 删除航线具体航点明细
export function delWaypoints(id) {
// 删除航线具体航点明细(可选 params.roomId)
export function delWaypoints(id, params = {}) {
return request({
url: '/system/waypoints/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}

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

7
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',
@ -154,6 +156,7 @@ export default {
inputMessage: 'Please enter message...',
send: 'Send',
pleaseInputMessage: 'Please enter message content',
operationRollbackSuccess: 'Operation rollback successful'
operationRollbackSuccess: 'Operation rollback successful',
noLogs: 'No operation logs'
}
}

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

@ -21,7 +21,9 @@ export default {
importATO: '导入ATO',
importLayer: '导入图层',
importRoute: '导入航线',
export: '导出'
export: '导出',
exportRoute: '导出航线',
exportPlan: '导出计划'
},
edit: {
routeEdit: '航线编辑',
@ -154,6 +156,7 @@ export default {
inputMessage: '请输入消息...',
send: '发送',
pleaseInputMessage: '请输入消息内容',
operationRollbackSuccess: '操作回滚成功'
operationRollbackSuccess: '操作回滚成功',
noLogs: '暂无操作日志'
}
}

6
ruoyi-ui/src/plugins/dialogDrag.js

@ -22,6 +22,10 @@ function startDrag(e, container) {
container.style.top = top + 'px'
container.style.margin = '0'
container.style.transform = 'none'
/* 锁定宽高,避免拖拽标题栏时弹窗尺寸被改变(只做位移) */
container.style.width = rect.width + 'px'
container.style.height = rect.height + 'px'
container.style.boxSizing = 'border-box'
const onMouseMove = (e2) => {
e2.preventDefault()
const dx = e2.clientX - startX
@ -46,6 +50,8 @@ function startDrag(e, container) {
function onDocumentMouseDown(e) {
if (e.button !== 0) return
if (e.target.closest('.el-dialog__headerbtn') || e.target.closest('.el-message-box__headerbtn') || e.target.closest('.close-btn')) return
/* 右下角改尺寸手柄:不触发拖拽,只做缩放 */
if (e.target.closest('.gantt-dialog-resize-handle')) return
const elHeader = e.target.closest('.el-dialog__header')
if (elHeader) {
const wrapper = elHeader.closest('.el-dialog__wrapper')

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;

951
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;

1047
ruoyi-ui/src/views/childRoom/GanttDrawer.vue

File diff suppressed because it is too large

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>

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

File diff suppressed because it is too large

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>

412
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

@ -69,45 +69,98 @@
<div class="operation-logs">
<div class="logs-header">
<h4>{{ $t('onlineMembersDialog.objectOperationLogs') }}</h4>
<el-button
type="warning"
size="mini"
@click="showRollbackConfirm"
:disabled="operationLogs.length === 0"
>
<i class="el-icon-refresh-right"></i> {{ $t('onlineMembersDialog.rollbackOperation') }}
</el-button>
</div>
<el-timeline>
<el-timeline-item
v-for="log in operationLogs"
:key="log.id"
:timestamp="log.time"
:type="log.type"
>
<div class="log-content">
<div class="log-user">{{ log.user }}</div>
<div class="log-action">{{ log.action }}</div>
<div class="log-object">{{ $t('onlineMembersDialog.objectOperationLogs') }}{{ log.object }}</div>
<div class="log-detail">{{ log.detail }}</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-loading="loadingLogs" class="logs-body">
<el-timeline v-if="operationLogs.length">
<el-timeline-item
v-for="log in operationLogs"
:key="log.id"
:timestamp="logDisplayTime(log)"
:type="logDisplayType(log)"
>
<div
class="log-content"
@click="openLogDetail(log)"
>
<div class="log-user">{{ log.operatorName || log.user }}</div>
<div class="log-action">{{ logDisplayAction(log) }}</div>
<div class="log-object">操作对象{{ log.objectName || log.object }}</div>
<div class="log-detail">{{ log.detail }}</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="logs-empty">{{ $t('onlineMembersDialog.noLogs') || '暂无操作日志' }}</div>
<el-pagination
v-if="logTotal > 0"
class="log-pagination"
:current-page="logPageNum"
:page-sizes="[10, 20, 50]"
:page-size="logPageSize"
:total="logTotal"
layout="total, sizes, prev, pager, next"
@size-change="handleLogSizeChange"
@current-change="handleLogPageChange"
/>
</div>
<!-- 日志详情弹窗展示详细信息并提供回滚/取消按钮 -->
<el-dialog
:title="$t('onlineMembersDialog.rollbackConfirm')"
:visible.sync="showRollbackDialog"
width="400px"
center
:title="$t('onlineMembersDialog.objectOperationLogs')"
:visible.sync="showLogDetailDialog"
width="560px"
append-to-body
>
<div class="rollback-confirm">
<p>{{ $t('onlineMembersDialog.rollbackConfirmText') }}</p>
<p class="text-warning mt-2">{{ $t('onlineMembersDialog.rollbackWarning') }}</p>
<div v-if="activeLog" class="log-detail-dialog">
<div class="detail-row">
<span class="detail-label">操作人</span>
<span class="detail-value">{{ activeLog.operatorName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">操作类型</span>
<span class="detail-value">{{ logDisplayAction(activeLog) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">对象类型</span>
<span class="detail-value">{{ displayObjectType(activeLog) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">操作对象</span>
<span class="detail-value">{{ activeLog.objectName || activeLog.objectId }}</span>
</div>
<div class="detail-row">
<span class="detail-label">时间</span>
<span class="detail-value">{{ logDisplayTime(activeLog) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">描述</span>
<span class="detail-value">{{ activeLog.detail }}</span>
</div>
<el-divider />
<div v-if="fieldChanges.length" class="detail-changes">
<div class="detail-json-title">关键字段变更</div>
<div
v-for="c in fieldChanges"
:key="c.field"
class="change-row"
>
<span class="detail-label">{{ c.label || c.field }}</span>
<el-input
class="change-input"
size="mini"
:value="formatChangeSummary(c)"
readonly
/>
</div>
</div>
<div v-if="fieldChanges.length === 0 && !activeLog.snapshotBefore && !activeLog.snapshotAfter" class="detail-json-empty">
暂无变更明细仅记录了概要描述
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="showRollbackDialog = false">{{ $t('leftMenu.cancel') }}</el-button>
<el-button type="primary" @click="rollbackOperation">{{ $t('onlineMembersDialog.confirmRollback') }}</el-button>
<el-button @click="showLogDetailDialog = false">{{ $t('leftMenu.cancel') }}</el-button>
<el-button type="primary" @click="confirmRollbackFromDetail" :disabled="!activeLog">
{{ $t('onlineMembersDialog.rollbackOperation') }}
</el-button>
</span>
</el-dialog>
</div>
@ -200,7 +253,10 @@
</template>
<script>
import { listObjectLog, rollbackObjectLog } from '@/api/system/objectLog'
const STORAGE_KEY_PREFIX = 'onlineMembersPanel_'
const OP_TYPE_MAP = { 1: '新增', 2: '修改', 3: '删除', 4: '选择', 5: '回滚' }
export default {
name: 'OnlineMembersDialog',
@ -275,54 +331,15 @@ export default {
selectedObject: 'Alpha进场航线',
selectedCount: 1,
//
operationLogs: [
{
id: 1,
user: '张三',
action: '修改',
object: 'J-20 歼击机',
detail: '更新了速度参数从800km/h到850km/h',
time: 'K+00:45:23',
type: 'success'
},
{
id: 2,
user: '李四',
action: '选择',
object: 'Alpha进场航线',
detail: '选中了Alpha进场航线进行编辑',
time: 'K+00:42:15',
type: 'primary'
},
{
id: 3,
user: '王五',
action: '添加',
object: 'WP5',
detail: '在Beta巡逻航线上添加了新航点WP5',
time: 'K+00:38:47',
type: 'info'
},
{
id: 4,
user: '赵六',
action: '删除',
object: '旧侦察航线',
detail: '删除了过期的侦察航线',
time: 'K+00:35:12',
type: 'warning'
},
{
id: 5,
user: '孙七',
action: '修改',
object: 'HQ-9防空系统',
detail: '更新了射程参数从120km到150km',
time: 'K+00:32:08',
type: 'success'
}
],
//
operationLogs: [],
logTotal: 0,
logPageNum: 1,
logPageSize: 10,
loadingLogs: false,
showLogDetailDialog: false,
activeLog: null,
fieldChanges: [],
newMessage: '',
chatMode: 'group',
@ -455,16 +472,192 @@ export default {
this.savePosition();
},
showRollbackConfirm() {
this.showRollbackDialog = true;
fetchOperationLogs() {
this.loadingLogs = true;
listObjectLog({
roomId: this.roomId ? Number(this.roomId) : undefined,
pageNum: this.logPageNum,
pageSize: this.logPageSize
}).then(res => {
this.operationLogs = (res.rows || []).map(row => ({ ...row }));
this.logTotal = res.total || 0;
}).catch(() => {
this.operationLogs = [];
this.logTotal = 0;
}).finally(() => {
this.loadingLogs = false;
});
},
handleLogSizeChange(size) {
this.logPageSize = size;
this.logPageNum = 1;
this.fetchOperationLogs();
},
handleLogPageChange(page) {
this.logPageNum = page;
this.fetchOperationLogs();
},
logDisplayTime(log) {
return log.kTime || (log.createdAt || log.createTime || '').replace('T', ' ');
},
logDisplayType(log) {
const t = log.operationType;
if (t === 3) return 'warning';
if (t === 1) return 'success';
return 'primary';
},
logDisplayAction(log) {
return OP_TYPE_MAP[log.operationType] || log.action || '操作';
},
openLogDetail(log) {
this.activeLog = log;
this.fieldChanges = this.computeFieldChanges(log);
this.showLogDetailDialog = true;
},
confirmRollbackFromDetail() {
if (!this.activeLog || !this.activeLog.id) return;
const id = this.activeLog.id;
rollbackObjectLog(id).then(() => {
this.$message.success(this.$t('onlineMembersDialog.operationRollbackSuccess'));
this.showLogDetailDialog = false;
this.activeLog = null;
this.fetchOperationLogs();
this.$emit('rollback-done');
}).catch(err => {
this.$message.error(err.msg || '回滚失败');
});
},
rollbackOperation() {
this.showRollbackDialog = false;
this.$message.success(this.$t('onlineMembersDialog.operationRollbackSuccess'));
//
formatJson(val) {
if (!val) return '';
try {
const obj = typeof val === 'string' ? JSON.parse(val) : val;
return JSON.stringify(obj, null, 2);
} catch (e) {
return String(val);
}
},
displayObjectType(log) {
if (!log || !log.objectType) return '';
if (log.objectType === 'route') return '航线';
if (log.objectType === 'waypoint') return '航点';
if (log.objectType === 'platform') return '平台模板';
return log.objectType;
},
computeFieldChanges(log) {
const changes = [];
if (!log) return changes;
let before = null;
let after = null;
try {
if (log.snapshotBefore) before = typeof log.snapshotBefore === 'string' ? JSON.parse(log.snapshotBefore) : log.snapshotBefore;
} catch (e) {}
try {
if (log.snapshotAfter) after = typeof log.snapshotAfter === 'string' ? JSON.parse(log.snapshotAfter) : log.snapshotAfter;
} catch (e) {}
const keys = new Set();
if (before) Object.keys(before).forEach(k => keys.add(k));
if (after) Object.keys(after).forEach(k => keys.add(k));
const prefer = ['name', 'type', 'callSign', 'iconUrl', 'specsJson'];
const orderedKeys = [...prefer, ...[...keys].filter(k => !prefer.includes(k))];
orderedKeys.forEach(k => {
const bv = before ? before[k] : undefined;
const av = after ? after[k] : undefined;
if (bv === undefined && av === undefined) return;
if (k === 'attributes') {
const subChanges = this.computeAttributesChanges(bv, av);
subChanges.forEach(c => changes.push(c));
return;
}
if (JSON.stringify(bv) === JSON.stringify(av)) return;
const labelMap = {
name: '名称',
type: '类型',
callSign: '呼号',
iconUrl: '图标',
specsJson: '参数(JSON)'
};
changes.push({
field: k,
label: labelMap[k] || k,
before: bv,
after: av
});
});
return changes;
},
/** 将 attributes JSON 展开为「航点大小」「填充颜色」等可读项,避免整段 JSON 横向滚动 */
computeAttributesChanges(beforeVal, afterVal) {
const changes = [];
const parse = (v) => {
if (v == null) return null;
if (typeof v === 'string') {
try { return JSON.parse(v); } catch (e) { return null; }
}
return v && typeof v === 'object' ? v : null;
};
const b = parse(beforeVal);
const a = parse(afterVal);
if (!b && !a) return changes;
const flat = (obj, prefix = '') => {
const out = {};
if (!obj || typeof obj !== 'object') return out;
Object.keys(obj).forEach(key => {
const val = obj[key];
const path = prefix ? prefix + '.' + key : key;
if (val !== null && typeof val === 'object' && !Array.isArray(val) && (key === 'waypointStyle' || key === 'lineStyle' || key === 'waypoint' || key === 'line')) {
Object.assign(out, flat(val, path));
} else {
out[path] = val;
}
});
return out;
};
const bFlat = flat(b);
const aFlat = flat(a);
const allPaths = new Set([...Object.keys(bFlat), ...Object.keys(aFlat)]);
const attrLabelMap = {
'waypointStyle.pixelSize': '航点大小',
'waypointStyle.color': '航点填充颜色',
'waypointStyle.outlineColor': '航点边框颜色',
'waypointStyle.outlineWidth': '航点边框粗细',
'lineStyle.style': '航线线型',
'lineStyle.width': '航线线宽',
'lineStyle.color': '航线颜色',
'lineStyle.gapColor': '航线间隔色',
'lineStyle.dashLength': '航线虚线长度'
};
[...allPaths].sort().forEach(path => {
const bv = bFlat[path];
const av = aFlat[path];
if (JSON.stringify(bv) === JSON.stringify(av)) return;
changes.push({
field: path,
label: attrLabelMap[path] || path,
before: bv,
after: av
});
});
return changes;
},
formatChangeSummary(c) {
const b = c.before;
const a = c.after;
const fmt = (v) => {
if (v === undefined || v === null) return '';
if (typeof v === 'object') {
if (Array.isArray(v)) return `数组(${v.length}项)`;
return '对象';
}
return String(v);
};
if (b === undefined && a !== undefined) {
return `新值:${fmt(a)}`;
}
if (b !== undefined && a === undefined) {
return `旧值:${fmt(b)}`;
}
return `旧:${fmt(b)} 新:${fmt(a)}`;
},
//
sendMessage() {
const text = this.newMessage.trim();
@ -522,9 +715,13 @@ export default {
},
watch: {
value(val) {
if (val) this.loadPosition();
if (val) {
this.loadPosition();
if (this.activeTab === 'logs') this.fetchOperationLogs();
}
},
activeTab(newVal) {
if (newVal === 'logs') this.fetchOperationLogs();
if (newVal === 'chat') {
this.$nextTick(() => this.scrollChatToBottom());
}
@ -770,8 +967,9 @@ export default {
/* 操作日志样式 */
.operation-logs {
position: relative;
max-height: 400px;
overflow-y: auto;
max-height: 480px;
display: flex;
flex-direction: column;
}
.logs-header {
@ -788,11 +986,41 @@ export default {
color: #333;
}
.logs-body {
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.logs-empty {
padding: 24px;
text-align: center;
color: #999;
font-size: 13px;
}
.log-pagination {
margin-top: 12px;
padding: 8px 0;
text-align: right;
}
.log-content {
background: rgba(240, 242, 245, 0.8);
padding: 12px;
border-radius: 6px;
margin-left: 16px;
cursor: pointer;
border: 2px solid transparent;
}
.log-content:hover {
background: rgba(230, 235, 242, 0.9);
}
.log-content.selected {
border-color: #e6a23c;
background: rgba(253, 246, 236, 0.95);
}
.log-user {

2
ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue

@ -344,7 +344,7 @@ export default {
iconUrl: this.formData.imageUrl,
specsJson: JSON.stringify(finalSpecs)
};
updateLib(postData).then(response => {
updateLib(postData, { roomId: this.roomId }).then(response => {
//
this.$modal.msgSuccess("修改成功");
//

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

19
sql/object_operation_log.sql

@ -0,0 +1,19 @@
-- 对象级操作日志表(航线、航点、平台等,支持回滚)
-- 执行前请根据实际库名修改
CREATE TABLE IF NOT EXISTS object_operation_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
room_id BIGINT DEFAULT NULL COMMENT '房间ID,非房间维度可为空',
operator_id BIGINT DEFAULT NULL COMMENT '操作人用户ID',
operator_name VARCHAR(64) DEFAULT '' COMMENT '操作人姓名',
operation_type TINYINT NOT NULL COMMENT '操作类型:1新增 2修改 3删除 4选择',
object_type VARCHAR(32) NOT NULL COMMENT '操作对象类型:route/waypoint/platform',
object_id VARCHAR(64) DEFAULT NULL COMMENT '业务对象ID(如航线ID、航点ID)',
object_name VARCHAR(255) DEFAULT '' COMMENT '对象显示名(如呼号、航点名)',
detail VARCHAR(500) DEFAULT '' COMMENT '详细操作描述',
snapshot_before TEXT DEFAULT NULL COMMENT '操作前快照JSON,用于回滚',
snapshot_after TEXT DEFAULT NULL COMMENT '操作后快照JSON',
k_time VARCHAR(32) DEFAULT NULL COMMENT '相对时间如 K+00:45:23',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_room_created (room_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对象级操作日志(支持回滚)';
Loading…
Cancel
Save