Browse Source

操作日志记录与回滚、甘特图逻辑重绘

mh
menghao 3 weeks ago
parent
commit
8d9f2f74c7
  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. 81
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  6. 88
      ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java
  7. 23
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/ObjectOperationLogMapper.java
  8. 8
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PlatformLibMapper.java
  9. 33
      ruoyi-system/src/main/java/com/ruoyi/system/service/IObjectOperationLogService.java
  10. 219
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java
  11. 56
      ruoyi-system/src/main/resources/mapper/system/ObjectOperationLogMapper.xml
  12. 19
      ruoyi-system/src/main/resources/mapper/system/PlatformLibMapper.xml
  13. 2
      ruoyi-ui/package.json
  14. 19
      ruoyi-ui/src/api/system/lib.js
  15. 23
      ruoyi-ui/src/api/system/objectLog.js
  16. 19
      ruoyi-ui/src/api/system/routes.js
  17. 19
      ruoyi-ui/src/api/system/waypoints.js
  18. 3
      ruoyi-ui/src/lang/en.js
  19. 3
      ruoyi-ui/src/lang/zh.js
  20. 6
      ruoyi-ui/src/plugins/dialogDrag.js
  21. 6
      ruoyi-ui/src/views/cesiumMap/index.vue
  22. 1047
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  23. 187
      ruoyi-ui/src/views/childRoom/index.vue
  24. 412
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  25. 2
      ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue
  26. 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));
}
}

81
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
@ -337,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) {

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

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

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>

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

19
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 } : {}
})
}

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

3
ruoyi-ui/src/lang/en.js

@ -156,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'
}
}

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

@ -156,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')

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

@ -508,7 +508,7 @@ export default {
missileDialogVisible: false,
missileForm: {
angle: 0,
distance: 100,
distance: 1000,
platformHeadingDeg: 0
},
/** 导弹预览:弹窗打开时显示的轨迹线与朝向图标,关闭时移除 */
@ -4078,7 +4078,7 @@ export default {
}
const platformHeadingDeg = Number(this.missileForm.platformHeadingDeg) || 0
const angle = Number(this.missileForm.angle) || 0
const distance = Number(this.missileForm.distance) || 100
const distance = Number(this.missileForm.distance) || 1000
const actualBearingDeg = ((platformHeadingDeg + angle) % 360 + 360) % 360
const brng = Cesium.Math.toRadians(actualBearingDeg)
const R = 6371000
@ -4252,7 +4252,7 @@ export default {
const startLat = item.startLat != null ? Number(item.startLat) : null
if (launchK == null || startLng == null || startLat == null) continue
const angle = Number(item.angle) || 0
const distance = Number(item.distance) || 100
const distance = Number(item.distance) || 1000
const platformHeadingDeg = Number(item.platformHeadingDeg) || 0
const actualBearingDeg = ((platformHeadingDeg + angle) % 360 + 360) % 360
const R = 6371000

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

File diff suppressed because it is too large

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

@ -322,6 +322,7 @@
:send-private-chat="sendPrivateChat"
:send-private-chat-history-request="sendPrivateChatHistoryRequest"
:current-user-id="currentUserId"
@rollback-done="onOperationRollbackDone"
/>
<!-- 平台编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
@ -396,6 +397,17 @@
@import="handleImportRoutes"
/>
<!-- 甘特图抽屉底部K-时轴与航线/任务列表联动 -->
<gantt-drawer
v-model="showGanttDrawer"
:time-range="ganttTimeRange"
:route-bars="ganttRouteBars"
:hold-bars="ganttHoldBars"
:current-room-id="currentRoomId"
:routes="routes"
:active-route-ids="activeRouteIds"
/>
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击4T图标时打开 -->
<four-t-panel
v-if="show4TPanel && !screenshotMode"
@ -497,9 +509,11 @@ import { listWhiteboards, getWhiteboard, createWhiteboard, updateWhiteboard, del
import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue";
import ExportRoutesDialog from "@/views/dialogs/ExportRoutesDialog.vue";
import ImportRoutesDialog from "@/views/dialogs/ImportRoutesDialog.vue";
import GanttDrawer from './GanttDrawer.vue';
export default {
name: 'MissionPlanningView',
components: {
GanttDrawer,
PlatformImportDialog,
ExportRoutesDialog,
ImportRoutesDialog,
@ -730,6 +744,10 @@ export default {
timelineHoverPercent: 0,
/** 导弹从 Redis 加载过的房间+航线组合 key,避免重复加载 */
_missilesLoadKey: null,
/** 甘特图抽屉显示 */
showGanttDrawer: false,
/** 甘特图按方案着色:同一方案一种颜色,不同方案不同颜色(scenarioId -> color) */
ganttScenarioColors: {},
//
userAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
@ -773,6 +791,30 @@ export default {
this.wsConnection.sendObjectEditUnlock('route', this.routeEditLockedId);
this.routeEditLockedId = null;
}
},
/** 打开甘特图抽屉时按方案加载颜色:同一方案取一种颜色(用该方案下第一条航线的平台色),不同方案不同颜色 */
showGanttDrawer(visible) {
if (!visible) return;
this.ganttScenarioColors = {};
if (!this.currentRoomId || !this.activeRouteIds.length) return;
const seenScenario = new Set();
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || route.platformId == null) return;
const sid = route.scenarioId;
if (sid != null && seenScenario.has(sid)) return;
seenScenario.add(sid);
getPlatformStyle({
roomId: this.currentRoomId,
routeId: route.id,
platformId: route.platformId
}).then(res => {
const s = res.data;
if (s && s.platformColor) {
this.$set(this.ganttScenarioColors, sid, s.platformColor);
}
}).catch(() => {});
});
}
},
computed: {
@ -816,6 +858,83 @@ export default {
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。';
return `${this.addHoldContext.fromName}${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`;
},
/** 甘特图:K 时范围(与推演时间轴一致,随航线/航点变化实时更新) */
ganttTimeRange() {
return this.getDeductionTimeRange();
},
/** 甘特图:当前展示航线的任务条(K-时起止 + 颜色与地图航线一致) */
ganttRouteBars() {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const bars = [];
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || !route.waypoints.length) return;
try {
const { segments } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, null);
if (!segments || segments.length === 0) return;
const startMinutes = segments[0].startTime;
const endMinutes = segments[segments.length - 1].endTime;
/* 同方案同色:按 scenarioId 取色,避免同一方案下航线444等与111/222/333颜色不一致 */
let color = (route.scenarioId != null && this.ganttScenarioColors[route.scenarioId])
? this.ganttScenarioColors[route.scenarioId]
: null;
if (!color) {
const style = this.parseRouteStyle(route.attributes);
if (style && style.line && style.line.color) color = style.line.color;
else if (style && style.waypoint && style.waypoint.color) color = style.waypoint.color;
}
if (!color || this.isGanttColorTooDark(color)) color = '#409EFF';
bars.push({
id: `route-${route.id}`,
name: route.name || route.callSign || `航线${route.id}`,
startMinutes,
endMinutes,
color
});
} catch (e) {
console.warn('ganttRouteBars buildRouteTimeline', routeId, e);
}
});
return bars;
},
/** 甘特图:当前展示航线中的盘旋段(与图例「盘旋」一致);需 pathData 才能算出盘旋段 */
ganttHoldBars() {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const bars = [];
const cesiumMap = this.$refs.cesiumMap;
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || !route.waypoints.length) return;
try {
let pathData = null;
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) {
const cachedRadii = (cesiumMap._routeHoldRadiiByRoute && cesiumMap._routeHoldRadiiByRoute[routeId]) ? cesiumMap._routeHoldRadiiByRoute[routeId] : {};
const cachedEllipse = (cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId]) ? cesiumMap._routeHoldEllipseParamsByRoute[routeId] : {};
const opts = (Object.keys(cachedRadii).length > 0 || Object.keys(cachedEllipse).length > 0) ? { holdRadiusByLegIndex: cachedRadii, holdEllipseParamsByLegIndex: cachedEllipse } : {};
const ret = cesiumMap.getRoutePathWithSegmentIndices(route.waypoints, opts);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0 && ret.holdArcRanges) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges };
}
}
const { segments } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
if (!segments) return;
const routeName = route.name || route.callSign || `航线${route.id}`;
segments.forEach((seg, idx) => {
if (seg.type !== 'hold') return;
bars.push({
id: `hold-${route.id}-${idx}`,
name: `${routeName}-盘旋`,
startMinutes: seg.startTime,
endMinutes: seg.endTime,
color: '#E6A23C'
});
});
} catch (e) {
console.warn('ganttHoldBars buildRouteTimeline', routeId, e);
}
});
return bars;
},
/** 白板模式下当前时间块应显示的实体(继承逻辑:当前时刻 = 上一时刻 + 本时刻差异) */
whiteboardDisplayEntities() {
if (!this.showWhiteboardPanel || !this.currentWhiteboard || !this.currentWhiteboardTimeBlock) return []
@ -970,11 +1089,11 @@ export default {
//
this.$modal.confirm('是否确认删除名称为 "' + platform.name + '" 的平台数据?').then(() => {
//
return delLib(platform.id);
return delLib(platform.id, { roomId: this.currentRoomId });
}).then(() => {
//
this.$modal.msgSuccess("删除成功");
//
//
this.getPlatformList();
}).catch(() => {
});
@ -1481,6 +1600,11 @@ export default {
showOnlineMembersDialog() {
this.showOnlineMembers = true;
},
onOperationRollbackDone() {
// 线/
this.getList();
this.getPlatformList();
},
sendChat(content) {
if (this.wsConnection && this.wsConnection.sendChat) {
this.wsConnection.sendChat(content);
@ -1873,7 +1997,7 @@ export default {
scenarioId: this.selectedPlanId,
createTime: new Date().getTime()
}));
addLib(data).then(response => {
addLib(data, { roomId: this.currentRoomId }).then(response => {
this.$modal.msgSuccess("导入成功");
this.showImportDialog = false;
this.getPlatformList();
@ -1925,6 +2049,29 @@ export default {
return null;
}
},
/** 甘特图用:判断颜色是否过深(黑/深灰),若是则改用图例蓝 #409EFF */
isGanttColorTooDark(color) {
if (!color || typeof color !== 'string') return true;
const s = color.trim().toLowerCase();
if (s === '#000' || s === '#000000' || s === 'black') return true;
const hex = s.match(/^#([0-9a-f]{6})$/);
if (hex) {
const r = parseInt(hex[1].slice(0, 2), 16);
const g = parseInt(hex[1].slice(2, 4), 16);
const b = parseInt(hex[1].slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.25;
}
const rgb = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) {
const r = Number(rgb[1]);
const g = Number(rgb[2]);
const b = Number(rgb[3]);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance < 0.25;
}
return false;
},
// 线
openRouteDialog(route) {
this.selectedRoute = route;
@ -1950,7 +2097,7 @@ export default {
platformId: updatedRoute.platformId || null,
attributes: updatedRoute.attributes != null ? updatedRoute.attributes : undefined
};
const res = await updateRoutes(apiData);
const res = await updateRoutes(apiData, { roomId: this.currentRoomId });
if (res.code === 200) {
const newRouteData = {
...this.routes[index],
@ -2064,7 +2211,7 @@ export default {
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
this.selectedRouteDetails = null;
}
const res = await delRoutes(route.id);
const res = await delRoutes(route.id, { roomId: this.currentRoomId });
if (res.code === 200) {
this.$message.success('删除成功');
// 线
@ -2278,7 +2425,7 @@ export default {
};
try {
const response = await addRoutes(routeData);
const response = await addRoutes(routeData, { roomId: this.currentRoomId });
if (response.code === 200) {
const savedRoute = response.data;
const newRouteId = savedRoute?.id;
@ -2822,7 +2969,7 @@ export default {
attributes: route.attributes || this.getDefaultRouteAttributes(),
waypoints: cleanWaypoints
};
const res = await addRoutes(payload);
const res = await addRoutes(payload, { roomId: this.currentRoomId });
if (res.code === 200) successCount++;
}
this.showImportRoutesDialog = false;
@ -3578,8 +3725,7 @@ export default {
},
generateGanttChart() {
const url = this.$router.resolve('/ganttChart').href
window.open(url, '_blank')
this.showGanttDrawer = true
},
systemDescription() {
@ -4403,23 +4549,32 @@ export default {
}
const path = pathData ? pathData.path : null;
const segmentEndIndices = pathData ? pathData.segmentEndIndices : null;
const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices);
const lastSeg = segments.length > 0 ? segments[segments.length - 1] : null;
const scheduledEndTime = waypoints.length > 0 ? this.waypointStartTimeToMinutes(waypoints[waypoints.length - 1].startTime) : null;
const effectiveMinutes = (scheduledEndTime != null && lastSeg != null && minutesFromK > scheduledEndTime && lastSeg.endTime > scheduledEndTime)
? scheduledEndTime
: minutesFromK;
if (effectiveMinutes !== minutesFromK) {
warnings.push('已过最后航点计划K时,平台未到达终点,存在时间冲突(盘旋或速度不足),平台已停止于该时刻位置。');
}
const position = this.getPositionFromTimeline(segments, effectiveMinutes, path, segmentEndIndices);
const stepMin = 1 / 60;
const nextPosition = this.getPositionFromTimeline(segments, minutesFromK + stepMin, path, segmentEndIndices);
const previousPosition = this.getPositionFromTimeline(segments, minutesFromK - stepMin, path, segmentEndIndices);
// speed
const nextPosition = this.getPositionFromTimeline(segments, effectiveMinutes + stepMin, path, segmentEndIndices);
const previousPosition = this.getPositionFromTimeline(segments, effectiveMinutes - stepMin, path, segmentEndIndices);
// speed effectiveMinutes
let currentSegment = null;
const segTime = effectiveMinutes;
if (segments && segments.length > 0) {
if (minutesFromK <= segments[0].startTime) {
if (segTime <= segments[0].startTime) {
const s = segments[0];
currentSegment = { legIndex: s.legIndex, speedKmh: waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800 };
} else if (minutesFromK >= segments[segments.length - 1].endTime) {
} else if (segTime >= segments[segments.length - 1].endTime) {
const s = segments[segments.length - 1];
currentSegment = { legIndex: s.legIndex, speedKmh: s.speedKmh != null ? s.speedKmh : (waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800) };
} else {
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
if (minutesFromK >= s.startTime && minutesFromK < s.endTime) {
if (segTime >= s.startTime && segTime < s.endTime) {
currentSegment = { legIndex: s.legIndex, speedKmh: s.speedKmh != null ? s.speedKmh : (waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800) };
break;
}

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("修改成功");
//

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