Compare commits
7 Commits
ef58d663b5
...
8d9f2f74c7
| Author | SHA1 | Date |
|---|---|---|
|
|
8d9f2f74c7 | 3 weeks ago |
|
|
47ccdea3aa | 3 weeks ago |
|
|
37c517b086 | 3 weeks ago |
|
|
afe9fc768d | 3 weeks ago |
|
|
d67e853223 | 4 weeks ago |
|
|
9b939f4112 | 4 weeks ago |
|
|
9032009bea | 4 weeks ago |
52 changed files with 8634 additions and 544 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"git.ignoreLimitWarning": true |
|||
} |
|||
@ -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 ""; |
|||
} |
|||
} |
|||
} |
|||
@ -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("删除失败"); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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 >= #{params.beginTime} </if> |
|||
<if test="params != null and params.endTime != null and params.endTime != ''"> and created_at <= #{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> |
|||
@ -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 } |
|||
}) |
|||
} |
|||
@ -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' |
|||
}) |
|||
} |
|||
@ -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 |
|||
} |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -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> |
|||
File diff suppressed because it is too large
@ -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 |
|||
} |
|||
] |
|||
} |
|||
@ -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 } |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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…
Reference in new issue