Browse Source

Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh

# Conflicts:
#	ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
#	ruoyi-ui/src/api/system/routes.js
#	ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
#	ruoyi-ui/src/views/cesiumMap/index.vue
mh
menghao 1 month ago
parent
commit
1fbdb403f3
  1. 44
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  2. 2
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java
  3. 81
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysTimelineSegmentController.java
  4. 89
      ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysTimelineSegment.java
  5. 17
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java
  6. 92
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/PlatformStyleDTO.java
  7. 22
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTimelineSegmentMapper.java
  8. 21
      ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTimelineSegmentService.java
  9. 57
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTimelineSegmentServiceImpl.java
  10. 1
      ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml
  11. 77
      ruoyi-system/src/main/resources/mapper/system/SysTimelineSegmentMapper.xml
  12. 19
      ruoyi-ui/src/api/system/routes.js
  13. 53
      ruoyi-ui/src/api/system/timeline.js
  14. 1
      ruoyi-ui/src/assets/icons/svg/T.svg
  15. 7
      ruoyi-ui/src/lang/en.js
  16. 7
      ruoyi-ui/src/lang/zh.js
  17. 5
      ruoyi-ui/src/router/index.js
  18. 117
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  19. 1678
      ruoyi-ui/src/views/cesiumMap/index.vue
  20. 16
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  21. 482
      ruoyi-ui/src/views/childRoom/BottomTimeline.vue
  22. 582
      ruoyi-ui/src/views/childRoom/FourTPanel.vue
  23. 20
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  24. 5
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  25. 603
      ruoyi-ui/src/views/childRoom/index.vue
  26. 209
      ruoyi-ui/src/views/dialogs/RouteEditDialog.vue
  27. 540
      ruoyi-ui/src/views/ganttChart/index.vue
  28. 22
      sql/sys_timeline_segment.sql

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

@ -25,6 +25,7 @@ import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.ArrayList;
@ -44,6 +45,10 @@ public class RoutesController extends BaseController
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
@Qualifier("fourTRedisTemplate")
private RedisTemplate<String, String> fourTRedisTemplate;
/**
* 保存平台样式到 Redis
*/
@ -78,6 +83,45 @@ public class RoutesController extends BaseController
}
/**
* 保存4T数据到 Redis按房间存储
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/save4TData")
public AjaxResult save4TData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":4t";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取4T数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/get4TData")
public AjaxResult get4TData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":4t";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* 获取导弹发射参数列表Redis房间+航线+平台为 key值为数组每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")

2
ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java

@ -9,6 +9,7 @@ import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
@ -32,6 +33,7 @@ import com.ruoyi.system.domain.SysCache;
public class CacheController
{
@Autowired
@Qualifier("stringRedisTemplate")
private RedisTemplate<String, String> redisTemplate;
private final static List<SysCache> caches = new ArrayList<SysCache>();

81
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysTimelineSegmentController.java

@ -0,0 +1,81 @@
package com.ruoyi.web.controller.system;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
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.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.service.ISysTimelineSegmentService;
@RestController
@RequestMapping("/system/timeline")
public class SysTimelineSegmentController extends BaseController
{
@Autowired
private ISysTimelineSegmentService segmentService;
@PreAuthorize("@ss.hasPermi('system:timeline:list')")
@GetMapping("/list")
public AjaxResult list(SysTimelineSegment segment)
{
List<SysTimelineSegment> list = segmentService.selectSegmentList(segment);
return success(list);
}
@GetMapping("/listByRoomId/{roomId}")
public AjaxResult listByRoomId(@PathVariable Long roomId)
{
List<SysTimelineSegment> list = segmentService.selectSegmentListByRoomId(roomId);
return success(list);
}
@PreAuthorize("@ss.hasPermi('system:timeline:query')")
@GetMapping(value = "/{segmentId}")
public AjaxResult getInfo(@PathVariable Long segmentId)
{
return success(segmentService.selectSegmentById(segmentId));
}
@PreAuthorize("@ss.hasPermi('system:timeline:add')")
@Log(title = "时间轴管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody SysTimelineSegment segment)
{
return toAjax(segmentService.insertSegment(segment));
}
@PreAuthorize("@ss.hasPermi('system:timeline:edit')")
@Log(title = "时间轴管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody SysTimelineSegment segment)
{
return toAjax(segmentService.updateSegment(segment));
}
@PreAuthorize("@ss.hasPermi('system:timeline:remove')")
@Log(title = "时间轴管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{segmentId}")
public AjaxResult remove(@PathVariable Long segmentId)
{
return toAjax(segmentService.deleteSegmentById(segmentId));
}
@PreAuthorize("@ss.hasPermi('system:timeline:remove')")
@Log(title = "时间轴管理", businessType = BusinessType.DELETE)
@DeleteMapping("/room/{roomId}")
public AjaxResult removeByRoomId(@PathVariable Long roomId)
{
return toAjax(segmentService.deleteSegmentByRoomId(roomId));
}
}

89
ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysTimelineSegment.java

@ -0,0 +1,89 @@
package com.ruoyi.common.core.domain.entity;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
public class SysTimelineSegment
{
private static final long serialVersionUID = 1L;
private Long segmentId;
private Long roomId;
@NotBlank(message = "时间点不能为空")
@Size(min = 0, max = 8, message = "时间点长度不能超过8个字符")
private String segmentTime;
@NotBlank(message = "时间段名称不能为空")
@Size(min = 0, max = 50, message = "时间段名称长度不能超过50个字符")
private String segmentName;
@Size(max = 500, message = "时间段描述长度不能超过500个字符")
private String segmentDesc;
public Long getSegmentId()
{
return segmentId;
}
public void setSegmentId(Long segmentId)
{
this.segmentId = segmentId;
}
@NotNull(message = "房间ID不能为空")
public Long getRoomId()
{
return roomId;
}
public void setRoomId(Long roomId)
{
this.roomId = roomId;
}
public String getSegmentTime()
{
return segmentTime;
}
public void setSegmentTime(String segmentTime)
{
this.segmentTime = segmentTime;
}
public String getSegmentName()
{
return segmentName;
}
public void setSegmentName(String segmentName)
{
this.segmentName = segmentName;
}
public String getSegmentDesc()
{
return segmentDesc;
}
public void setSegmentDesc(String segmentDesc)
{
this.segmentDesc = segmentDesc;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("segmentId", getSegmentId())
.append("roomId", getRoomId())
.append("segmentTime", getSegmentTime())
.append("segmentName", getSegmentName())
.append("segmentDesc", getSegmentDesc())
.toString();
}
}

17
ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java

@ -63,6 +63,23 @@ public class RedisConfig extends CachingConfigurerSupport
return template;
}
/**
* 纯字符串 RedisTemplate用于存储 4T JSON 字符串避免 FastJson 序列化问题
* 命名为 fourTRedisTemplate 避免与 Spring Boot 自带的 stringRedisTemplate 冲突
*/
@Bean("fourTRedisTemplate")
public RedisTemplate<String, String> fourTRedisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean
public DefaultRedisScript<Long> limitScript()
{

92
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/PlatformStyleDTO.java

@ -27,6 +27,26 @@ public class PlatformStyleDTO implements Serializable {
/** 平台颜色 */
private String platformColor;
/** 探测区半径(千米),整圆 */
private Double detectionZoneRadius;
/** 探测区填充颜色 */
private String detectionZoneColor;
/** 探测区透明度 0-1 */
private Double detectionZoneOpacity;
/** 探测区是否在地图上显示 */
private Boolean detectionZoneVisible;
/** 威力区半径(千米) */
private Double powerZoneRadius;
/** 威力区扇形夹角(度),如 60 表示 60 度扇形 */
private Double powerZoneAngle;
/** 威力区填充颜色 */
private String powerZoneColor;
/** 威力区透明度 0-1 */
private Double powerZoneOpacity;
/** 威力区是否在地图上显示 */
private Boolean powerZoneVisible;
public String getRoomId() {
return roomId;
}
@ -90,4 +110,76 @@ public class PlatformStyleDTO implements Serializable {
public void setPlatformColor(String platformColor) {
this.platformColor = platformColor;
}
public Double getPowerZoneRadius() {
return powerZoneRadius;
}
public void setPowerZoneRadius(Double powerZoneRadius) {
this.powerZoneRadius = powerZoneRadius;
}
public String getPowerZoneColor() {
return powerZoneColor;
}
public void setPowerZoneColor(String powerZoneColor) {
this.powerZoneColor = powerZoneColor;
}
public Double getDetectionZoneRadius() {
return detectionZoneRadius;
}
public void setDetectionZoneRadius(Double detectionZoneRadius) {
this.detectionZoneRadius = detectionZoneRadius;
}
public String getDetectionZoneColor() {
return detectionZoneColor;
}
public void setDetectionZoneColor(String detectionZoneColor) {
this.detectionZoneColor = detectionZoneColor;
}
public Double getDetectionZoneOpacity() {
return detectionZoneOpacity;
}
public void setDetectionZoneOpacity(Double detectionZoneOpacity) {
this.detectionZoneOpacity = detectionZoneOpacity;
}
public Boolean getDetectionZoneVisible() {
return detectionZoneVisible;
}
public void setDetectionZoneVisible(Boolean detectionZoneVisible) {
this.detectionZoneVisible = detectionZoneVisible;
}
public Double getPowerZoneAngle() {
return powerZoneAngle;
}
public void setPowerZoneAngle(Double powerZoneAngle) {
this.powerZoneAngle = powerZoneAngle;
}
public Double getPowerZoneOpacity() {
return powerZoneOpacity;
}
public void setPowerZoneOpacity(Double powerZoneOpacity) {
this.powerZoneOpacity = powerZoneOpacity;
}
public Boolean getPowerZoneVisible() {
return powerZoneVisible;
}
public void setPowerZoneVisible(Boolean powerZoneVisible) {
this.powerZoneVisible = powerZoneVisible;
}
}

22
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTimelineSegmentMapper.java

@ -0,0 +1,22 @@
package com.ruoyi.system.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
public interface SysTimelineSegmentMapper
{
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment);
public List<SysTimelineSegment> selectSegmentListByRoomId(@Param("roomId") Long roomId);
public SysTimelineSegment selectSegmentById(Long segmentId);
public int insertSegment(SysTimelineSegment segment);
public int updateSegment(SysTimelineSegment segment);
public int deleteSegmentById(Long segmentId);
public int deleteSegmentByRoomId(@Param("roomId") Long roomId);
}

21
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTimelineSegmentService.java

@ -0,0 +1,21 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
public interface ISysTimelineSegmentService
{
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment);
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId);
public SysTimelineSegment selectSegmentById(Long segmentId);
public int insertSegment(SysTimelineSegment segment);
public int updateSegment(SysTimelineSegment segment);
public int deleteSegmentById(Long segmentId);
public int deleteSegmentByRoomId(Long roomId);
}

57
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTimelineSegmentServiceImpl.java

@ -0,0 +1,57 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
import com.ruoyi.system.mapper.SysTimelineSegmentMapper;
import com.ruoyi.system.service.ISysTimelineSegmentService;
@Service
public class SysTimelineSegmentServiceImpl implements ISysTimelineSegmentService
{
@Autowired
private SysTimelineSegmentMapper segmentMapper;
@Override
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment)
{
return segmentMapper.selectSegmentList(segment);
}
@Override
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId)
{
return segmentMapper.selectSegmentListByRoomId(roomId);
}
@Override
public SysTimelineSegment selectSegmentById(Long segmentId)
{
return segmentMapper.selectSegmentById(segmentId);
}
@Override
public int insertSegment(SysTimelineSegment segment)
{
return segmentMapper.insertSegment(segment);
}
@Override
public int updateSegment(SysTimelineSegment segment)
{
return segmentMapper.updateSegment(segment);
}
@Override
public int deleteSegmentById(Long segmentId)
{
return segmentMapper.deleteSegmentById(segmentId);
}
@Override
public int deleteSegmentByRoomId(Long roomId)
{
return segmentMapper.deleteSegmentByRoomId(roomId);
}
}

1
ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml

@ -40,6 +40,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="pointType != null and pointType != ''"> and point_type = #{pointType}</if>
<if test="holdParams != null and holdParams != ''"> and hold_params = #{holdParams}</if>
</where>
order by seq asc
</select>
<select id="selectRouteWaypointsById" parameterType="Long" resultMap="RouteWaypointsResult">

77
ruoyi-system/src/main/resources/mapper/system/SysTimelineSegmentMapper.xml

@ -0,0 +1,77 @@
<?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.SysTimelineSegmentMapper">
<resultMap type="SysTimelineSegment" id="SysTimelineSegmentResult">
<id property="segmentId" column="segment_id" />
<result property="roomId" column="room_id" />
<result property="segmentTime" column="segment_time" />
<result property="segmentName" column="segment_name" />
<result property="segmentDesc" column="segment_desc" />
</resultMap>
<sql id="selectSegmentVo">
select s.segment_id, s.room_id, s.segment_time, s.segment_name, s.segment_desc
from sys_timeline_segment s
</sql>
<select id="selectSegmentList" parameterType="SysTimelineSegment" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where 1=1
<if test="roomId != null and roomId != 0">
AND room_id = #{roomId}
</if>
<if test="segmentName != null and segmentName != ''">
AND segment_name like concat('%', #{segmentName}, '%')
</if>
order by s.segment_time
</select>
<select id="selectSegmentListByRoomId" parameterType="Long" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where room_id = #{roomId}
order by s.segment_time
</select>
<select id="selectSegmentById" parameterType="Long" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where segment_id = #{segmentId}
</select>
<insert id="insertSegment" parameterType="SysTimelineSegment">
insert into sys_timeline_segment(
room_id,
segment_time,
segment_name,
<if test="segmentDesc != null and segmentDesc != ''">segment_desc,</if>
segment_id
)values(
#{roomId},
#{segmentTime},
#{segmentName},
<if test="segmentDesc != null and segmentDesc != ''">#{segmentDesc},</if>
#{segmentId}
)
</insert>
<update id="updateSegment" parameterType="SysTimelineSegment">
update sys_timeline_segment
<set>
<if test="segmentTime != null and segmentTime != ''">segment_time = #{segmentTime},</if>
<if test="segmentName != null and segmentName != ''">segment_name = #{segmentName},</if>
<if test="segmentDesc != null">segment_desc = #{segmentDesc},</if>
</set>
where segment_id = #{segmentId}
</update>
<delete id="deleteSegmentById" parameterType="Long">
delete from sys_timeline_segment where segment_id = #{segmentId}
</delete>
<delete id="deleteSegmentByRoomId" parameterType="Long">
delete from sys_timeline_segment where room_id = #{roomId}
</delete>
</mapper>

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

@ -61,6 +61,25 @@ export function getPlatformStyle(query) {
})
}
// 保存4T数据到Redis(禁用防重复提交,因拖拽/调整大小可能快速连续触发保存)
export function save4TData(data) {
return request({
url: '/system/routes/save4TData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从Redis获取4T数据
export function get4TData(params) {
return request({
url: '/system/routes/get4TData',
method: 'get',
params
})
}
// 获取导弹发射参数(Redis:房间+航线+平台为 key)
export function getMissileParams(params) {
return request({

53
ruoyi-ui/src/api/system/timeline.js

@ -0,0 +1,53 @@
import request from '@/utils/request'
export function listTimelineSegments(query) {
return request({
url: '/system/timeline/list',
method: 'get',
params: query
})
}
export function getTimelineSegmentsByRoomId(roomId) {
return request({
url: '/system/timeline/listByRoomId/' + roomId,
method: 'get'
})
}
export function getTimelineSegment(id) {
return request({
url: '/system/timeline/' + id,
method: 'get'
})
}
export function addTimelineSegment(data) {
return request({
url: '/system/timeline',
method: 'post',
data: data
})
}
export function updateTimelineSegment(data) {
return request({
url: '/system/timeline',
method: 'put',
data: data
})
}
export function delTimelineSegment(id) {
return request({
url: '/system/timeline/' + id,
method: 'delete'
})
}
export function delTimelineSegmentsByRoomId(roomId) {
return request({
url: '/system/timeline/room/' + roomId,
method: 'delete'
})
}

1
ruoyi-ui/src/assets/icons/svg/T.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772160025231" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2763" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M866.56 70.72v123.84h-287.04v758.4h-136.32V194.56H157.44V70.72h709.12z" p-id="2764"></path></svg>

After

Width:  |  Height:  |  Size: 430 B

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

@ -71,6 +71,13 @@ export default {
systemDescription: 'System Description',
language: 'Language'
},
tools: {
routeCalculation: 'Route Calculation',
conflictDisplay: 'Conflict Display',
dataMaterials: 'Data Materials',
coordinateConversion: 'Coordinate Conversion',
generateGanttChart: 'Generate Gantt Chart'
},
favorites: {
layerFavorites: 'Layer Favorites',
routeFavorites: 'Route Favorites'

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

@ -71,6 +71,13 @@ export default {
systemDescription: '系统说明',
language: '语言'
},
tools: {
routeCalculation: '航线计算',
conflictDisplay: '冲突显示',
dataMaterials: '数据资料',
coordinateConversion: '坐标转换',
generateGanttChart: '生成甘特图'
},
favorites: {
layerFavorites: '图层收藏',
routeFavorites: '航线收藏'

5
ruoyi-ui/src/router/index.js

@ -70,6 +70,11 @@ export const constantRoutes = [
hidden: true
},
{
path: '/ganttChart',
component: () => import('@/views/ganttChart'),
hidden: true
},
{
path: '/register',
component: () => import('@/views/register'),
hidden: true

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

@ -7,8 +7,29 @@
</div>
</div>
<!-- 航线上锁/解锁复制 -->
<div class="menu-section" v-if="entityData && entityData.type === 'route'">
<!-- 航点编辑向前/向后增加航点 -->
<div class="menu-section" v-if="entityData && entityData.type === 'routeWaypoint'">
<div class="menu-title">航点</div>
<div class="menu-item" @click="handleEditWaypoint">
<span class="menu-icon">📝</span>
<span>编辑航点</span>
</div>
<div class="menu-item" @click="handleAddWaypointBefore">
<span class="menu-icon"></span>
<span>向前增加航点</span>
</div>
<div class="menu-item" @click="handleAddWaypointAfter">
<span class="menu-icon"></span>
<span>向后增加航点</span>
</div>
<div class="menu-item" @click="handleToggleWaypointHold">
<span class="menu-icon">🔄</span>
<span>切换盘旋航点</span>
</div>
</div>
<!-- 航线上锁/解锁复制航点右键时也显示 routeId -->
<div class="menu-section" v-if="entityData && (entityData.type === 'route' || entityData.type === 'routeWaypoint')">
<div class="menu-title">航线编辑</div>
<div class="menu-item" @click="handleToggleRouteLock">
<span class="menu-icon">{{ isRouteLocked ? '🔓' : '🔒' }}</span>
@ -31,10 +52,22 @@
<span class="menu-icon">📝</span>
<span>编辑</span>
</div>
<div class="menu-item" @click="handleDetectionZone">
<span class="menu-icon">🔍</span>
<span>探测区</span>
</div>
<div class="menu-item" @click="handlePowerZone">
<span class="menu-icon"></span>
<span>威力区</span>
</div>
<div class="menu-item menu-item-sub" @click="handleToggleDetectionZone">
<span class="menu-icon">{{ detectionZoneVisible ? '👁' : '👁‍🗨' }}</span>
<span>{{ detectionZoneVisible ? '隐藏探测区' : '显示探测区' }}</span>
</div>
<div class="menu-item menu-item-sub" @click="handleTogglePowerZone">
<span class="menu-icon">{{ powerZoneVisible ? '👁' : '👁‍🗨' }}</span>
<span>{{ powerZoneVisible ? '隐藏威力区' : '显示威力区' }}</span>
</div>
<div class="menu-item" @click="handleLaunchMissile">
<span class="menu-icon">🚀</span>
<span>发射导弹</span>
@ -315,18 +348,21 @@
<!-- 平台图标拖拽到地图的图标特有选项 -->
<div class="menu-section" v-if="entityData.type === 'platformIcon'">
<div class="menu-title">平台图标</div>
<div class="menu-item" @click.stop="handleShowTransformBox">
<span class="menu-icon">📐</span>
<span>显示伸缩框</span>
<div class="menu-item" @click="handleDetectionZonePlatform">
<span class="menu-icon">🔍</span>
<span>探测区</span>
</div>
<div class="menu-item" @click="handleEditPlatformPosition">
<span class="menu-icon">📍</span>
<span>修改位置</span>
<div class="menu-item" @click="handlePowerZonePlatform">
<span class="menu-icon"></span>
<span>威力区</span>
</div>
<div class="menu-item" @click="handleEditPlatformHeading">
<span class="menu-icon">🧭</span>
<span>修改朝向</span>
<span class="menu-value">{{ entityData.heading != null ? entityData.heading + '°' : '0°' }}</span>
<div class="menu-item menu-item-sub" @click="handleToggleDetectionZone">
<span class="menu-icon">{{ detectionZoneVisible ? '👁' : '👁‍🗨' }}</span>
<span>{{ detectionZoneVisible ? '隐藏探测区' : '显示探测区' }}</span>
</div>
<div class="menu-item menu-item-sub" @click="handleTogglePowerZone">
<span class="menu-icon">{{ powerZoneVisible ? '👁' : '👁‍🗨' }}</span>
<span>{{ powerZoneVisible ? '隐藏威力区' : '显示威力区' }}</span>
</div>
<div class="menu-title" style="margin-top:8px;">航线</div>
<div class="menu-item" @click="handleStartRouteBeforePlatform">
@ -360,6 +396,14 @@ export default {
routeLocked: {
type: Object,
default: () => ({})
},
detectionZoneVisible: {
type: Boolean,
default: true
},
powerZoneVisible: {
type: Boolean,
default: true
}
},
data() {
@ -389,7 +433,7 @@ export default {
}
},
isRouteLocked() {
if (!this.entityData || this.entityData.type !== 'route' || this.entityData.routeId == null) return false
if (!this.entityData || this.entityData.routeId == null) return false
return !!this.routeLocked[this.entityData.routeId]
}
},
@ -398,16 +442,20 @@ export default {
this.$emit('delete')
},
handleShowTransformBox() {
this.$emit('show-transform-box')
handleDetectionZonePlatform() {
this.$emit('detection-zone')
},
handleEditPlatformPosition() {
this.$emit('edit-platform-position')
handlePowerZonePlatform() {
this.$emit('power-zone')
},
handleToggleDetectionZone() {
this.$emit('toggle-detection-zone')
},
handleEditPlatformHeading() {
this.$emit('edit-platform-heading')
handleTogglePowerZone() {
this.$emit('toggle-power-zone')
},
handleStartRouteBeforePlatform() {
@ -437,10 +485,30 @@ export default {
this.$emit('copy-route')
},
handleEditWaypoint() {
this.$emit('open-waypoint-dialog', this.entityData.dbId, this.entityData.routeId, this.entityData.waypointIndex)
},
handleAddWaypointBefore() {
this.$emit('add-waypoint-at', { routeId: this.entityData.routeId, waypointIndex: this.entityData.waypointIndex, mode: 'before' })
},
handleAddWaypointAfter() {
this.$emit('add-waypoint-at', { routeId: this.entityData.routeId, waypointIndex: this.entityData.waypointIndex, mode: 'after' })
},
handleToggleWaypointHold() {
this.$emit('toggle-waypoint-hold', { routeId: this.entityData.routeId, dbId: this.entityData.dbId, waypointIndex: this.entityData.waypointIndex })
},
handleEditPlatform() {
this.$emit('edit-platform')
},
handleDetectionZone() {
this.$emit('detection-zone')
},
handlePowerZone() {
this.$emit('power-zone')
},
@ -648,6 +716,17 @@ export default {
flex: 1;
}
.menu-item-sub {
padding: 6px 16px 6px 24px;
font-size: 12px;
color: #666;
}
.menu-item-sub:hover {
background-color: #f0f7ff;
color: #333;
}
/* 颜色选择器样式 */
.color-picker-container {
padding: 8px 16px;

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

File diff suppressed because it is too large

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

@ -1,6 +1,6 @@
<template>
<div>
<bottom-timeline ref="timeline" @timeline-hidden="onTimelineHidden" />
<bottom-timeline ref="timeline" @timeline-hidden="onTimelineHidden" :room-id="roomId" />
<div class="bottom-left-panel" v-show="showPanel">
<div class="panel-toggle" @click="togglePanel" :title="isExpanded ? '收起' : '展开'">
@ -51,6 +51,12 @@ export default {
components: {
BottomTimeline
},
props: {
roomId: {
type: Number,
default: null
}
},
data() {
return {
isExpanded: false,
@ -75,19 +81,23 @@ export default {
this.$refs.timeline.isVisible = true
this.isExpanded = false
this.showPanel = false
this.$emit('bottom-panel-visible', true)
}
},
onTimelineHidden() {
this.showPanel = true
this.$emit('bottom-panel-visible', false)
},
showSixSteps() {
this.showSixStepsBar = true
this.isExpanded = false
this.showPanel = false
this.$emit('bottom-panel-visible', true)
},
hideSixStepsBar() {
this.showSixStepsBar = false
this.showPanel = true
this.$emit('bottom-panel-visible', false)
},
selectStep(index) {
const clickedStep = this.sixStepsData[index]
@ -205,7 +215,7 @@ export default {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.98) 100%);
backdrop-filter: blur(20px);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.08);
padding: 5px 60px 5px 60px;
padding: 2px 60px 4px 60px;
z-index: 1000;
animation: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border-top: 1px solid rgba(0, 0, 0, 0.05);
@ -249,7 +259,7 @@ export default {
.steps-bar-container {
display: flex;
gap: 12px;
height: 40px;
height: 62px;
align-items: center;
}

482
ruoyi-ui/src/views/childRoom/BottomTimeline.vue

@ -1,12 +1,19 @@
<template>
<div class="bottom-timeline" v-if="isVisible">
<div class="timeline-header">
<el-button type="text" size="mini" @click="openSettingsDialog" icon="el-icon-setting" title="时间轴设置">
时间轴设置
<div class="timeline-title">
<i class="el-icon-time"></i>
<span>任务时间轴</span>
</div>
<div class="header-actions">
<el-button type="text" size="mini" @click="openSettingsDialog" title="时间轴设置">
<i class="el-icon-setting"></i>
<span>设置</span>
</el-button>
<el-button type="text" size="mini" @click="closeTimeline" icon="el-icon-close" title="关闭时间轴">
</el-button>
</div>
</div>
<div class="timeline-bar-container">
<div class="timeline-bar">
@ -22,6 +29,10 @@
:title="segment.time + ' - ' + segment.name"
>
<div class="marker-dot"></div>
<div class="marker-tooltip">
<div class="tooltip-time">{{ segment.time }}</div>
<div class="tooltip-name">{{ segment.name }}</div>
</div>
</div>
</div>
<div class="timeline-current" :style="{ left: progressWidth + '%' }">
@ -137,28 +148,6 @@
<el-form-item label="描述">
<el-input v-model="segmentForm.description" type="textarea" :rows="3" placeholder="输入描述" />
</el-form-item>
<el-form-item label="提醒方式">
<el-checkbox-group v-model="segmentForm.reminderTypes">
<el-checkbox label="popup">弹窗提醒</el-checkbox>
<el-checkbox label="sound">声音提醒</el-checkbox>
<el-checkbox label="message">消息提醒</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="分配任务">
<el-select
v-model="segmentForm.assignedTask"
placeholder="选择任务"
style="width: 100%"
clearable
>
<el-option
v-for="task in availableTasks"
:key="task.id"
:label="task.name"
:value="task.id"
/>
</el-select>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="showSegmentDialog = false">取消</el-button>
@ -189,18 +178,6 @@
<span>{{ currentSegment.description }}</span>
</div>
<div class="detail-row">
<label>分配任务</label>
<span>{{ getTaskName(currentSegment.assignedTask) }}</span>
</div>
<div class="detail-row">
<label>提醒方式</label>
<div class="reminder-tags">
<el-tag v-if="currentSegment.reminderTypes.includes('popup')" size="small">弹窗</el-tag>
<el-tag v-if="currentSegment.reminderTypes.includes('sound')" size="small">声音</el-tag>
<el-tag v-if="currentSegment.reminderTypes.includes('message')" size="small">消息</el-tag>
</div>
</div>
<div class="detail-row">
<label>状态</label>
<el-tag :type="currentSegment.passed ? 'success' : (currentSegment.active ? 'warning' : 'info')" size="small">
{{ currentSegment.passed ? '已到达' : (currentSegment.active ? '即将到达' : '未到达') }}
@ -216,8 +193,16 @@
</template>
<script>
import { getTimelineSegmentsByRoomId, addTimelineSegment, updateTimelineSegment, delTimelineSegment } from '@/api/system/timeline'
export default {
name: 'BottomTimeline',
props: {
roomId: {
type: Number,
default: null
}
},
data() {
return {
isVisible: false,
@ -236,34 +221,33 @@ export default {
segmentForm: {
time: null,
name: '',
description: '',
reminderTypes: ['popup', 'message'],
assignedTask: null
description: ''
},
timer: null,
audio: null,
currentTimeStr: '',
availableTasks: [
{ id: 1, name: '任务准备' },
{ id: 2, name: '资源调配' },
{ id: 3, name: '任务执行' },
{ id: 4, name: '任务监控' },
{ id: 5, name: '任务完成' },
{ id: 6, name: '数据收集' },
{ id: 7, name: '分析评估' },
{ id: 8, name: '报告生成' }
]
isLoading: false
}
},
mounted() {
this.initDefaultTime()
this.initDefaultSegments()
this.loadTimelineSegments()
this.startTimer()
this.initAudio()
},
beforeDestroy() {
this.stopTimer()
},
watch: {
roomId: {
handler(newVal) {
if (newVal) {
this.loadTimelineSegments()
}
},
immediate: true
}
},
methods: {
closeTimeline() {
this.isVisible = false
@ -279,9 +263,7 @@ export default {
this.segmentForm = {
time: null,
name: '',
description: '',
reminderTypes: ['popup', 'message'],
assignedTask: null
description: ''
}
this.showSegmentDialog = true
},
@ -296,6 +278,38 @@ export default {
this.editSegment(this.currentSegment)
},
async loadTimelineSegments() {
if (!this.roomId) {
this.initDefaultSegments()
return
}
try {
this.isLoading = true
const response = await getTimelineSegmentsByRoomId(this.roomId)
if (response.code === 200 && response.data && response.data.length > 0) {
this.timelineSegments = response.data.map(item => ({
segmentId: item.segmentId,
time: item.segmentTime,
name: item.segmentName,
description: item.segmentDesc,
active: false,
passed: false,
position: 0,
triggered: false
}))
this.updateTimeline()
} else {
this.initDefaultSegments()
}
} catch (error) {
console.error('加载时间轴数据失败:', error)
this.initDefaultSegments()
} finally {
this.isLoading = false
}
},
initDefaultTime() {
const now = new Date()
this.startTime = new Date(now)
@ -312,8 +326,6 @@ export default {
description: '开始准备任务所需的资源和设备',
active: false,
passed: false,
reminderTypes: ['popup', 'message'],
assignedTask: 1,
position: 0,
triggered: false
},
@ -323,8 +335,6 @@ export default {
description: '完成资源的调配和分配',
active: false,
passed: false,
reminderTypes: ['popup', 'message'],
assignedTask: 2,
position: 0,
triggered: false
},
@ -334,8 +344,6 @@ export default {
description: '开始执行主要任务',
active: false,
passed: false,
reminderTypes: ['popup', 'sound', 'message'],
assignedTask: 3,
position: 0,
triggered: false
},
@ -345,8 +353,6 @@ export default {
description: '监控任务执行进度',
active: false,
passed: false,
reminderTypes: ['popup', 'message'],
assignedTask: 4,
position: 0,
triggered: false
},
@ -356,8 +362,6 @@ export default {
description: '任务完成,进行总结',
active: false,
passed: false,
reminderTypes: ['popup', 'sound', 'message'],
assignedTask: 5,
position: 0,
triggered: false
}
@ -426,26 +430,20 @@ export default {
},
triggerReminder(segment) {
if (segment.reminderTypes.includes('popup')) {
this.$notify({
title: '时间提醒',
message: `${segment.time} - ${segment.name}`,
type: 'warning',
duration: 5000
})
}
if (segment.reminderTypes.includes('sound')) {
this.playSound()
}
if (segment.reminderTypes.includes('message')) {
this.$message({
message: `时间提醒:${segment.time} - ${segment.name}`,
type: 'warning',
duration: 5000
})
}
},
playSound() {
@ -492,25 +490,38 @@ export default {
this.segmentForm = {
time: this.parseTime(segment.time),
name: segment.name,
description: segment.description,
reminderTypes: [...segment.reminderTypes],
assignedTask: segment.assignedTask
description: segment.description
}
this.showSegmentDialog = true
},
saveSegment() {
async saveSegment() {
if (!this.segmentForm.time || !this.segmentForm.name) {
this.$message.warning('请填写完整信息')
return
}
const segmentData = {
roomId: this.roomId,
segmentTime: this.formatTime(this.segmentForm.time),
segmentName: this.segmentForm.name,
segmentDesc: this.segmentForm.description
}
try {
if (this.isEditMode && this.timelineSegments[this.editIndex].segmentId) {
segmentData.segmentId = this.timelineSegments[this.editIndex].segmentId
await updateTimelineSegment(segmentData)
} else {
const response = await addTimelineSegment(segmentData)
segmentData.segmentId = response.data
}
const segment = {
time: this.formatTime(this.segmentForm.time),
name: this.segmentForm.name,
description: this.segmentForm.description,
reminderTypes: [...this.segmentForm.reminderTypes],
assignedTask: this.segmentForm.assignedTask,
segmentId: segmentData.segmentId,
time: segmentData.segmentTime,
name: segmentData.segmentName,
description: segmentData.segmentDesc,
active: false,
passed: false,
triggered: false,
@ -530,25 +541,33 @@ export default {
this.updateTimeline()
this.showSegmentDialog = false
this.$message.success(this.isEditMode ? '编辑成功' : '添加成功')
} catch (error) {
console.error('保存时间段失败:', error)
this.$message.error('保存失败,请重试')
}
},
deleteSegment(index) {
async deleteSegment(index) {
this.$confirm('确定要删除这个时间段吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
}).then(async () => {
try {
const segment = this.timelineSegments[index]
if (segment.segmentId && this.roomId) {
await delTimelineSegment(segment.segmentId)
}
this.timelineSegments.splice(index, 1)
this.updateTimeline()
this.$message.success('删除成功')
} catch (error) {
console.error('删除时间段失败:', error)
this.$message.error('删除失败,请重试')
}
}).catch(() => {})
},
getTaskName(taskId) {
const task = this.availableTasks.find(t => t.id === taskId)
return task ? task.name : '未分配'
},
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
@ -582,49 +601,89 @@ export default {
left: 0;
right: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-top: 1px solid #e4e7ed;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
padding: 4px 12px 6px 12px;
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.08);
padding: 2px 20px 4px;
border-radius: 12px 12px 0 0;
overflow: visible;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 16px;
margin-bottom: 3px;
min-height: 24px;
margin-bottom: 2px;
}
.timeline-title {
font-size: 15px;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 8px;
}
.timeline-title i {
color: #008aff;
font-size: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions .el-button {
color: #008aff;
transition: all 0.3s ease;
font-size: 15px;
padding: 4px 8px;
}
.header-actions .el-button:hover {
color: #0078e5;
transform: translateY(-1px);
}
.timeline-bar {
position: relative;
height: 8px;
background: #f5f7fa;
border-radius: 4px;
overflow: hidden;
border: 1px solid #e4e7ed;
height: 14px;
background: rgba(255, 255, 255, 0.6);
border-radius: 8px;
overflow: visible;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
}
.timeline-bar-container {
position: relative;
margin-bottom: 0;
margin-bottom: 2px;
overflow: visible;
}
.timeline-scale {
position: relative;
height: 14px;
margin-top: 3px;
height: 20px;
margin-top: 2px;
display: flex;
align-items: center;
}
.scale-label {
position: absolute;
font-size: 12px;
color: #909399;
font-size: 13px;
color: #64748b;
transform: translateX(-50%);
white-space: nowrap;
font-weight: 500;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.9);
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.timeline-progress {
@ -632,9 +691,10 @@ export default {
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #409EFF 0%, #67c23a 100%);
transition: width 0.5s ease;
border-radius: 6px;
background: linear-gradient(90deg, #008aff 0%, #66b8ff 100%);
transition: width 0.5s ease-in-out;
border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 138, 255, 0.3);
}
.timeline-markers {
@ -647,102 +707,132 @@ export default {
.timeline-marker {
position: absolute;
top: 0;
transform: translateX(-50%);
top: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
z-index: 10;
height: 100%;
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.timeline-marker:hover {
transform: translate(-50%, -50%) translateY(-3px);
}
.timeline-marker:hover .marker-dot {
transform: scale(1.3);
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.3);
transform: scale(1.5);
box-shadow: 0 0 0 6px rgba(0, 138, 255, 0.2);
}
.timeline-marker:hover .marker-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-12px);
}
.timeline-marker.active .marker-dot {
background: #E6A23C;
box-shadow: 0 0 0 3px rgba(230, 162, 60, 0.4);
animation: pulse 1.5s infinite;
background: #f59e0b;
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.3);
animation: pulse 2s infinite ease-in-out;
}
.timeline-marker.passed .marker-dot {
background: #67c23a;
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.3);
background: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
}
.marker-dot {
width: 6px;
height: 6px;
background: #409EFF;
width: 12px;
height: 12px;
background: #008aff;
border-radius: 50%;
border: 1px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.3s;
border: 2px solid white;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.marker-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(0);
background: white;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
padding: 8px 12px;
min-width: 140px;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 20;
border: 1px solid #e2e8f0;
}
.tooltip-time {
font-size: 16px;
color: #008aff;
font-weight: 600;
margin-bottom: 4px;
}
.tooltip-name {
font-size: 15px;
color: #1e293b;
font-weight: 500;
}
.timeline-current {
position: absolute;
top: 0;
width: 2px;
width: 4px;
height: 100%;
background: #F56C6C;
background: #ef4444;
transform: translateX(-50%);
z-index: 20;
transition: left 0.5s ease;
transition: left 0.5s ease-in-out;
border-radius: 4px;
}
.current-indicator {
position: absolute;
top: 0;
left: -4px;
width: 8px;
height: 8px;
background: #F56C6C;
top: 50%;
left: -6px;
width: 14px;
height: 14px;
background: #ef4444;
border-radius: 50%;
border: 1px solid white;
box-shadow: 0 1px 3px rgba(245, 108, 108, 0.4);
animation: currentPulse 2s infinite;
border: 2px solid white;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.5);
transform: translateY(-50%);
animation: currentPulse 1.5s infinite ease-in-out;
}
.current-time {
position: absolute;
top: -14px;
left: 0;
top: -26px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #F56C6C;
font-size: 13px;
color: #ef4444;
white-space: nowrap;
font-weight: 500;
font-weight: 600;
z-index: 25;
padding: 4px 10px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2);
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes currentPulse {
0% {
box-shadow: 0 1px 3px rgba(245, 108, 108, 0.4);
}
50% {
box-shadow: 0 1px 6px rgba(245, 108, 108, 0.6);
}
100% {
box-shadow: 0 1px 3px rgba(245, 108, 108, 0.4);
}
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
.settings-content {
@ -752,8 +842,9 @@ export default {
.time-settings {
margin-bottom: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 6px;
background: #f8fafc;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.setting-row {
@ -771,7 +862,7 @@ export default {
.setting-label {
font-size: 13px;
color: #606266;
color: #334155;
font-weight: 500;
white-space: nowrap;
min-width: 60px;
@ -787,13 +878,13 @@ export default {
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #dcdfe6;
border-bottom: 1px solid #e2e8f0;
}
.section-header h4 {
margin: 0;
font-size: 14px;
color: #303133;
color: #1e293b;
font-weight: 600;
}
@ -805,31 +896,32 @@ export default {
.segment-item {
display: flex;
align-items: center;
padding: 12px;
padding: 12px 16px;
background: white;
border: 1px solid #dcdfe6;
border-radius: 6px;
margin-bottom: 10px;
transition: all 0.3s;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.3s ease;
}
.segment-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.segment-item.active {
background: linear-gradient(135deg, rgba(230, 162, 60, 0.05) 0%, rgba(230, 162, 60, 0.1) 100%);
border-color: #E6A23C;
background: linear-gradient(135deg, rgba(245,158,11,0.05) 0%, rgba(245,158,11,0.1) 100%);
border-color: #f59e0b;
}
.segment-item.passed {
background: linear-gradient(135deg, rgba(103, 194, 58, 0.05) 0%, rgba(103, 194, 58, 0.1) 100%);
border-color: #67c23a;
background: linear-gradient(135deg, rgba(16,185,129,0.05) 0%, rgba(16,185,129,0.1) 100%);
border-color: #10b981;
}
.segment-time {
font-size: 13px;
color: #409EFF;
color: #008aff;
font-weight: 700;
min-width: 80px;
}
@ -842,13 +934,13 @@ export default {
.segment-name {
font-size: 14px;
font-weight: 600;
color: #303133;
color: #1e293b;
margin-bottom: 3px;
}
.segment-desc {
font-size: 12px;
color: #909399;
color: #64748b;
}
.segment-status {
@ -872,13 +964,13 @@ export default {
.detail-row label {
font-weight: 600;
color: #303133;
color: #1e293b;
min-width: 80px;
flex-shrink: 0;
}
.detail-row span {
color: #606266;
color: #334155;
line-height: 1.5;
}
@ -896,35 +988,49 @@ export default {
left: 50% !important;
transform: translate(-50%, -50%) !important;
margin: 0 !important;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
border: none;
}
.bottom-timeline .el-dialog__header {
background: #409EFF;
background: linear-gradient(90deg, #008aff 0%, #66b8ff 100%);
color: white;
padding: 12px 20px;
}
.bottom-timeline .el-dialog__title {
color: white;
font-size: 14px;
font-size: 15px;
font-weight: 600;
}
.bottom-timeline .el-dialog__headerbtn .el-dialog__close {
color: white;
font-size: 16px;
transition: all 0.3s ease;
}
.bottom-timeline .el-dialog__headerbtn .el-dialog__close:hover {
color: #e0e7ff;
transform: rotate(90deg);
}
.bottom-timeline .el-dialog__body {
padding: 20px;
padding: 24px;
background: #f8fafc;
}
.bottom-timeline .el-dialog__footer {
padding: 15px 20px;
border-top: 1px solid #dcdfe6;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
background: white;
}
.timeline-settings-dialog.el-dialog {
position: fixed !important;
bottom: 60px !important;
bottom: 130px !important;
top: auto !important;
left: 50% !important;
transform: translateX(-50%) !important;
@ -934,7 +1040,7 @@ export default {
.timeline-segment-dialog.el-dialog {
position: fixed !important;
bottom: 60px !important;
bottom: 130px !important;
top: auto !important;
left: 50% !important;
transform: translateX(-50%) !important;
@ -944,7 +1050,7 @@ export default {
.timeline-detail-dialog.el-dialog {
position: fixed !important;
bottom: 60px !important;
bottom: 130px !important;
top: auto !important;
left: 50% !important;
transform: translateX(-50%) !important;

582
ruoyi-ui/src/views/childRoom/FourTPanel.vue

@ -0,0 +1,582 @@
<template>
<div
v-show="visible"
class="four-t-panel"
:class="{ 'four-t-panel-ready': layoutReady }"
:style="panelStyle"
>
<div class="four-t-panel-header" @mousedown="onDragStart">
<span class="four-t-panel-title">4T</span>
<div class="four-t-header-actions" @mousedown.stop>
<el-button
v-if="!isEditMode"
type="primary"
size="mini"
@mousedown.stop
@click="enterEditMode"
>
编辑
</el-button>
<template v-else>
<el-button
type="success"
size="mini"
@mousedown.stop
@click="saveAndExitEdit"
>
保存
</el-button>
<el-button
size="mini"
@mousedown.stop
@click="cancelEdit"
>
取消
</el-button>
</template>
<i class="el-icon-close close-btn" @mousedown.stop @click="$emit('update:visible', false)" title="关闭"></i>
</div>
</div>
<div class="four-t-panel-body">
<div
v-for="section in sections"
:key="section.key"
class="four-t-section"
>
<div class="four-t-section-header">
<span class="four-t-section-title">{{ section.title }}</span>
<div
v-if="isEditMode"
class="four-t-add-btn four-t-add-btn-inline"
@click="addImage(section.key)"
title="插入图片"
>
<i class="el-icon-plus"></i>
</div>
</div>
<div class="four-t-section-content four-t-content-box">
<el-input
v-model="localData[section.key].text"
type="textarea"
:autosize="{ minRows: 2, maxRows: 8 }"
:placeholder="isEditMode ? '请输入文本内容' : ''"
:disabled="!isEditMode"
class="four-t-textarea"
/>
<div v-if="localData[section.key].images.length > 0" class="four-t-images-inline">
<div
v-for="(img, idx) in localData[section.key].images"
:key="idx"
class="four-t-image-item"
>
<img :src="img" alt="插入的图片" />
<i
v-if="isEditMode"
class="el-icon-close remove-img"
@click="removeImage(section.key, idx)"
></i>
</div>
</div>
</div>
</div>
</div>
<div
class="four-t-resize-handle"
@mousedown="onResizeStart"
title="拖动调整大小"
></div>
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="onFileSelected"
/>
</div>
</template>
<script>
import { save4TData, get4TData } from '@/api/system/routes'
const SECTIONS = [
{ key: 'threat', title: 'THREAT' },
{ key: 'task', title: 'TASK' },
{ key: 'target', title: 'TARGET' },
{ key: 'tactic', title: 'TACTIC' }
]
const defaultData = () => ({
threat: { text: '', images: [] },
task: { text: '', images: [] },
target: { text: '', images: [] },
tactic: { text: '', images: [] }
})
export default {
name: 'FourTPanel',
props: {
visible: {
type: Boolean,
default: false
},
roomId: {
type: [String, Number],
default: null
}
},
data() {
return {
sections: SECTIONS,
localData: defaultData(),
isEditMode: false,
editDataBackup: null,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
panelLeft: null,
panelTop: null,
panelWidth: 420,
panelHeight: 480,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
layoutReady: false,
}
},
computed: {
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) - 20
const top = this.panelTop != null ? this.panelTop : 80
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
}
}
},
watch: {
visible: {
handler(val) {
if (val && this.roomId) {
this.loadData()
}
},
immediate: true
},
roomId: {
handler(val) {
if (val && this.visible) {
this.loadData()
}
},
immediate: true
}
},
methods: {
async loadData() {
this.layoutReady = false
if (!this.roomId) {
this.layoutReady = true
return
}
try {
const res = await get4TData({ roomId: this.roomId })
let d = res && res.data
if (d) {
if (typeof d === 'string') {
try {
d = JSON.parse(d)
} catch (e) {
this.layoutReady = true
return
}
}
this.localData = {
threat: { text: d.threat?.text || '', images: d.threat?.images || [] },
task: { text: d.task?.text || '', images: d.task?.images || [] },
target: { text: d.target?.text || '', images: d.target?.images || [] },
tactic: { text: d.tactic?.text || '', images: d.tactic?.images || [] }
}
if (d.panelSize) {
const w = Number(d.panelSize.width)
const h = Number(d.panelSize.height)
if (!isNaN(w) && w >= 320 && w <= 800) this.panelWidth = w
if (!isNaN(h) && h >= 300 && h <= window.innerHeight - 60) this.panelHeight = h
}
if (d.panelPosition) {
const left = Number(d.panelPosition.left)
const top = Number(d.panelPosition.top)
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth)
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight)
}
}
} catch (e) {
console.warn('加载4T数据失败:', e)
} finally {
this.layoutReady = true
}
},
enterEditMode() {
this.editDataBackup = JSON.parse(JSON.stringify(this.localData))
this.isEditMode = true
},
saveAndExitEdit() {
if (!this.roomId) {
this.$message.warning('请先进入任务房间后再保存')
return
}
this.isEditMode = false
this.editDataBackup = null
this.$message.success('保存成功')
this.saveData().catch(() => {})
},
cancelEdit() {
if (this.editDataBackup) {
this.localData = JSON.parse(JSON.stringify(this.editDataBackup))
}
this.isEditMode = false
this.editDataBackup = null
this.$message.info('已取消编辑')
},
async saveData() {
if (!this.roomId) {
this.$message.warning('请先进入任务房间后再保存')
return
}
try {
const payload = {
...this.localData,
panelSize: { width: this.panelWidth, height: this.panelHeight }
}
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop }
}
await save4TData({
roomId: this.roomId,
data: JSON.stringify(payload)
})
} catch (e) {
console.error('保存4T失败:', e)
this.$message.error('保存4T内容失败,请检查网络或权限')
}
},
addImage(sectionKey) {
this.pendingAddSection = sectionKey
this.$refs.fileInput && this.$refs.fileInput.click()
},
onFileSelected(e) {
const file = e.target.files && e.target.files[0]
if (!file || !file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = (ev) => {
const base64 = ev.target.result
if (this.pendingAddSection && this.localData[this.pendingAddSection]) {
this.localData[this.pendingAddSection].images.push(base64)
}
this.pendingAddSection = null
}
reader.readAsDataURL(file)
e.target.value = ''
},
removeImage(sectionKey, index) {
if (this.localData[sectionKey] && this.localData[sectionKey].images) {
this.localData[sectionKey].images.splice(index, 1)
}
},
onDragStart(e) {
// @mousedown.stop
e.preventDefault()
this.isDragging = true
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth - 20)
const currentTop = this.panelTop != null ? this.panelTop : 80
this.dragStartX = e.clientX - currentLeft
this.dragStartY = e.clientY - currentTop
document.addEventListener('mousemove', this.onDragMove)
document.addEventListener('mouseup', this.onDragEnd)
},
onDragMove(e) {
if (!this.isDragging) return
e.preventDefault()
let left = e.clientX - this.dragStartX
let top = e.clientY - this.dragStartY
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left))
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top))
this.panelLeft = left
this.panelTop = top
},
onDragEnd() {
this.isDragging = false
document.removeEventListener('mousemove', this.onDragMove)
document.removeEventListener('mouseup', this.onDragEnd)
if (this.roomId) this.saveData()
},
onResizeStart(e) {
e.preventDefault()
e.stopPropagation()
this.isResizing = true
this.resizeStartX = e.clientX
this.resizeStartY = e.clientY
this.resizeStartW = this.panelWidth
this.resizeStartH = this.panelHeight
document.addEventListener('mousemove', this.onResizeMove)
document.addEventListener('mouseup', this.onResizeEnd)
},
onResizeMove(e) {
if (!this.isResizing) return
e.preventDefault()
const dx = e.clientX - this.resizeStartX
const dy = e.clientY - this.resizeStartY
let w = Math.max(320, Math.min(800, this.resizeStartW + dx))
let h = Math.max(300, Math.min(window.innerHeight - 60, this.resizeStartH + dy))
this.panelWidth = w
this.panelHeight = h
},
onResizeEnd() {
this.isResizing = false
document.removeEventListener('mousemove', this.onResizeMove)
document.removeEventListener('mouseup', this.onResizeEnd)
if (this.roomId) this.saveData()
}
}
}
</script>
<style scoped>
.four-t-panel {
position: fixed;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-out;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 138, 255, 0.2);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 138, 255, 0.15);
z-index: 200;
overflow: hidden;
display: flex;
flex-direction: column;
}
.four-t-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(0, 138, 255, 0.1);
border-bottom: 1px solid rgba(0, 138, 255, 0.15);
cursor: move;
user-select: none;
}
.four-t-panel-title {
font-weight: 600;
color: #008aff;
font-size: 14px;
}
.four-t-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.four-t-header-actions .el-button {
margin: 0;
}
.close-btn {
cursor: pointer;
font-size: 16px;
color: #606266;
}
.close-btn:hover {
color: #008aff;
}
.four-t-panel-body {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 12px;
padding: 12px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.four-t-section {
display: flex;
flex-direction: column;
gap: 8px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 10px;
min-height: 0;
flex: 1;
}
.four-t-section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 8px;
}
.four-t-section-title {
font-weight: 600;
font-size: 12px;
color: #606266;
user-select: none;
}
.four-t-add-btn-inline {
flex-shrink: 0;
width: 28px;
height: 28px;
font-size: 14px;
}
.four-t-section-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 0;
}
.four-t-content-box {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px;
background: #fff;
overflow-y: auto;
flex: 1;
min-height: 60px;
}
.four-t-section-content .four-t-textarea {
flex: 0 0 auto;
display: block;
margin-bottom: 0;
}
.four-t-section-content .four-t-textarea >>> .el-textarea {
margin-bottom: 0;
}
.four-t-section-content .four-t-textarea >>> textarea.el-textarea__inner {
font-size: 12px;
border: none !important;
padding: 0 !important;
resize: none;
box-sizing: border-box;
min-height: 36px !important;
background: transparent !important;
}
.four-t-section-content >>> .el-textarea.is-disabled .el-textarea__inner {
background: transparent !important;
color: #606266;
cursor: default;
}
.four-t-images-inline {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
align-items: flex-start;
flex-shrink: 0;
}
.four-t-image-item {
position: relative;
width: 64px;
height: 64px;
flex-shrink: 0;
}
.four-t-image-item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e4e7ed;
display: block;
}
.four-t-image-item .remove-img {
position: absolute;
top: -4px;
right: -4px;
width: 16px;
height: 16px;
background: #f56c6c;
color: #fff;
border-radius: 50%;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.four-t-add-btn {
width: 36px;
height: 36px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #909399;
font-size: 18px;
transition: all 0.2s;
}
.four-t-add-btn:hover {
border-color: #008aff;
color: #008aff;
background: rgba(0, 138, 255, 0.05);
}
.four-t-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
/* 右下角三角形拖拽区域 */
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.2) 50%);
}
.four-t-resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.4) 50%);
}
.four-t-panel.four-t-panel-ready {
opacity: 1;
pointer-events: auto;
}
</style>

20
ruoyi-ui/src/views/childRoom/RightPanel.vue

@ -119,16 +119,24 @@
<div class="conflict-details">
<div class="detail-item">
<span class="label">{{ $t('rightPanel.involvedRoutes') }}</span>
<span class="value">{{ conflict.routes.join('、') }}</span>
<span class="value">{{ conflict.routeName || (conflict.routes && conflict.routes.join('、')) }}</span>
</div>
<div class="detail-item">
<div v-if="conflict.fromWaypoint && conflict.toWaypoint" class="detail-item">
<span class="label">问题航段</span>
<span class="value">{{ conflict.fromWaypoint }} {{ conflict.toWaypoint }}</span>
</div>
<div v-if="conflict.time" class="detail-item">
<span class="label">{{ $t('rightPanel.conflictTime') }}</span>
<span class="value">{{ conflict.time }}</span>
</div>
<div class="detail-item">
<div v-if="conflict.position" class="detail-item">
<span class="label">{{ $t('rightPanel.conflictPosition') }}</span>
<span class="value">{{ conflict.position }}</span>
</div>
<div v-if="conflict.suggestion" class="detail-item suggestion">
<span class="label">建议</span>
<span class="value">{{ conflict.suggestion }}</span>
</div>
</div>
<div class="conflict-actions">
<el-button type="text" size="mini" class="blue-text-btn" @click="handleViewConflict(conflict)">
@ -831,6 +839,12 @@ export default {
font-weight: 500;
}
.detail-item.suggestion .value {
white-space: normal;
word-break: break-word;
color: #008aff;
}
.conflict-actions {
display: flex;
gap: 10px;

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

@ -156,6 +156,7 @@
<el-dropdown-item @click.native="conflictDisplay">{{ $t('topHeader.tools.conflictDisplay') }}</el-dropdown-item>
<el-dropdown-item @click.native="dataMaterials">{{ $t('topHeader.tools.dataMaterials') }}</el-dropdown-item>
<el-dropdown-item @click.native="coordinateConversion">{{ $t('topHeader.tools.coordinateConversion') }}</el-dropdown-item>
<el-dropdown-item @click.native="generateGanttChart">{{ $t('topHeader.tools.generateGanttChart') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@ -574,6 +575,10 @@ export default {
this.$emit('toggle-route', this.isRouteVisible)
},
generateGanttChart() {
this.$emit('generate-gantt-chart')
},
systemDescription() {
this.$emit('system-description')
},

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

@ -13,6 +13,7 @@
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')"
:scaleConfig="scaleConfig"
:coordinateFormat="coordinateFormat"
:bottomPanelVisible="bottomPanelVisible"
:route-locked="routeLocked"
:deduction-time-minutes="deductionMinutesFromK"
:room-id="currentRoomId"
@ -25,6 +26,9 @@
@open-route-dialog="handleOpenRouteEdit"
@copy-route="handleCopyRoute"
@route-copy-placed="handleRouteCopyPlaced"
@add-waypoint-at="handleAddWaypointAt"
@add-waypoint-placed="handleAddWaypointPlaced"
@toggle-waypoint-hold="handleToggleWaypointHold"
@waypoint-position-changed="handleWaypointPositionChanged"
@scale-click="handleScaleClick"
@platform-icon-updated="onPlatformIconUpdated"
@ -143,6 +147,7 @@
@toggle-airport="toggleAirport"
@toggle-landmark="toggleLandmark"
@toggle-route="toggleRoute"
@generate-gantt-chart="generateGanttChart"
@system-description="systemDescription"
@layer-favorites="layerFavorites"
@route-favorites="routeFavorites"
@ -209,7 +214,7 @@
@open-import-dialog="showImportDialog = true"
/>
<!-- 左下角工具面板 -->
<bottom-left-panel v-show="!screenshotMode" />
<bottom-left-panel v-show="!screenshotMode" @bottom-panel-visible="handleBottomPanelVisible" :room-id="currentRoomId" />
<!-- 底部时间轴最初版本的样式- 蓝色主题 -->
<div
v-show="!screenshotMode"
@ -220,7 +225,6 @@
<div class="popup-hide-btn" @click="hideKTimePopup" title="隐藏K时">
<i class="el-icon-arrow-down"></i>
</div>
<p class="deduction-hint">仅推演当前展示的航线K 时可随时由房主/管理员在右上角作战时间处修改</p>
<div class="timeline-controls">
<div class="current-time blue-time">
<i class="el-icon-time"></i>
@ -263,14 +267,6 @@
</div>
</div>
</div>
<div v-if="deductionWarnings.length > 0 || hasEarlyArrivalLegs" class="deduction-warnings">
<i class="el-icon-warning-outline"></i>
<span>{{ deductionWarnings[0] || '存在航段将提前到达下一航点。' }}</span>
<el-tooltip v-if="deductionWarnings.length > 1" :content="deductionWarnings.join(';')" placement="top">
<span class="warnings-more"> {{ deductionWarnings.length }} </span>
</el-tooltip>
<el-button v-if="hasEarlyArrivalLegs" type="text" size="mini" @click="openAddHoldFromFirstEarly">在此添加盘旋</el-button>
</div>
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body>
<div v-if="addHoldContext" class="add-hold-tip">{{ addHoldDialogTip }}</div>
<el-form :model="addHoldForm" label-width="100px" size="small">
@ -373,6 +369,13 @@
@confirm="handleImportConfirm"
/>
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击方案或4T时渲染 -->
<four-t-panel
v-if="show4TPanel && !screenshotMode"
:visible.sync="show4TPanel"
:room-id="currentRoomId"
/>
<el-dialog
title="新建方案"
:visible.sync="showPlanNameDialog"
@ -430,8 +433,9 @@ import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
import TopHeader from './TopHeader'
import FourTPanel from './FourTPanel'
import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes } from "@/api/system/routes";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle } from "@/api/system/routes";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms } from "@/api/system/rooms";
@ -453,7 +457,8 @@ export default {
LeftMenu,
RightPanel,
BottomLeftPanel,
TopHeader
TopHeader,
FourTPanel
},
data() {
return {
@ -494,6 +499,8 @@ export default {
platformIconSaveTimer: null,
//
showImportDialog: false,
// /
bottomPanelVisible: false,
//
screenshotMode: false,
@ -519,6 +526,7 @@ export default {
//
defaultMenuItems: [
{ id: 'file', name: '方案', icon: 'plan' },
{ id: '4t', name: '4T', icon: 'T' },
{ id: 'start', name: '冲突', icon: 'chongtu' },
{ id: 'insert', name: '平台', icon: 'el-icon-s-platform' },
{ id: 'pattern', name: '空域', icon: 'ky' },
@ -582,6 +590,8 @@ export default {
isRightPanelHidden: true, //
// K
showKTimePopup: false,
// 4T4T
show4TPanel: false,
// /
showAirport: true,
@ -600,26 +610,9 @@ export default {
activeRouteIds: [], // 线ID
/** 航线上锁状态:routeId -> true 上锁,与地图右键及右侧列表锁图标同步 */
routeLocked: {},
//
conflictCount: 2,
conflicts: [
{
id: 1,
title: '航线空间冲突',
routes: ['Alpha进场航线', 'Beta巡逻航线'],
time: 'K+01:20:00',
position: 'E116° N39°',
severity: 'high'
},
{
id: 2,
title: '时间窗口重叠',
routes: ['侦察覆盖区', '无人机巡逻'],
time: 'K+02:15:00',
position: 'E117° N38°',
severity: 'medium'
}
],
// runConflictCheck 线
conflictCount: 0,
conflicts: [],
//
activePlatformTab: 'air',
@ -635,7 +628,7 @@ export default {
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs
showAddHoldDialog: false,
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName }
addHoldForm: { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null },
addHoldForm: { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null },
missionDrawingActive: false,
missionDrawingPointsCount: 0,
isPlaying: false,
@ -737,8 +730,24 @@ export default {
if (this.currentRoomId) this.getRoomDetail();
},
methods: {
//
async handleOpenWaypointEdit(wpId, routeId) {
handleBottomPanelVisible(visible) {
this.bottomPanelVisible = visible
},
// wpId waypointIndex waypointIndex
async handleOpenWaypointEdit(wpId, routeId, waypointIndex) {
if (waypointIndex != null && (wpId == null || wpId === undefined)) {
try {
const response = await getRoutes(routeId);
if (response.code === 200 && response.data && response.data.waypoints) {
const wp = response.data.waypoints[waypointIndex];
if (wp) wpId = wp.id;
}
} catch (e) {}
}
if (wpId == null || wpId === undefined) {
this.$message.info('未找到该航点');
return;
}
console.log(`>>> [父组件接收] 航点 ID: ${wpId}, 所属航线 ID: ${routeId}`);
// 线
if (this.selectedRouteId != routeId) {
@ -747,12 +756,16 @@ export default {
const response = await getRoutes(routeId);
if (response.code === 200 && response.data) {
const fullRouteData = response.data;
// selectRoute
const fromList = this.routes.find(r => r.id === routeId);
// list platform 便
this.selectedRouteId = fullRouteData.id;
this.selectedRouteDetails = {
id: fullRouteData.id,
name: fullRouteData.callSign,
waypoints: fullRouteData.waypoints || []
waypoints: fullRouteData.waypoints || [],
platformId: fromList?.platformId,
platform: fromList?.platform,
attributes: fromList?.attributes
};
}
} catch (error) {
@ -831,6 +844,231 @@ export default {
}
},
/** 右键航点“向前/向后增加航点”:进入放置模式,传入 waypoints 给地图预览 */
handleAddWaypointAt({ routeId, waypointIndex, mode }) {
if (this.routeLocked[routeId]) {
this.$message.info('该航线已上锁,请先解锁');
return;
}
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || route.waypoints.length === 0) {
this.$message.warning('航线无航点数据');
return;
}
if (!this.$refs.cesiumMap || typeof this.$refs.cesiumMap.startAddWaypointAt !== 'function') return;
this.$refs.cesiumMap.startAddWaypointAt(routeId, waypointIndex, mode, route.waypoints);
},
/** 地图放置新航点后:调用 addWaypoints 插入,再按插入位置重排 seq 并重绘。向后添加时:当前第 K 个,新航点为第 K+1 个,原第 K+1 个及以后依次后移。 */
async handleAddWaypointPlaced({ routeId, waypointIndex, mode, position }) {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints) {
this.$message.warning('航线不存在或无航点');
return;
}
const waypoints = route.waypoints;
const refWp = waypoints[waypointIndex];
// insertIndex = waypointIndex+1 (waypointIndex+2) waypointIndex+2 waypointIndex+3
const insertIndex = mode === 'before' ? waypointIndex : waypointIndex + 1;
const prevWp = insertIndex > 0 ? waypoints[insertIndex - 1] : null;
const startTime = prevWp && prevWp.startTime ? prevWp.startTime : 'K+00:00:00';
const count = waypoints.length + 1;
const newName = `WP${insertIndex + 1}`;
try {
const addRes = await addWaypoints({
routeId,
name: newName,
seq: insertIndex + 1,
lat: position.lat,
lng: position.lng,
alt: position.alt,
speed: (refWp && refWp.speed != null) ? refWp.speed : 800,
startTime,
turnAngle: (refWp && refWp.turnAngle != null) ? refWp.turnAngle : (insertIndex === 0 || insertIndex === count - 1 ? 0 : 45)
});
await this.getList();
let updated = this.routes.find(r => r.id === routeId);
if (!updated || !updated.waypoints || updated.waypoints.length !== count) {
this.$message.warning('添加航点后数据未刷新');
return;
}
const list = updated.waypoints;
const prevIds = new Set(waypoints.map(w => w.id));
const newWp = (addRes && addRes.data && addRes.data.id != null && list.find(w => w.id === addRes.data.id))
? list.find(w => w.id === addRes.data.id)
: list.find(w => !prevIds.has(w.id)) || list[list.length - 1];
if (!newWp) {
this.$message.warning('未找到新插入的航点');
return;
}
const others = list.filter(w => w.id !== newWp.id);
others.sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
const reordered = [...others.slice(0, insertIndex), newWp, ...others.slice(insertIndex)];
const routeInListFirst = this.routes.find(r => r.id === routeId);
if (routeInListFirst) routeInListFirst.waypoints = reordered;
if (this.selectedRouteId === routeId) this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints: reordered };
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(reordered, routeId, routeInListFirst?.platformId, routeInListFirst?.platform, this.parseRouteStyle(routeInListFirst?.attributes || route?.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
const isHold = (w) => (w.pointType || w.point_type) === 'hold_circle' || (w.pointType || w.point_type) === 'hold_ellipse';
for (let i = 0; i < reordered.length; i++) {
const wp = reordered[i];
const newSeq = i + 1;
const isNewlyInserted = wp.id === newWp.id;
const nameToUse = isNewlyInserted ? (isHold(wp) ? `HOLD${newSeq}` : `WP${newSeq}`) : (wp.name || (isHold(wp) ? `HOLD${newSeq}` : `WP${newSeq}`));
await updateWaypoints({
id: wp.id,
routeId,
name: nameToUse,
seq: newSeq,
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
speed: wp.speed,
startTime: wp.startTime,
turnAngle: wp.turnAngle,
...(wp.pointType != null && { pointType: wp.pointType }),
...(wp.holdParams != null && { holdParams: wp.holdParams }),
...(wp.labelFontSize != null && { labelFontSize: wp.labelFontSize }),
...(wp.labelColor != null && { labelColor: wp.labelColor })
});
}
await this.getList();
updated = this.routes.find(r => r.id === routeId);
if (!updated || !updated.waypoints) {
this.$message.warning('刷新后未拿到航线航点');
return;
}
const sortedWaypoints = updated.waypoints.slice().sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
updated.waypoints = sortedWaypoints;
const routeInList = this.routes.find(r => r.id === routeId);
if (routeInList) routeInList.waypoints = sortedWaypoints;
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && updated.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: updated.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(sortedWaypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
if (this.selectedRouteId === routeId) {
this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints: sortedWaypoints };
}
this.$message.success('已添加航点');
} catch (e) {
this.$message.error(e.msg || e.message || '添加航点失败');
console.error(e);
}
},
/** 右键航点“切换盘旋航点”:普通航点设为盘旋(圆形默认),盘旋航点设为普通;支持首尾航点 */
async handleToggleWaypointHold({ routeId, dbId, waypointIndex }) {
if (this.routeLocked[routeId]) {
this.$message.info('该航线已上锁,请先解锁');
return;
}
let route = this.routes.find(r => r.id === routeId);
let waypoints = route && route.waypoints;
if (!waypoints || waypoints.length === 0) {
try {
const res = await getRoutes(routeId);
if (res.code === 200 && res.data && res.data.waypoints) {
waypoints = res.data.waypoints;
route = { ...route, waypoints };
}
} catch (e) {
this.$message.error('获取航线失败');
return;
}
}
if (!waypoints || waypoints.length === 0) {
this.$message.warning('航线无航点');
return;
}
const wp = dbId != null ? waypoints.find(w => w.id === dbId) : waypoints[waypointIndex];
if (!wp) {
this.$message.warning('未找到该航点');
return;
}
const index = waypoints.indexOf(wp);
const total = waypoints.length;
const isFirstOrLast = index === 0 || index === total - 1;
const isHold = this.isHoldWaypoint(wp);
let pointType;
let holdParams;
let turnAngle;
if (isHold) {
pointType = 'normal';
holdParams = null;
turnAngle = isFirstOrLast ? 0 : (Number(wp.turnAngle) || 45);
} else {
pointType = 'hold_circle';
holdParams = JSON.stringify({ radius: 15000, clockwise: true });
turnAngle = 0;
}
try {
const payload = {
id: wp.id,
routeId,
name: wp.name,
seq: wp.seq,
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
speed: wp.speed,
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
turnAngle,
pointType
};
if (holdParams != null) payload.holdParams = holdParams;
else payload.holdParams = '';
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
if (turnAngle > 0 && this.$refs.cesiumMap) {
payload.turnRadius = this.$refs.cesiumMap.getWaypointRadius(payload);
} else {
payload.turnRadius = 0;
}
const response = await updateWaypoints(payload);
if (response.code !== 200) throw new Error(response.msg || '更新失败');
const merged = { ...wp, ...payload };
const routeInList = this.routes.find(r => r.id === routeId);
if (routeInList && routeInList.waypoints) {
const idx = routeInList.waypoints.findIndex(p => p.id === wp.id);
if (idx !== -1) routeInList.waypoints.splice(idx, 1, merged);
}
if (this.selectedRouteId === routeId && this.selectedRouteDetails && this.selectedRouteDetails.waypoints) {
const idxS = this.selectedRouteDetails.waypoints.findIndex(p => p.id === wp.id);
if (idxS !== -1) this.selectedRouteDetails.waypoints.splice(idxS, 1, merged);
}
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
const r = this.routes.find(rr => rr.id === routeId);
if (r && r.waypoints) {
const roomId = this.currentRoomId;
if (roomId && r.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: r.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
}
this.$message.success(isHold ? '已设为普通航点' : '已设为盘旋航点');
} catch (e) {
this.$message.error(e.msg || e.message || '切换失败');
console.error(e);
}
},
/** 右键「复制航线」:拉取航点后进入复制预览,左键放置后弹窗保存 */
async handleCopyRoute(routeId) {
try {
@ -912,11 +1150,19 @@ export default {
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged);
}
if (this.$refs.cesiumMap) {
const routeForPlatform = this.routes.find(r => r.id === routeId) || route;
const roomId = this.currentRoomId;
if (roomId && routeForPlatform.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: routeForPlatform.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.renderRouteWaypoints(
waypoints,
routeId,
route.platformId,
route.platform,
routeForPlatform.platformId,
routeForPlatform.platform,
this.parseRouteStyle(route.attributes)
);
}
@ -1004,11 +1250,11 @@ export default {
//
this.showPlatformDialog = false;
},
/** 新建航线时写入数据库的默认样式(与地图默认显示一致:色实线线宽3) */
/** 新建航线时写入数据库的默认样式(与地图默认显示一致:墨绿色实线线宽3) */
getDefaultRouteAttributes() {
const defaultAttrs = {
waypointStyle: { pixelSize: 7, color: '#ffffff', outlineColor: '#0078FF', outlineWidth: 2 },
lineStyle: { style: 'solid', width: 3, color: '#800080', gapColor: '#000000', dashLength: 20 }
lineStyle: { style: 'solid', width: 3, color: '#2E5C3E', gapColor: '#000000', dashLength: 20 }
};
return JSON.stringify(defaultAttrs);
},
@ -1044,7 +1290,7 @@ export default {
this.selectedRoute = route;
this.showRouteDialog = true;
},
// 线
// 线
async updateRoute(updatedRoute) {
const index = this.routes.findIndex(r => r.id === updatedRoute.id);
if (index === -1) return;
@ -1064,6 +1310,41 @@ export default {
platform: updatedRoute.platform,
attributes: updatedRoute.attributes
};
// 线
if (updatedRoute.waypoints && updatedRoute.waypoints.length > 0) {
for (const wp of updatedRoute.waypoints) {
const payload = {
id: wp.id,
routeId: wp.routeId,
name: wp.name,
seq: wp.seq,
lat: wp.lat,
lng: wp.lng,
alt: wp.alt,
speed: wp.speed,
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
turnAngle: wp.turnAngle
};
if (wp.pointType != null) payload.pointType = wp.pointType;
if (wp.holdParams != null) payload.holdParams = wp.holdParams;
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
if (payload.turnAngle > 0 && this.$refs.cesiumMap) {
payload.turnRadius = this.$refs.cesiumMap.getWaypointRadius(payload);
} else {
payload.turnRadius = 0;
}
await updateWaypoints(payload);
}
const mergedWaypoints = (newRouteData.waypoints || []).map((oldWp) => {
const fromDialog = updatedRoute.waypoints.find((w) => w.id === oldWp.id);
return fromDialog ? { ...oldWp, ...fromDialog } : oldWp;
});
newRouteData.waypoints = mergedWaypoints;
if (this.selectedRouteDetails && this.selectedRouteId === updatedRoute.id) {
this.selectedRouteDetails.waypoints = mergedWaypoints;
}
}
this.routes.splice(index, 1, newRouteData);
if (this.selectedRouteDetails && this.selectedRouteId === updatedRoute.id) {
this.selectedRouteDetails.name = updatedRoute.name;
@ -1071,14 +1352,20 @@ export default {
this.selectedRouteDetails.platform = updatedRoute.platform;
this.selectedRouteDetails.attributes = updatedRoute.attributes;
}
this.$message.success('航线更新成功');
this.$message.success(updatedRoute.waypoints && updatedRoute.waypoints.length > 0 ? '航线与航点已保存' : '航线更新成功');
const routeStyle = updatedRoute.routeStyle || this.parseRouteStyle(updatedRoute.attributes);
if (this.$refs.cesiumMap && this.activeRouteIds.includes(updatedRoute.id)) {
const route = this.routes.find(r => r.id === updatedRoute.id);
if (route && route.waypoints && route.waypoints.length > 0) {
const roomId = this.currentRoomId;
if (roomId && route.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: updatedRoute.id, platformId: route.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(updatedRoute.id, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.removeRouteById(updatedRoute.id);
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, updatedRoute.id, route.platformId, route.platform, routeStyle);
//
this.$nextTick(() => this.updateDeductionPositions());
}
}
@ -1122,7 +1409,8 @@ export default {
// 线
if (this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(route.id);
// 线
// 线removeRouteById
this.$refs.cesiumMap.removeDetectionZoneByRouteId(route.id);
this.$refs.cesiumMap.removePowerZoneByRouteId(route.id);
}
// ID
@ -1176,18 +1464,26 @@ export default {
});
this.routes = allRoutes;
// activeRouteIds
if (this.activeRouteIds.length > 0) {
// 线
if (this.activeRouteIds.length > 0 && this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
await Promise.all(this.activeRouteIds.map(async (id) => {
const route = this.routes.find(r => r.id === id);
if (!route || !route.waypoints || route.waypoints.length === 0) return;
if (roomId && route.platformId) {
try {
const res = await getPlatformStyle({ roomId, routeId: id, platformId: route.platformId });
if (res.data) this.$refs.cesiumMap.setPlatformStyle(id, res.data);
} catch (_) {}
}
}));
this.$nextTick(() => {
this.activeRouteIds.forEach(id => {
const route = this.routes.find(r => r.id === id);
// 线
if (route && route.waypoints && route.waypoints.length > 0) {
if (this.$refs.cesiumMap) {
if (route && route.waypoints && route.waypoints.length > 0 && this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(id);
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
}
}
});
});
}
@ -1224,7 +1520,7 @@ export default {
openAddHoldDuringDrawing() {
this.addHoldContext = { mode: 'drawing' };
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.addHoldForm = { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.showAddHoldDialog = true;
},
@ -1242,9 +1538,24 @@ export default {
}
// 2. pointTypeholdParams 14 #333333
// 线 0 45°
// K ÷
const wpCount = this.tempMapPoints.length;
const finalWaypoints = this.tempMapPoints.map((p, index) => {
let cumulativeMinutes = 0;
const pointsWithStartTime = this.tempMapPoints.map((p, index) => {
const startTime = this.minutesToStartTime(index === 0 ? 0 : Math.ceil(cumulativeMinutes));
if (index < wpCount - 1) {
const next = this.tempMapPoints[index + 1];
const dist = this.segmentDistance(
{ lat: p.lat, lng: p.lng, alt: p.alt != null ? p.alt : 5000 },
{ lat: next.lat, lng: next.lng, alt: next.alt != null ? next.alt : 5000 }
);
const speedKmh = Number(p.speed) || 800;
cumulativeMinutes += (dist / 1000) * (60 / speedKmh);
}
return { ...p, startTime };
});
// 线 0 45°
const finalWaypoints = pointsWithStartTime.map((p, index) => {
const isFirstOrLast = index === 0 || index === wpCount - 1;
const defaultTurnAngle = isFirstOrLast ? 0.0 : 45.0;
return {
@ -1253,7 +1564,7 @@ export default {
lng: p.lng,
alt: p.alt != null ? p.alt : 5000.0,
speed: p.speed != null ? p.speed : 800.0,
startTime: p.startTime || 'K+00:00:00',
startTime: p.startTime,
turnAngle: p.turnAngle != null ? p.turnAngle : defaultTurnAngle,
labelFontSize: p.labelFontSize != null ? p.labelFontSize : 14,
labelColor: p.labelColor || '#333333',
@ -1383,13 +1694,21 @@ export default {
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
}
if (this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
const sd = this.selectedRouteDetails;
if (roomId && sd.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: sd.id, platformId: sd.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(sd.id, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.updateWaypointGraphicById(updatedWaypoint.id, updatedWaypoint.name);
this.$refs.cesiumMap.renderRouteWaypoints(
this.selectedRouteDetails.waypoints,
this.selectedRouteDetails.id,
this.selectedRouteDetails.platformId,
this.selectedRouteDetails.platform,
this.parseRouteStyle(this.selectedRouteDetails.attributes)
sd.waypoints,
sd.id,
sd.platformId,
sd.platform,
this.parseRouteStyle(sd.attributes)
);
}
this.showWaypointDialog = false;
@ -1805,6 +2124,18 @@ export default {
} catch (e) { /* 解析失败保留默认 */ }
if (Array.isArray(arr) && arr.length > 0) {
const defaultMap = (this.defaultMenuItems || []).reduce((m, it) => { m[it.id] = it; return m }, {})
const savedIds = new Set(arr.map(i => i.id))
// 4T defaultMenuItems
const defaultOrder = (this.defaultMenuItems || []).map(d => d.id)
defaultOrder.forEach(defId => {
if (!savedIds.has(defId) && defaultMap[defId]) {
const insertAfterId = defaultOrder[defaultOrder.indexOf(defId) - 1]
const refIdx = insertAfterId ? arr.findIndex(i => i.id === insertAfterId) : -1
const insertIdx = refIdx >= 0 ? refIdx + 1 : 0
arr.splice(insertIdx, 0, { ...defaultMap[defId] })
savedIds.add(defId)
}
})
this.menuItems = arr.map(item => {
const def = defaultMap[item.id]
if (def) return { ...item, name: def.name, icon: def.icon, action: def.action }
@ -2064,6 +2395,11 @@ export default {
this.$message.success(this.showRoute ? '显示航线' : '隐藏航线');
},
generateGanttChart() {
const url = this.$router.resolve('/ganttChart').href
window.open(url, '_blank')
},
systemDescription() {
this.$message.success('系统说明');
//
@ -2091,21 +2427,25 @@ export default {
this.handleMenuAction(item.action)
}
//
if (item.id === 'file' || item.id === 'start' || item.id === 'insert') {
// 4T
if (item.id === 'file' || item.id === 'start' || item.id === 'insert' || item.id === '4t') {
this.drawDom = false;
this.airspaceDrawDom = false;
}
//
if (item.id === 'file') {
//
// 4T
this.show4TPanel = true;
if (this.activeRightTab === 'plan' && !this.isRightPanelHidden) {
this.isRightPanelHidden = true;
} else {
this.activeRightTab = 'plan';
this.isRightPanelHidden = false;
}
} else if (item.id === '4t') {
// 4T4T
this.show4TPanel = !this.show4TPanel;
} else if (item.id === 'start') {
//
if (this.activeRightTab === 'conflict' && !this.isRightPanelHidden) {
@ -2377,11 +2717,14 @@ export default {
const pos = { lng: p.lng, lat: p.lat, alt: p.alt };
return {
segments: [{ startTime: globalMin, endTime: globalMax, startPos: pos, endPos: pos, type: 'wait' }],
warnings
warnings,
earlyArrivalLegs: [],
lateArrivalLegs: []
};
}
const effectiveTime = [points[0].minutes];
const segments = [];
const lateArrivalLegs = []; //
const path = pathData && pathData.path;
const segmentEndIndices = pathData && pathData.segmentEndIndices;
const holdArcRanges = pathData && pathData.holdArcRanges || {};
@ -2448,6 +2791,13 @@ export default {
warnings.push(
`某航段:距离约 ${(dist / 1000).toFixed(1)}km,计划 ${(scheduled - points[i].minutes).toFixed(0)} 分钟,当前速度 ${speedKmh}km/h 无法按时到达,约需 ≥${Math.ceil(requiredSpeedKmh)}km/h,请调整相对K时或速度。`
);
lateArrivalLegs.push({
legIndex: i,
fromName: waypoints[i].name,
toName: waypoints[i + 1].name,
requiredSpeedKmh: Math.ceil(requiredSpeedKmh),
speedKmh
});
} else if (actualArrival < scheduled - 0.5) {
warnings.push('存在航段将提前到达下一航点,平台将在该点等待至计划时间再飞往下一段。');
}
@ -2472,7 +2822,7 @@ export default {
earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name });
}
}
return { segments, warnings, earlyArrivalLegs };
return { segments, warnings, earlyArrivalLegs, lateArrivalLegs };
},
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
@ -2717,6 +3067,13 @@ export default {
await this.getList();
const updated = this.routes.find(r => r.id === routeId);
if (updated && updated.waypoints && this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && updated.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: updated.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes));
this.$nextTick(() => this.updateDeductionPositions());
@ -2786,7 +3143,8 @@ export default {
this.activeRouteIds.splice(index, 1);
if (this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(route.id);
// 线
// 线
this.$refs.cesiumMap.removeDetectionZoneByRouteId(route.id);
this.$refs.cesiumMap.removePowerZoneByRouteId(route.id);
}
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
@ -2823,10 +3181,14 @@ export default {
const waypoints = fullRouteData.waypoints || [];
this.activeRouteIds.push(route.id);
this.selectedRouteId = fullRouteData.id;
// list platformId/platform便
this.selectedRouteDetails = {
id: fullRouteData.id,
name: fullRouteData.callSign,
waypoints: waypoints
waypoints: waypoints,
platformId: route.platformId,
platform: route.platform,
attributes: route.attributes
};
// routes 线 waypoints
@ -2839,8 +3201,14 @@ export default {
}
if (waypoints.length > 0) {
//
if (this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && route.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: route.id, platformId: route.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
}
} else {
@ -2975,11 +3343,15 @@ export default {
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
getRoutes(lastId).then(res => {
if (res.code === 200 && res.data) {
const fromList = this.routes.find(r => r.id === lastId);
this.selectedRouteId = res.data.id;
this.selectedRouteDetails = {
id: res.data.id,
name: res.data.callSign,
waypoints: res.data.waypoints || []
waypoints: res.data.waypoints || [],
platformId: fromList?.platformId,
platform: fromList?.platform,
attributes: fromList?.attributes
};
}
}).catch(e => {
@ -2996,10 +3368,58 @@ export default {
}
},
//
// 线
runConflictCheck() {
this.conflictCount = 2;
this.$message.warning('检测到2处航线冲突');
const list = [];
let id = 1;
const routeIds = this.activeRouteIds && this.activeRouteIds.length > 0 ? this.activeRouteIds : this.routes.map(r => r.id);
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
routeIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || route.waypoints.length < 2) return;
let pathData = null;
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(route.waypoints);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
}
const { earlyArrivalLegs, lateArrivalLegs } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
const routeName = route.name || `航线${route.id}`;
(earlyArrivalLegs || []).forEach(leg => {
list.push({
id: id++,
title: '提前到达',
routeName,
fromWaypoint: leg.fromName,
toWaypoint: leg.toName,
time: this.minutesToStartTime(leg.actualArrival),
suggestion: '该航段将提前到达下一航点,建议在此段加入盘旋或延后下一航点计划时间。',
severity: 'high'
});
});
(lateArrivalLegs || []).forEach(leg => {
list.push({
id: id++,
title: '无法按时到达',
routeName,
fromWaypoint: leg.fromName,
toWaypoint: leg.toName,
suggestion: `当前速度不足,建议将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h,或延后下一航点计划时间。`,
severity: 'high'
});
});
});
this.conflicts = list;
this.conflictCount = list.length;
if (list.length > 0) {
this.$message.warning(`检测到 ${list.length} 处航线时间问题`);
} else {
this.$message.success('未发现航线时间冲突');
}
},
viewConflict(conflict) {
@ -3034,7 +3454,7 @@ export default {
overflow: hidden;
}
/* 地图背景 - 保持不变 */
/* 地图背景:使用相对路径便于 IDE 与构建解析;若需图片请将 map-background.png 放到 src/assets/ */
.map-background {
position: absolute;
top: 0;
@ -3042,8 +3462,7 @@ export default {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a2f4b 0%, #2c3e50 100%);
/* 正确的写法,直接复制这行替换 */
background: url('~@/assets/map-background.png');
/* 若已存在 src/assets/map-background.png,可改为:background: url('../../assets/map-background.png'); 并注释掉上一行 */
background-size: cover;
background-position: center;
z-index: 1;
@ -3267,40 +3686,6 @@ export default {
line-height: 1.5;
}
.deduction-hint {
margin: 0 0 8px 0;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.deduction-warnings {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 10px;
background: rgba(230, 162, 60, 0.15);
border: 1px solid rgba(230, 162, 60, 0.5);
border-radius: 6px;
font-size: 12px;
color: #b88230;
}
.deduction-warnings i {
flex-shrink: 0;
}
.deduction-warnings span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deduction-warnings .warnings-more {
flex-shrink: 0;
color: #008aff;
cursor: help;
}
.popup-hide-btn {
position: absolute;
top: -28px;

209
ruoyi-ui/src/views/dialogs/RouteEditDialog.vue

@ -2,7 +2,7 @@
<el-dialog
title="编辑航线"
:visible.sync="visible"
width="560px"
:width="activeTab === 'waypoints' ? '920px' : '560px'"
:close-on-click-modal="false"
append-to-body
custom-class="blue-dialog route-edit-dialog"
@ -10,6 +10,7 @@
<div class="route-edit-tab-bar">
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基础</button>
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'platform' }" @click="activeTab = 'platform'">平台</button>
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'waypoints' }" @click="activeTab = 'waypoints'">航点</button>
</div>
<div v-show="activeTab === 'basic'" class="tab-pane-wrap">
<div class="tab-pane-body basic-tab-content">
@ -190,6 +191,91 @@
</template>
</div>
</div>
<div v-show="activeTab === 'waypoints'" class="tab-pane-wrap">
<div class="tab-pane-body waypoints-tab-content">
<div v-if="!waypointsTableData.length" class="empty-tip">该航线暂无航点数据</div>
<template v-else>
<div class="waypoints-table-actions">
<template v-if="!waypointsEditMode">
<el-button type="primary" size="mini" class="blue-btn" @click="waypointsEditMode = true"> </el-button>
</template>
<template v-else>
<el-button type="primary" size="mini" class="blue-btn" @click="confirmWaypointsEdit"> </el-button>
<el-button size="mini" @click="cancelWaypointsEdit"> </el-button>
</template>
</div>
<div class="waypoints-table-wrap">
<el-table :data="waypointsTableData" border size="small" max-height="340" class="waypoints-table">
<el-table-column type="index" label="序号" width="52" align="center" />
<el-table-column label="名称" min-width="80">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ scope.row.name || '' }}</span>
<el-input v-else v-model="scope.row.name" size="mini" placeholder="名称" />
</template>
</el-table-column>
<el-table-column label="经度" min-width="95">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.lng) }}</span>
<el-input v-else v-model.number="scope.row.lng" size="mini" placeholder="经度" />
</template>
</el-table-column>
<el-table-column label="纬度" min-width="95">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.lat) }}</span>
<el-input v-else v-model.number="scope.row.lat" size="mini" placeholder="纬度" />
</template>
</el-table-column>
<el-table-column label="高度(m)" min-width="88">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.alt) }}</span>
<el-input v-else v-model.number="scope.row.alt" size="mini" placeholder="高度" />
</template>
</el-table-column>
<el-table-column label="速度" min-width="80">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.speed) }}</span>
<el-input v-else v-model.number="scope.row.speed" size="mini" placeholder="速度" />
</template>
</el-table-column>
<el-table-column label="转弯坡度(°)" min-width="100">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.turnAngle) }}</span>
<el-input v-else v-model.number="scope.row.turnAngle" size="mini" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="盘旋" width="72" align="center">
<template slot-scope="scope">
<el-tag :type="isHoldRow(scope.row) ? 'warning' : 'info'" size="mini">
{{ isHoldRow(scope.row) ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="相对K时(分)" min-width="110">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.minutesFromK) }}</span>
<el-input v-else v-model.number="scope.row.minutesFromK" size="mini" placeholder="0" />
</template>
</el-table-column>
<el-table-column label="字号" width="70">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.labelFontSize) }}</span>
<el-input v-else v-model.number="scope.row.labelFontSize" size="mini" placeholder="14" />
</template>
</el-table-column>
<el-table-column label="颜色" width="100">
<template slot-scope="scope">
<span v-if="!waypointsEditMode" class="color-text">{{ scope.row.labelColor || '' }}</span>
<div v-else class="table-color-wrap">
<el-color-picker v-model="scope.row.labelColor" size="mini" :predefine="presetColors" />
<span class="color-value">{{ scope.row.labelColor }}</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button size="mini" @click="visible = false"> </el-button>
@ -217,6 +303,8 @@ export default {
groundPlatforms: [],
selectedPlatformId: null,
selectedPlatform: null,
waypointsTableData: [],
waypointsEditMode: false,
form: {
id: '',
name: ''
@ -254,6 +342,7 @@ export default {
this.selectedPlatformId = val.platformId || null
this.selectedPlatform = val.platform ? { ...val.platform } : null
this.parseStyleFromRoute(val)
this.syncWaypointsTableData(val.waypoints || [])
}
},
immediate: true,
@ -352,6 +441,75 @@ export default {
attrs.lineStyle = { ...this.styleForm.line }
return JSON.stringify(attrs)
},
/** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */
startTimeToMinutes(s) {
if (!s || typeof s !== 'string') return 0
const m = s.match(/K([+-])(\d{2}):(\d{2})/)
if (!m) return 0
const sign = m[1] === '+' ? 1 : -1
const h = parseInt(m[2], 10)
const min = parseInt(m[3], 10)
return sign * (h * 60 + min)
},
/** 将相对 K 的分钟数转为 startTime 字符串 */
minutesToStartTime(m) {
const num = Number(m)
if (isNaN(num)) return 'K+00:00:00'
if (num >= 0) {
const h = Math.floor(num / 60)
const min = num % 60
return `K+${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`
}
const abs = Math.abs(num)
const h = Math.floor(abs / 60)
const min = abs % 60
return `K-${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`
},
isHoldRow(row) {
const t = (row && (row.pointType || row.point_type)) || 'normal'
return t === 'hold_circle' || t === 'hold_ellipse'
},
syncWaypointsTableData(waypoints) {
this.waypointsTableData = (waypoints || []).map(wp => ({
...wp,
minutesFromK: this.startTimeToMinutes(wp.startTime),
labelFontSize: wp.labelFontSize != null ? Number(wp.labelFontSize) : 14,
labelColor: wp.labelColor || '#333333'
}))
this.waypointsEditMode = false
},
formatNum(val) {
if (val === undefined || val === null || val === '') return '—'
const n = Number(val)
return isNaN(n) ? String(val) : n
},
confirmWaypointsEdit() {
this.waypointsEditMode = false
this.$message.success('表格已保存,点击下方「确定」将提交航线与航点')
},
cancelWaypointsEdit() {
this.syncWaypointsTableData(this.route.waypoints || [])
this.$message.info('已取消编辑')
},
getWaypointsPayloadForSave() {
const routeId = this.form.id
return this.waypointsTableData.map(row => ({
id: row.id,
routeId,
seq: row.seq,
name: row.name,
lng: row.lng,
lat: row.lat,
alt: row.alt,
speed: row.speed,
startTime: this.minutesToStartTime(row.minutesFromK),
turnAngle: row.turnAngle != null ? row.turnAngle : 0,
pointType: row.pointType || null,
holdParams: row.holdParams || null,
labelFontSize: row.labelFontSize != null ? row.labelFontSize : 14,
labelColor: row.labelColor || '#333333'
}))
},
handleSave() {
const payload = {
...this.form,
@ -363,6 +521,9 @@ export default {
line: { ...this.styleForm.line }
}
}
if (this.waypointsTableData.length > 0) {
payload.waypoints = this.getWaypointsPayloadForSave()
}
this.$emit('save', payload)
this.visible = false
}
@ -662,6 +823,52 @@ export default {
font-size: 12px;
}
/* 航点表格页 */
.waypoints-tab-content {
min-height: 380px;
max-height: 380px;
overflow: hidden;
padding: 0 16px;
background: #fff;
}
.waypoints-table-actions {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.waypoints-table-wrap {
background: #fff;
border-radius: 8px;
padding: 12px 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.05);
}
.waypoints-table {
width: 100%;
}
.waypoints-table .el-input-number {
width: 100%;
}
.waypoints-table .el-input-number .el-input__inner {
text-align: left;
}
.table-color-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.table-color-wrap .color-value {
font-size: 11px;
color: #909399;
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
}
.waypoints-table .color-text {
font-size: 12px;
color: #606266;
}
.blue-btn {
background: #165dff;
border-color: #165dff;

540
ruoyi-ui/src/views/ganttChart/index.vue

@ -0,0 +1,540 @@
<template>
<div class="gantt-chart-container">
<div class="gantt-header">
<h1 class="gantt-title">甘特图</h1>
<div class="gantt-actions">
<el-button type="primary" size="small" @click="refreshData">刷新</el-button>
<el-button type="default" size="small" @click="exportData">导出</el-button>
<el-button type="default" size="small" @click="closePage">关闭</el-button>
</div>
</div>
<div class="gantt-content">
<div class="gantt-toolbar">
<div class="toolbar-left">
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="hour">小时视图</el-radio-button>
<el-radio-button label="day">日视图</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-right">
<el-button-group size="small">
<el-button icon="el-icon-d-arrow-left" @click="prevPeriod">上一个</el-button>
<el-button @click="today">今天</el-button>
<el-button icon="el-icon-d-arrow-right" @click="nextPeriod">下一个</el-button>
</el-button-group>
<span class="current-period">{{ currentPeriodText }}</span>
</div>
</div>
<div class="gantt-body">
<div class="gantt-sidebar">
<div class="sidebar-header">任务名称</div>
<div class="sidebar-content">
<div v-for="item in ganttData" :key="item.id" class="sidebar-item">
<div class="item-icon" :style="{ backgroundColor: item.color }"></div>
<div class="item-name">{{ item.name }}</div>
</div>
</div>
</div>
<div class="gantt-timeline">
<div class="timeline-header">
<div v-for="(time, index) in timelineTimes" :key="index" class="timeline-time">
<div class="time-label">{{ time }}</div>
</div>
</div>
<div class="timeline-content">
<div v-for="item in ganttData" :key="item.id" class="timeline-row">
<div
class="timeline-bar"
:style="{
left: item.startPercent + '%',
width: item.durationPercent + '%',
backgroundColor: item.color
}"
>
<div class="bar-label">{{ item.name }}</div>
<div class="bar-time">{{ formatTime(item.startTime) }} - {{ formatTime(item.endTime) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="gantt-legend">
<div class="legend-title">图例</div>
<div class="legend-items">
<div v-for="legend in legends" :key="legend.type" class="legend-item">
<div class="legend-color" :style="{ backgroundColor: legend.color }"></div>
<div class="legend-text">{{ legend.label }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'GanttChart',
data() {
return {
viewMode: 'day',
currentDate: new Date(),
ganttData: [
{
id: 1,
name: '航线一',
type: 'route',
color: '#409EFF',
startTime: new Date(2026, 1, 20, 6, 30),
endTime: new Date(2026, 1, 20, 8, 30),
startPercent: 3.33,
durationPercent: 13.33
},
{
id: 2,
name: '航线二',
type: 'route',
color: '#67C23A',
startTime: new Date(2026, 1, 20, 8, 45),
endTime: new Date(2026, 1, 20, 10, 45),
startPercent: 21.67,
durationPercent: 13.33
},
{
id: 3,
name: '航线三',
type: 'route',
color: '#E6A23C',
startTime: new Date(2026, 1, 20, 11, 0),
endTime: new Date(2026, 1, 20, 13, 0),
startPercent: 40,
durationPercent: 13.33
},
{
id: 4,
name: '导弹发射任务',
type: 'missile',
color: '#F56C6C',
startTime: new Date(2026, 1, 20, 13, 15),
endTime: new Date(2026, 1, 20, 14, 15),
startPercent: 47.5,
durationPercent: 6.67
},
{
id: 5,
name: '空中加油任务',
type: 'refuel',
color: '#909399',
startTime: new Date(2026, 1, 20, 14, 30),
endTime: new Date(2026, 1, 20, 16, 0),
startPercent: 55,
durationPercent: 10
},
{
id: 6,
name: '航线四',
type: 'route',
color: '#409EFF',
startTime: new Date(2026, 1, 20, 16, 15),
endTime: new Date(2026, 1, 20, 18, 15),
startPercent: 63.33,
durationPercent: 13.33
},
{
id: 7,
name: '航线五',
type: 'route',
color: '#67C23A',
startTime: new Date(2026, 1, 20, 18, 30),
endTime: new Date(2026, 1, 20, 20, 0),
startPercent: 81.67,
durationPercent: 10
},
{
id: 8,
name: '侦察任务',
type: 'reconnaissance',
color: '#E6A23C',
startTime: new Date(2026, 1, 20, 9, 30),
endTime: new Date(2026, 1, 20, 11, 30),
startPercent: 30,
durationPercent: 13.33
}
],
legends: [
{ type: 'route', label: '航线', color: '#409EFF' },
{ type: 'missile', label: '导弹发射', color: '#F56C6C' },
{ type: 'refuel', label: '空中加油', color: '#909399' },
{ type: 'reconnaissance', label: '侦察任务', color: '#E6A23C' }
]
}
},
computed: {
currentPeriodText() {
const year = this.currentDate.getFullYear()
const month = this.currentDate.getMonth() + 1
const day = this.currentDate.getDate()
const hour = this.currentDate.getHours()
const minute = this.currentDate.getMinutes()
if (this.viewMode === 'hour') {
return `${year}${month}${day}${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
} else {
return `${year}${month}${day}`
}
},
timelineTimes() {
const times = []
const startHour = 6
const endHour = 20
for (let hour = startHour; hour <= endHour; hour++) {
times.push(`${hour.toString().padStart(2, '0')}:00`)
}
return times
}
},
methods: {
refreshData() {
this.$message.success('数据已刷新')
},
exportData() {
this.$message.success('数据导出中...')
},
closePage() {
window.close()
},
prevPeriod() {
if (this.viewMode === 'hour') {
this.currentDate.setHours(this.currentDate.getHours() - 1)
} else {
this.currentDate.setDate(this.currentDate.getDate() - 1)
}
},
nextPeriod() {
if (this.viewMode === 'hour') {
this.currentDate.setHours(this.currentDate.getHours() + 1)
} else {
this.currentDate.setDate(this.currentDate.getDate() + 1)
}
},
today() {
this.currentDate = new Date()
},
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
}
}
</script>
<style scoped>
.gantt-chart-container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.gantt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.gantt-title {
margin: 0;
font-size: 20px;
font-weight: 500;
color: #303133;
}
.gantt-actions {
display: flex;
gap: 10px;
}
.gantt-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
}
.gantt-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 10px 15px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 15px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 15px;
}
.current-period {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.gantt-body {
flex: 1;
display: flex;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.gantt-sidebar {
width: 250px;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
}
.sidebar-header {
height: 50px;
display: flex;
align-items: center;
padding: 0 15px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-size: 14px;
font-weight: 500;
color: #606266;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
}
.sidebar-item {
height: 50px;
display: flex;
align-items: center;
padding: 0 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s;
}
.sidebar-item:hover {
background-color: #f5f7fa;
}
.item-icon {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
.item-name {
font-size: 13px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gantt-timeline {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.timeline-header {
height: 50px;
display: flex;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}
.timeline-time {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
border-right: 1px solid #e4e7ed;
}
.time-label {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.timeline-content {
flex: 1;
position: relative;
overflow-y: auto;
background: linear-gradient(to right,
transparent 0%,
transparent 6.667%,
#f0f0f0 6.667%,
#f0f0f0 6.677%,
transparent 6.677%,
transparent 13.333%,
#f0f0f0 13.333%,
#f0f0f0 13.343%,
transparent 13.343%,
transparent 20%,
#f0f0f0 20%,
#f0f0f0 20.01%,
transparent 20.01%,
transparent 26.667%,
#f0f0f0 26.667%,
#f0f0f0 26.677%,
transparent 26.677%,
transparent 33.333%,
#f0f0f0 33.333%,
#f0f0f0 33.343%,
transparent 33.343%,
transparent 40%,
#f0f0f0 40%,
#f0f0f0 40.01%,
transparent 40.01%,
transparent 46.667%,
#f0f0f0 46.667%,
#f0f0f0 46.677%,
transparent 46.677%,
transparent 53.333%,
#f0f0f0 53.333%,
#f0f0f0 53.343%,
transparent 53.343%,
transparent 60%,
#f0f0f0 60%,
#f0f0f0 60.01%,
transparent 60.01%,
transparent 66.667%,
#f0f0f0 66.667%,
#f0f0f0 66.677%,
transparent 66.677%,
transparent 73.333%,
#f0f0f0 73.333%,
#f0f0f0 73.343%,
transparent 73.343%,
transparent 80%,
#f0f0f0 80%,
#f0f0f0 80.01%,
transparent 80.01%,
transparent 86.667%,
#f0f0f0 86.667%,
#f0f0f0 86.677%,
transparent 86.677%,
transparent 93.333%,
#f0f0f0 93.333%,
#f0f0f0 93.343%,
transparent 93.343%,
transparent 100%
);
}
.timeline-row {
height: 50px;
position: relative;
border-bottom: 1px solid #f0f0f0;
}
.timeline-bar {
position: absolute;
top: 8px;
height: 36px;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.timeline-bar:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.bar-label {
font-size: 12px;
color: #fff;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px;
}
.bar-time {
font-size: 10px;
color: rgba(255, 255, 255, 0.9);
margin-top: 2px;
}
.gantt-legend {
display: flex;
align-items: center;
padding: 15px 20px;
background: #fff;
border-top: 1px solid #e4e7ed;
margin-top: 15px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.legend-title {
font-size: 14px;
font-weight: 500;
color: #606266;
margin-right: 20px;
}
.legend-items {
display: flex;
gap: 20px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 2px;
}
.legend-text {
font-size: 13px;
color: #606266;
}
</style>

22
sql/sys_timeline_segment.sql

@ -0,0 +1,22 @@
-- ----------------------------
-- 时间轴信息表
-- ----------------------------
drop table if exists sys_timeline_segment;
create table sys_timeline_segment (
segment_id bigint(20) not null auto_increment comment '时间段ID',
room_id bigint(20) not null comment '房间ID',
segment_time varchar(8) not null comment '时间点(HH:mm:ss)',
segment_name varchar(50) not null comment '时间段名称',
segment_desc varchar(500) default '' comment '时间段描述',
primary key (segment_id),
key idx_room_id (room_id)
) engine=innodb auto_increment=100 comment = '时间轴信息表';
-- ----------------------------
-- 初始化时间轴信息表数据(示例数据)
-- ----------------------------
insert into sys_timeline_segment values(1, 1, '08:00:00', '任务准备', '开始准备任务所需的资源和设备');
insert into sys_timeline_segment values(2, 1, '10:00:00', '资源调配', '完成资源的调配和分配');
insert into sys_timeline_segment values(3, 1, '12:00:00', '任务执行', '开始执行主要任务');
insert into sys_timeline_segment values(4, 1, '14:00:00', '任务监控', '监控任务执行进度');
insert into sys_timeline_segment values(5, 1, '16:00:00', '任务完成', '任务完成,进行总结');
Loading…
Cancel
Save