Compare commits

...

4 Commits

  1. 39
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  2. 27
      ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java
  3. 30
      ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java
  4. 27
      ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql
  5. 832
      ruoyi-ui/public/stepEditor.html
  6. 19
      ruoyi-ui/src/api/system/routes.js
  7. 6
      ruoyi-ui/src/router/index.js
  8. 872
      ruoyi-ui/src/views/cesiumMap/index.vue
  9. 289
      ruoyi-ui/src/views/childRoom/BottomLeftPanel.vue
  10. 439
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  11. 599
      ruoyi-ui/src/views/childRoom/StepEditor.vue
  12. 858
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  13. 767
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue
  14. 535
      ruoyi-ui/src/views/childRoom/index.vue
  15. 129
      ruoyi-ui/src/views/dialogs/RouteEditDialog.vue
  16. 174
      ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

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

@ -124,6 +124,45 @@ public class RoutesController extends BaseController
}
/**
* 保存六步法任务页数据到 Redis背景图标文本框
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/saveTaskPageData")
public AjaxResult saveTaskPageData(@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) + ":task_page";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取六步法任务页数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getTaskPageData")
public AjaxResult getTaskPageData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":task_page";
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')")

27
ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java

@ -245,6 +245,33 @@ public class RouteWaypoints extends BaseEntity
return displayStyle != null && displayStyle.getOutlineColor() != null ? displayStyle.getOutlineColor() : "#000000";
}
/** 航段模式:fixed_speed-定速,fixed_time-定时,空或null-普通 */
public String getSegmentMode() {
return displayStyle != null ? displayStyle.getSegmentMode() : null;
}
public void setSegmentMode(String segmentMode) {
getOrCreateDisplayStyle().setSegmentMode(segmentMode);
}
/** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时,可与相对K时分离支持“定时到达+盘旋至相对K时出发” */
public Double getSegmentTargetMinutes() {
return displayStyle != null ? displayStyle.getSegmentTargetMinutes() : null;
}
public void setSegmentTargetMinutes(Double segmentTargetMinutes) {
getOrCreateDisplayStyle().setSegmentTargetMinutes(segmentTargetMinutes);
}
/** 定速目标(km/h):fixed_speed 时表示本航段固定速度 */
public Double getSegmentTargetSpeed() {
return displayStyle != null ? displayStyle.getSegmentTargetSpeed() : null;
}
public void setSegmentTargetSpeed(Double segmentTargetSpeed) {
getOrCreateDisplayStyle().setSegmentTargetSpeed(segmentTargetSpeed);
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

30
ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java

@ -19,6 +19,12 @@ public class WaypointDisplayStyle implements Serializable {
private Integer pixelSize;
/** 航点圆点边框颜色,默认 #000000 */
private String outlineColor;
/** 航段模式:fixed_speed-定速(移动下一航点改下一航点相对K时), fixed_time-定时(移动本航点改上一航点速度), 空或null-普通 */
private String segmentMode;
/** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时(分),与相对K时分离后可支持“定时到达+盘旋至相对K时出发” */
private Double segmentTargetMinutes;
/** 定速目标(km/h):fixed_speed 时表示本航段使用的固定速度,用于重算下一航点相对K时 */
private Double segmentTargetSpeed;
public Integer getLabelFontSize() {
return labelFontSize;
@ -59,4 +65,28 @@ public class WaypointDisplayStyle implements Serializable {
public void setOutlineColor(String outlineColor) {
this.outlineColor = outlineColor;
}
public String getSegmentMode() {
return segmentMode;
}
public void setSegmentMode(String segmentMode) {
this.segmentMode = segmentMode;
}
public Double getSegmentTargetMinutes() {
return segmentTargetMinutes;
}
public void setSegmentTargetMinutes(Double segmentTargetMinutes) {
this.segmentTargetMinutes = segmentTargetMinutes;
}
public Double getSegmentTargetSpeed() {
return segmentTargetSpeed;
}
public void setSegmentTargetSpeed(Double segmentTargetSpeed) {
this.segmentTargetSpeed = segmentTargetSpeed;
}
}

27
ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql

@ -1,27 +0,0 @@
-- 将航点显示相关字段合并为单列 JSON:display_style
-- 执行前请备份。若表带 schema(如 ry.route_waypoints)请自行替换表名。
-- 1. 新增 JSON 列
ALTER TABLE route_waypoints
ADD COLUMN display_style JSON DEFAULT NULL COMMENT '航点显示样式JSON: labelFontSize,labelColor,color,pixelSize,outlineColor';
-- 2. 从旧列回填(仅存在 label_font_size / label_color 时)
UPDATE route_waypoints
SET display_style = JSON_OBJECT(
'labelFontSize', COALESCE(label_font_size, 16),
'labelColor', COALESCE(label_color, '#000000'),
'color', '#ffffff',
'pixelSize', 12,
'outlineColor', '#000000'
)
WHERE display_style IS NULL;
-- 3. 删除旧列(若你曾加过 color/pixel_size/outline_color 三列,也一并删除)
ALTER TABLE route_waypoints
DROP COLUMN label_font_size,
DROP COLUMN label_color;
-- 若存在以下列则逐条执行(没有则跳过):
-- ALTER TABLE route_waypoints DROP COLUMN color;
-- ALTER TABLE route_waypoints DROP COLUMN pixel_size;
-- ALTER TABLE route_waypoints DROP COLUMN outline_color;

832
ruoyi-ui/public/stepEditor.html

@ -1,832 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>六步法编辑器</title>
<link rel="stylesheet" href="./element-ui.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #fafafa;
}
#app {
width: 100%;
height: 100vh;
}
.step-editor-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #fafafa;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
height: 56px;
background: #fff;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-right {
display: flex;
gap: 12px;
}
.back-btn {
padding: 8px 12px;
font-size: 16px;
color: #666;
}
.back-btn:hover {
color: #409EFF;
}
.step-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.save-btn {
padding: 8px 24px;
font-size: 16px;
border-radius: 4px;
}
.export-btn {
padding: 8px 24px;
font-size: 16px;
border-radius: 4px;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 0;
padding: 0 32px;
height: 44px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 4px;
color: #666;
font-size: 16px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #f5f5f5;
color: #409EFF;
}
.format-icon {
font-size: 16px;
font-weight: 700;
font-family: Arial, sans-serif;
font-style: normal;
}
.format-icon.italic {
font-style: italic;
}
.format-icon.underline {
text-decoration: underline;
}
.format-icon.strike {
text-decoration: line-through;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: #e8e8e8;
margin: 0 16px;
}
.word-count {
font-size: 12px;
color: #999;
padding: 0 12px;
min-width: 60px;
text-align: right;
}
.editor-content {
flex: 1;
overflow: hidden;
padding: 16px;
display: flex;
justify-content: center;
}
.editor-area {
width: 100%;
max-width: 100%;
height: 100%;
background: #fff;
border-radius: 8px;
padding: 24px;
overflow-y: auto;
outline: none;
font-size: 16px;
line-height: 1.8;
color: #333;
}
.editor-area:empty:before {
content: attr(data-placeholder);
color: #c0c4cc;
pointer-events: none;
}
.editor-area p {
margin: 16px 0;
}
.editor-area img {
max-width: 100%;
height: auto;
margin: 10px 0;
}
.editor-area a {
color: #409EFF;
text-decoration: underline;
}
.editor-area ul,
.editor-area ol {
margin: 16px 0;
padding-left: 32px;
}
.editor-area li {
margin: 8px 0;
}
</style>
</head>
<body>
<div id="app">
<div class="step-editor-container">
<div class="editor-header">
<div class="header-left">
<el-button type="text" icon="el-icon-back" @click="goBack" class="back-btn">
关闭
</el-button>
<span class="step-title">{{ stepTitle }}</span>
</div>
<div class="header-right">
<el-button type="primary" @click="saveContent" class="save-btn">
<i class="el-icon-check"></i>
保存
</el-button>
<el-button type="success" @click="exportContent" class="export-btn">
<i class="el-icon-download"></i>
导出
</el-button>
</div>
</div>
<div class="editor-toolbar">
<div class="toolbar-group">
<el-tooltip content="撤销" placement="top">
<el-button type="text" @click="formatText('undo')" class="toolbar-btn">
<i class="el-icon-refresh-left"></i>
</el-button>
</el-tooltip>
<el-tooltip content="重做" placement="top">
<el-button type="text" @click="formatText('redo')" class="toolbar-btn">
<i class="el-icon-refresh-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="导入文档" placement="top">
<el-button type="text" @click="importDocument" class="toolbar-btn">
<i class="el-icon-document"></i>
</el-button>
</el-tooltip>
<el-tooltip content="导入图片" placement="top">
<el-button type="text" @click="importImage" class="toolbar-btn">
<i class="el-icon-picture"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="加粗" placement="top">
<el-button type="text" @click="formatText('bold')" class="toolbar-btn">
<span class="format-icon">B</span>
</el-button>
</el-tooltip>
<el-tooltip content="斜体" placement="top">
<el-button type="text" @click="formatText('italic')" class="toolbar-btn">
<span class="format-icon italic">I</span>
</el-button>
</el-tooltip>
<el-tooltip content="下划线" placement="top">
<el-button type="text" @click="formatText('underline')" class="toolbar-btn">
<span class="format-icon underline">U</span>
</el-button>
</el-tooltip>
<el-tooltip content="删除线" placement="top">
<el-button type="text" @click="formatText('strikeThrough')" class="toolbar-btn">
<span class="format-icon strike">S</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="上标" placement="top">
<el-button type="text" @click="formatText('superscript')" class="toolbar-btn">
<span class="format-icon"></span>
</el-button>
</el-tooltip>
<el-tooltip content="下标" placement="top">
<el-button type="text" @click="formatText('subscript')" class="toolbar-btn">
<span class="format-icon">x₂</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="左对齐" placement="top">
<el-button type="text" @click="formatText('justifyLeft')" class="toolbar-btn">
<i class="el-icon-s-unfold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="居中对齐" placement="top">
<el-button type="text" @click="formatText('justifyCenter')" class="toolbar-btn">
<i class="el-icon-s-grid"></i>
</el-button>
</el-tooltip>
<el-tooltip content="右对齐" placement="top">
<el-button type="text" @click="formatText('justifyRight')" class="toolbar-btn">
<i class="el-icon-s-fold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="两端对齐" placement="top">
<el-button type="text" @click="formatText('justifyFull')" class="toolbar-btn">
<i class="el-icon-menu"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="无序列表" placement="top">
<el-button type="text" @click="formatText('insertUnorderedList')" class="toolbar-btn">
<i class="el-icon-plus"></i>
</el-button>
</el-tooltip>
<el-tooltip content="有序列表" placement="top">
<el-button type="text" @click="formatText('insertOrderedList')" class="toolbar-btn">
<i class="el-icon-sort"></i>
</el-button>
</el-tooltip>
<el-tooltip content="减少缩进" placement="top">
<el-button type="text" @click="formatText('outdent')" class="toolbar-btn">
<i class="el-icon-back"></i>
</el-button>
</el-tooltip>
<el-tooltip content="增加缩进" placement="top">
<el-button type="text" @click="formatText('indent')" class="toolbar-btn">
<i class="el-icon-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="全屏" placement="top">
<el-button type="text" @click="toggleFullscreen" class="toolbar-btn">
<i :class="isFullscreen ? 'el-icon-crop' : 'el-icon-full-screen'"></i>
</el-button>
</el-tooltip>
<div class="word-count">
{{ wordCount }} 字
</div>
</div>
</div>
<div class="editor-content">
<div
ref="editor"
class="editor-area"
contenteditable="true"
:data-placeholder="placeholder"
@input="onInput"
@keydown="onKeydown"
@focus="onFocus"
@blur="onBlur"
>
</div>
</div>
<input
ref="fileInput"
type="file"
style="display: none"
@change="handleFileChange"
/>
</div>
</div>
<script src="https://unpkg.com/vue@2.6.12/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://unpkg.com/mammoth@1.6.0/mammoth.browser.min.js"></script>
<script>
new Vue({
el: '#app',
data() {
return {
stepTitle: '',
stepIndex: 0,
content: '',
placeholder: '在此输入内容...',
uploadType: '',
savedContent: {},
wordCount: 0,
isFullscreen: false
}
},
created() {
const urlParams = new URLSearchParams(window.location.search)
this.stepTitle = urlParams.get('title') || '编辑器'
this.stepIndex = parseInt(urlParams.get('index')) || 0
this.loadSavedContent()
},
methods: {
goBack() {
window.close()
},
formatText(command) {
document.execCommand(command, false, null)
this.$refs.editor.focus()
},
importDocument() {
this.uploadType = 'document'
this.$refs.fileInput.accept = '.txt,.md,.docx'
this.$refs.fileInput.click()
},
importImage() {
this.uploadType = 'image'
this.$refs.fileInput.accept = 'image/*'
this.$refs.fileInput.click()
},
handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
if (this.uploadType === 'document') {
this.handleDocumentUpload(file)
} else if (this.uploadType === 'image') {
this.handleImageUpload(file)
}
event.target.value = ''
},
handleDocumentUpload(file) {
const fileName = file.name.toLowerCase()
const fileExt = fileName.substring(fileName.lastIndexOf('.'))
if (fileExt === '.docx') {
this.handleDocxUpload(file)
} else if (fileExt === '.txt' || fileExt === '.md') {
this.handleTextFileUpload(file)
} else {
this.$message.warning('不支持的文件格式,请使用 .txt、.md 或 .docx 文件')
}
},
handleDocxUpload(file) {
this.$message.warning('正在解析 Word 文档...')
const reader = new FileReader()
reader.onload = (e) => {
try {
const arrayBuffer = e.target.result
this.parseDocx(arrayBuffer)
} catch (error) {
console.error('解析 Word 文档失败:', error)
this.$message.error('解析 Word 文档失败,请将文档另存为 .txt 或 .md 格式后重试')
}
}
reader.readAsArrayBuffer(file)
},
async parseDocx(arrayBuffer) {
try {
const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer })
const text = result.value
this.insertText(text)
this.$message.success('Word 文档导入成功')
} catch (error) {
console.error('mammoth.js 解析失败:', error)
this.$message.error('解析 Word 文档失败')
}
},
handleTextFileUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
let text = e.target.result
if (!text || text.trim() === '') {
this.$message.warning('文档内容为空')
return
}
if (this.isGarbled(text)) {
this.tryGBKEncoding(file)
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'UTF-8')
},
isGarbled(text) {
const garbageChars = text.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g)
return garbageChars && garbageChars.length > text.length * 0.3
},
tryGBKEncoding(file) {
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target.result
if (this.isGarbled(text)) {
this.$message.error('文档编码无法识别,请确保文档使用 UTF-8 或 GBK 编码')
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'GBK')
},
handleImageUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
const img = `<img src="${e.target.result}" style="max-width: 100%; height: auto; margin: 10px 0;" />`
this.insertHTML(img)
this.$message.success('图片导入成功')
}
reader.readAsDataURL(file)
},
insertText(text) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const textNode = document.createTextNode(text)
range.insertNode(textNode)
} else {
if (editor.innerHTML.trim() === '') {
editor.innerHTML = `<p>${text}</p>`
} else {
editor.innerHTML += `<p>${text}</p>`
}
}
this.$nextTick(() => {
this.updateWordCount()
})
},
insertHTML(html) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const div = document.createElement('div')
div.innerHTML = html
range.insertNode(div)
} else {
if (editor.innerHTML.trim() === '') {
editor.innerHTML = `<p>${html}</p>`
} else {
editor.innerHTML += `<p>${html}</p>`
}
}
this.$nextTick(() => {
this.updateWordCount()
})
},
onInput() {
this.content = this.$refs.editor.innerHTML
this.updateWordCount()
},
updateWordCount() {
const text = this.$refs.editor.innerText || ''
this.wordCount = text.replace(/\s+/g, '').length
},
toggleFullscreen() {
this.isFullscreen = !this.isFullscreen
const container = document.querySelector('.step-editor-container')
if (this.isFullscreen) {
if (container.requestFullscreen) {
container.requestFullscreen()
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen()
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen()
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
}
},
onFocus() {
const editor = this.$refs.editor
if (editor.innerText.trim() === '' && editor.innerHTML.trim() === '') {
editor.innerHTML = ''
}
},
onBlur() {
const editor = this.$refs.editor
if (editor.innerHTML.trim() === '' || editor.innerText.trim() === '') {
editor.innerHTML = ''
}
},
onKeydown(event) {
if (event.ctrlKey || event.metaKey) {
if (event.key === 's') {
event.preventDefault()
this.saveContent()
}
}
},
saveContent() {
this.content = this.$refs.editor.innerHTML
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
savedData[`step_${this.stepIndex}`] = {
content: this.content,
updateTime: new Date().toISOString()
}
localStorage.setItem('stepEditorContent', JSON.stringify(savedData))
this.$message.success('保存成功')
},
async exportContent() {
this.content = this.$refs.editor.innerHTML
let contentWithBase64 = this.content
try {
const base64 = await this.imageToBase64('./logo2.jpg')
contentWithBase64 = this.content.replace(/<img src="\.\/logo2\.jpg"[^>]*>/g,
`<img src="${base64}" style="height: 50px; width: auto;">`)
} catch (e) {
console.log('Logo conversion skipped:', e)
}
const completeHtml = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.stepTitle}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1 {
font-size: 32px;
font-weight: bold;
margin: 20px 0;
text-align: center;
}
h2 {
font-size: 24px;
font-weight: bold;
margin: 20px 0;
}
p {
margin: 10px 0;
}
.editor-content {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="editor-content">
${contentWithBase64}
</div>
</body>
</html>
`
const blob = new Blob([completeHtml], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${this.stepTitle}_${new Date().toLocaleString().replace(/[/:]/g, '-')}.html`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
this.$message.success('导出成功')
},
imageToBase64(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
resolve(canvas.toDataURL('image/jpeg'))
}
img.onerror = reject
img.src = url
})
},
loadSavedContent() {
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
const stepData = savedData[`step_${this.stepIndex}`]
if (stepData && stepData.content) {
this.$nextTick(() => {
this.$refs.editor.innerHTML = stepData.content
this.content = stepData.content
this.updateWordCount()
})
} else {
this.setDefaultContent()
}
},
setDefaultContent() {
this.$nextTick(() => {
if (this.stepIndex === 0) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">点名</h1>
<div></div>
</div>
<div style="display: flex; justify-content: center; gap: 580px; margin-top: 30px; text-align: center;">
<div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
</div>
<div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
</div>
<div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p><p>&nbsp;</p></div>
</div>
</div>
`
} else if (this.stepIndex === 1) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">集体协同</h1>
<div></div>
</div>
<p style="font-size: 18px; font-weight: bold; margin: 20px 0; text-align: center;">任务主要目标、次要目标及风险等级</p>
<div style="margin-top: 30px;">
<p style="font-size: 16px; font-weight: bold; margin-bottom: 10px;">主要目标:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">次要目标:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">风险等级:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div>
`
} else if (this.stepIndex === 2) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">战术规划</h1>
<div></div>
</div>
<h2 style="font-size: 24px; font-weight: bold; margin: 20px 0;">整体战术:</h2>
`
} else if (this.stepIndex === 3) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">资源准备</h1>
<div></div>
</div>
<p style="font-size: 18px; font-weight: bold; margin: 20px 0; text-align: center;">资源准备、风险识别及应对措施</p>
<div style="margin-top: 30px;">
<p style="font-size: 16px; font-weight: bold; margin-bottom: 10px;">资源准备:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">风险识别:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">应对措施:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div>
`
} else if (this.stepIndex === 4) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">指挥演练</h1>
<div></div>
</div>
<p style="font-size: 18px; font-weight: bold; margin: 20px 0; text-align: center;">任务分工、时间节点及实时监控</p>
<div style="margin-top: 30px;">
<p style="font-size: 16px; font-weight: bold; margin-bottom: 10px;">任务分工:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">时间节点:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">实时监控:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div>
`
} else if (this.stepIndex === 5) {
this.$refs.editor.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; margin-bottom: 20px;">
<img src="./logo2.jpg" alt="logo" style="height: 50px; width: auto; justify-self: start;">
<h1 style="font-size: 32px; font-weight: bold; margin: 0; text-align: center;">总结评估</h1>
<div></div>
</div>
<p style="font-size: 18px; font-weight: bold; margin: 20px 0; text-align: center;">任务完成情况、效果评估及总结改进</p>
<div style="margin-top: 30px;">
<p style="font-size: 16px; font-weight: bold; margin-bottom: 10px;">完成情况:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">效果评估:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">总结改进:</p>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div>
`
} else {
this.$refs.editor.innerHTML = ''
}
this.content = this.$refs.editor.innerHTML
this.updateWordCount()
})
}
}
})
</script>
</body>
</html>

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

@ -81,6 +81,25 @@ export function get4TData(params) {
})
}
// 保存六步法任务页数据到 Redis(背景、图标、文本框)
export function saveTaskPageData(data) {
return request({
url: '/system/routes/saveTaskPageData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从 Redis 获取六步法任务页数据
export function getTaskPageData(params) {
return request({
url: '/system/routes/getTaskPageData',
method: 'get',
params
})
}
// 获取导弹发射参数(Redis:房间+航线+平台为 key)
export function getMissileParams(params) {
return request({

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

@ -58,12 +58,6 @@ export const constantRoutes = [
component: () => import('@/views/childRoom'),
hidden: true
},
// 六步法编辑器
{
path: '/stepEditor',
component: () => import('@/views/childRoom/StepEditor'),
hidden: true
},
{
path: '/cesiumMap',
component: () => import('@/views/cesiumMap'),

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

File diff suppressed because it is too large

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

@ -1,6 +1,11 @@
<template>
<div>
<bottom-timeline ref="timeline" @timeline-hidden="onTimelineHidden" :room-id="roomId" />
<!-- 时间轴与六步法同时显示时在最下方 -->
<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 ? '收起' : '展开'">
@ -9,47 +14,43 @@
</div>
<div class="panel-content" :class="{ expanded: isExpanded }">
<div class="panel-item" @click="showTimeline">
<div class="panel-item" @click="toggleTimeline" :class="{ active: showTimelineBar }">
<i class="el-icon-time"></i>
<span>时间轴</span>
</div>
<div class="panel-item" @click="showSixSteps">
<div class="panel-item" @click="toggleSixSteps" :class="{ active: showSixStepsBar }">
<i class="el-icon-s-operation"></i>
<span>六步法</span>
</div>
</div>
</div>
<div class="sixsteps-bar" v-if="showSixStepsBar">
<div class="close-btn" @click="hideSixStepsBar">
<i class="el-icon-close"></i>
</div>
<div class="steps-bar-container">
<div
v-for="(step, index) in sixStepsData"
:key="index"
class="step-bar-item"
:class="{ active: step.active, completed: step.completed }"
@click="selectStep(index)"
>
<div class="step-bar-number">{{ index + 1 }}</div>
<div class="step-bar-content">
<div class="step-bar-title">{{ step.title }}</div>
<div class="step-bar-desc">{{ step.desc }}</div>
</div>
</div>
</div>
</div>
<!-- 六步法弹窗覆盖地图右侧栏为任务+1-6点击可切换 -->
<six-steps-overlay
v-if="showSixStepsBar"
:visible="showSixStepsOverlay"
:room-id="roomId"
:current-step-index="activeStepIndex"
:step-titles="sixStepsData.map(s => s.title)"
:override-title="taskBlockActive ? '任务' : null"
:six-steps-data="sixStepsData"
:task-block-active="taskBlockActive"
@close="hideSixStepsBar"
@select-task="selectTask"
@select-step="selectStep"
/>
</div>
</template>
<script>
import BottomTimeline from './BottomTimeline.vue'
import SixStepsOverlay from './SixStepsOverlay.vue'
export default {
name: 'BottomLeftPanel',
components: {
BottomTimeline
BottomTimeline,
SixStepsOverlay
},
props: {
roomId: {
@ -57,11 +58,24 @@ export default {
default: null
}
},
computed: {
showPanel() {
return !this.showTimelineBar && !this.showSixStepsBar
}
},
watch: {
showSixStepsOverlay(val) {
this.$emit('six-steps-overlay-visible', val)
}
},
data() {
return {
isExpanded: false,
showPanel: true,
showTimelineBar: false,
showSixStepsBar: false,
showSixStepsOverlay: false,
activeStepIndex: 0,
taskBlockActive: false,
sixStepsData: [
{ title: '理解', desc: '明确任务目标和要求', active: false, completed: false },
{ title: '判断', desc: '评估可用资源和能力', active: false, completed: false },
@ -76,32 +90,65 @@ export default {
togglePanel() {
this.isExpanded = !this.isExpanded
},
showTimeline() {
toggleTimeline() {
if (this.$refs.timeline) {
this.$refs.timeline.isVisible = true
this.showTimelineBar = !this.showTimelineBar
this.$refs.timeline.isVisible = this.showTimelineBar
//
if (!this.showTimelineBar) {
this.closeBoth()
}
this.isExpanded = false
this.showPanel = false
this.$emit('bottom-panel-visible', true)
this.updateBottomPanelVisible()
}
},
onTimelineHidden() {
this.showPanel = true
this.$emit('bottom-panel-visible', false)
},
showSixSteps() {
this.showSixStepsBar = true
toggleSixSteps() {
this.showSixStepsBar = !this.showSixStepsBar
if (this.showSixStepsBar) {
this.showSixStepsOverlay = true
this.taskBlockActive = true
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
this.activeStepIndex = 0
//
this.showTimelineBar = true
if (this.$refs.timeline) {
this.$refs.timeline.isVisible = true
}
} else {
this.closeBoth()
}
this.isExpanded = false
this.showPanel = false
this.$emit('bottom-panel-visible', true)
this.updateBottomPanelVisible()
},
updateBottomPanelVisible() {
this.$emit('bottom-panel-visible', this.showTimelineBar || this.showSixStepsBar)
},
onTimelineHidden() {
this.closeBoth()
},
hideSixStepsBar() {
this.closeBoth()
},
closeBoth() {
this.showTimelineBar = false
this.showSixStepsBar = false
this.showPanel = true
this.$emit('bottom-panel-visible', false)
this.showSixStepsOverlay = false
this.taskBlockActive = false
if (this.$refs.timeline) {
this.$refs.timeline.isVisible = false
}
this.updateBottomPanelVisible()
},
selectTask() {
this.taskBlockActive = true
this.sixStepsData.forEach(s => { s.active = false; s.completed = false })
this.activeStepIndex = 0
this.showSixStepsOverlay = true
},
selectStep(index) {
this.taskBlockActive = false
const clickedStep = this.sixStepsData[index]
if (clickedStep.completed && !clickedStep.active) {
this.sixStepsData.forEach((step, i) => {
if (i >= index) {
@ -109,16 +156,16 @@ export default {
}
})
}
this.sixStepsData.forEach((step, i) => {
step.active = i === index
if (i < index) {
step.completed = true
}
})
const url = `/stepEditor.html?title=${encodeURIComponent(this.sixStepsData[index].title)}&index=${index}`
window.open(url, '_blank')
this.activeStepIndex = index
this.showSixStepsOverlay = true
}
}
}
@ -196,6 +243,11 @@ export default {
transform: translateX(5px);
}
.panel-item.active {
background: rgba(0, 138, 255, 0.15);
color: #008aff;
}
.panel-item i {
font-size: 18px;
margin-right: 10px;
@ -207,153 +259,4 @@ export default {
font-weight: 500;
}
.sixsteps-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
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: 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);
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.close-btn {
position: absolute;
top: 50%;
right: 20px;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: #94a3b8;
font-size: 18px;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
}
.close-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
transform: translateY(-50%) rotate(90deg);
}
.steps-bar-container {
display: flex;
gap: 12px;
height: 62px;
align-items: center;
}
.step-bar-item {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 0 16px;
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(226, 232, 240, 0.8);
min-width: 0;
position: relative;
overflow: hidden;
}
.step-bar-item:hover {
background: rgba(255, 255, 255, 0.9);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
border-color: rgba(148, 163, 184, 0.4);
}
.step-bar-item.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 100%);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.15);
}
.step-bar-item.completed {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(34, 197, 94, 0.01) 100%);
border-color: rgba(34, 197, 94, 0.25);
}
.step-bar-number {
width: 24px;
height: 24px;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
font-weight: 700;
color: #64748b;
margin-right: 10px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.step-bar-item.active .step-bar-number {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.step-bar-item.completed .step-bar-number {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.25);
}
.step-bar-content {
text-align: left;
width: 100%;
overflow: hidden;
}
.step-bar-title {
font-size: 12px;
font-weight: 600;
color: #1e293b;
margin-bottom: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.3px;
}
.step-bar-item.active .step-bar-title {
color: #1e40af;
}
.step-bar-desc {
font-size: 10px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.2px;
}
</style>

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

@ -0,0 +1,439 @@
<template>
<div
v-show="visible"
class="sixsteps-overlay"
:class="{ 'is-draggable': draggable }"
>
<div class="overlay-main">
<div class="overlay-header" ref="headerRef">
<div class="header-left">
<span class="overlay-title">{{ currentStepTitle }}</span>
<!-- 理解步骤子标题点名接收解析任务XXXXXXXX放在理解和插入之间 -->
<template v-if="currentStepIndex === 0 && !overrideTitle">
<div
v-for="(sub, idx) in understandingSubTitles"
:key="idx"
class="header-sub-title"
:class="{ active: activeUnderstandingSubIndex === idx }"
@click="activeUnderstandingSubIndex = idx"
>
{{ sub }}
</div>
<el-dropdown trigger="click" @command="handleUnderstandingInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
<el-dropdown-item command="textbox">
<i class="el-icon-edit-outline"></i> 文本框
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<!-- 任务页插入按钮 -->
<el-dropdown v-else-if="overrideTitle === '任务'" trigger="click" @command="handleInsertCommand" class="header-insert">
<el-button size="small" type="primary" plain>
<i class="el-icon-plus"></i> 插入
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="background">
<i class="el-icon-picture-outline"></i> 背景
</el-dropdown-item>
<el-dropdown-item command="icon">
<i class="el-icon-s-opportunity"></i> 图标
</el-dropdown-item>
<el-dropdown-item command="textbox">
<i class="el-icon-edit-outline"></i> 文本框
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<div class="overlay-body" :style="overlayBodyStyle">
<div class="overlay-content" :class="{ 'task-page': overrideTitle === '任务', 'understanding-page': currentStepIndex === 0 && !overrideTitle }">
<!-- 任务页插入工具栏 + 可编辑画布 -->
<task-page-content
v-if="overrideTitle === '任务'"
ref="taskPage"
:room-id="roomId"
:background-image="taskPageBackground"
@background-change="taskPageBackground = $event"
class="task-page-body"
/>
<!-- 理解步骤4 子标题 + 可编辑画布 -->
<understanding-step-content
v-else-if="currentStepIndex === 0"
ref="understandingStep"
:background-image="sixStepsSharedBackground"
:active-sub-index="activeUnderstandingSubIndex"
@background-change="sixStepsSharedBackground = $event"
class="understanding-page-body"
/>
<!-- 判断规划准备执行评估使用共享背景 -->
<div v-else class="blank-placeholder">
<i class="el-icon-document"></i>
<p>{{ currentStepTitle }} - 内容区域</p>
<p class="hint">此处为空白页后续可添加具体功能</p>
</div>
</div>
</div>
</div>
<!-- 右侧栏任务 + 1-6 垂直排列 -->
<div class="overlay-sidebar">
<div class="sidebar-steps">
<div
class="sidebar-task"
:class="{ active: taskBlockActive }"
@click="$emit('select-task')"
>
<span>任务</span>
</div>
<div
v-for="(step, index) in sixStepsData"
:key="index"
class="sidebar-step"
:class="{ active: step.active, completed: step.completed }"
@click="$emit('select-step', index)"
>
<div class="sidebar-step-num">{{ index + 1 }}</div>
<span class="sidebar-step-title">{{ step.title }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import TaskPageContent from './TaskPageContent.vue'
import UnderstandingStepContent from './UnderstandingStepContent.vue'
export default {
name: 'SixStepsOverlay',
components: { TaskPageContent, UnderstandingStepContent },
props: {
roomId: {
type: [Number, String],
default: null
},
visible: {
type: Boolean,
default: false
},
currentStepIndex: {
type: Number,
default: 0
},
stepTitles: {
type: Array,
default: () => ['理解', '判断', '规划', '准备', '执行', '评估']
},
/** 覆盖标题,如「任务」独立步骤 */
overrideTitle: {
type: String,
default: null
},
sixStepsData: {
type: Array,
default: () => []
},
taskBlockActive: {
type: Boolean,
default: false
},
draggable: {
type: Boolean,
default: true
}
},
data() {
return {
taskPageBackground: null,
sixStepsSharedBackground: null,
understandingSubTitles: ['点名', '接收解析任务', 'XXXX', 'XXXX'],
activeUnderstandingSubIndex: 0
}
},
computed: {
currentStepTitle() {
if (this.overrideTitle) return this.overrideTitle
return this.stepTitles[this.currentStepIndex] || '六步法'
},
overlayBodyStyle() {
if (this.overrideTitle === '任务') return {}
if (this.currentStepIndex >= 1 && this.sixStepsSharedBackground) {
return {
backgroundImage: `url(${this.sixStepsSharedBackground})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}
}
return {}
}
},
methods: {
close() {
this.$emit('close')
},
handleInsertCommand(cmd) {
if (this.$refs.taskPage) {
this.$refs.taskPage.handleInsertCommand(cmd)
}
},
handleUnderstandingInsertCommand(cmd) {
if (this.$refs.understandingStep) {
this.$refs.understandingStep.handleInsertCommand(cmd)
}
}
}
}
</script>
<style scoped>
/* 悬浮窗:左侧顶到屏幕最左侧,右侧顶到屏幕最右侧,下边框与时间轴上边框对齐 */
.sixsteps-overlay {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 71px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.06);
z-index: 999;
display: flex;
flex-direction: row;
overflow: hidden;
animation: overlayFadeIn 0.3s ease;
}
.overlay-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.overlay-sidebar {
width: 100px;
flex-shrink: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.98) 100%);
border-left: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 12px 8px;
}
.sidebar-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-task {
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #1e293b;
text-align: center;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(226, 232, 240, 0.8);
transition: all 0.3s;
}
.sidebar-task:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(148, 163, 184, 0.4);
}
.sidebar-task.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 100%);
border-color: rgba(59, 130, 246, 0.3);
color: #1e40af;
}
.sidebar-step {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(226, 232, 240, 0.8);
transition: all 0.3s;
}
.sidebar-step:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(148, 163, 184, 0.4);
}
.sidebar-step.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 100%);
border-color: rgba(59, 130, 246, 0.3);
}
.sidebar-step.completed {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(34, 197, 94, 0.01) 100%);
border-color: rgba(34, 197, 94, 0.25);
}
.sidebar-step-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #64748b;
flex-shrink: 0;
}
.sidebar-step.active .sidebar-step-num {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
.sidebar-step.completed .sidebar-step-num {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
}
.sidebar-step-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-step.active .sidebar-step-title {
color: #1e40af;
}
@keyframes overlayFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(135deg, rgba(0, 138, 255, 0.08) 0%, rgba(0, 138, 255, 0.02) 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
cursor: default;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-insert {
cursor: pointer;
}
.header-sub-title {
padding: 6px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #64748b;
transition: all 0.2s;
}
.header-sub-title:hover {
background: rgba(0, 138, 255, 0.08);
color: #008aff;
}
.header-sub-title.active {
background: rgba(0, 138, 255, 0.12);
color: #008aff;
font-weight: 600;
}
.overlay-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.overlay-body {
flex: 1;
overflow: auto;
min-height: 200px;
}
.overlay-content {
padding: 24px;
min-height: 180px;
}
.overlay-content.task-page {
padding: 0;
height: 100%;
}
.overlay-content.understanding-page {
padding: 0;
height: 100%;
}
.task-page-body,
.understanding-page-body {
height: 100%;
}
.blank-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 160px;
color: #94a3b8;
text-align: center;
}
.blank-placeholder i {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.blank-placeholder p {
margin: 4px 0;
font-size: 14px;
}
.blank-placeholder .hint {
font-size: 12px;
color: #cbd5e1;
}
</style>

599
ruoyi-ui/src/views/childRoom/StepEditor.vue

@ -1,599 +0,0 @@
<template>
<div class="step-editor-container">
<div class="editor-header">
<div class="header-left">
<el-button type="text" icon="el-icon-back" @click="goBack" class="back-btn">
返回
</el-button>
<span class="step-title">{{ stepTitle }}</span>
</div>
<div class="header-right">
<el-button type="primary" @click="saveContent" class="save-btn">
<i class="el-icon-check"></i>
保存
</el-button>
</div>
</div>
<div class="editor-toolbar">
<div class="toolbar-group">
<el-tooltip content="撤销" placement="top">
<el-button type="text" @click="formatText('undo')" class="toolbar-btn">
<i class="el-icon-refresh-left"></i>
</el-button>
</el-tooltip>
<el-tooltip content="重做" placement="top">
<el-button type="text" @click="formatText('redo')" class="toolbar-btn">
<i class="el-icon-refresh-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="导入文档" placement="top">
<el-button type="text" @click="importDocument" class="toolbar-btn">
<i class="el-icon-document"></i>
</el-button>
</el-tooltip>
<el-tooltip content="导入图片" placement="top">
<el-button type="text" @click="importImage" class="toolbar-btn">
<i class="el-icon-picture"></i>
</el-button>
</el-tooltip>
<el-tooltip content="插入链接" placement="top">
<el-button type="text" @click="insertLink" class="toolbar-btn">
<i class="el-icon-link"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="加粗" placement="top">
<el-button type="text" @click="formatText('bold')" class="toolbar-btn">
<span class="format-icon">B</span>
</el-button>
</el-tooltip>
<el-tooltip content="斜体" placement="top">
<el-button type="text" @click="formatText('italic')" class="toolbar-btn">
<span class="format-icon italic">I</span>
</el-button>
</el-tooltip>
<el-tooltip content="下划线" placement="top">
<el-button type="text" @click="formatText('underline')" class="toolbar-btn">
<span class="format-icon underline">U</span>
</el-button>
</el-tooltip>
<el-tooltip content="删除线" placement="top">
<el-button type="text" @click="formatText('strikeThrough')" class="toolbar-btn">
<span class="format-icon strike">S</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="上标" placement="top">
<el-button type="text" @click="formatText('superscript')" class="toolbar-btn">
<span class="format-icon">x²</span>
</el-button>
</el-tooltip>
<el-tooltip content="下标" placement="top">
<el-button type="text" @click="formatText('subscript')" class="toolbar-btn">
<span class="format-icon">x</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="左对齐" placement="top">
<el-button type="text" @click="formatText('justifyLeft')" class="toolbar-btn">
<i class="el-icon-s-unfold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="居中对齐" placement="top">
<el-button type="text" @click="formatText('justifyCenter')" class="toolbar-btn">
<i class="el-icon-s-grid"></i>
</el-button>
</el-tooltip>
<el-tooltip content="右对齐" placement="top">
<el-button type="text" @click="formatText('justifyRight')" class="toolbar-btn">
<i class="el-icon-s-fold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="两端对齐" placement="top">
<el-button type="text" @click="formatText('justifyFull')" class="toolbar-btn">
<i class="el-icon-menu"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="无序列表" placement="top">
<el-button type="text" @click="formatText('insertUnorderedList')" class="toolbar-btn">
<i class="el-icon-plus"></i>
</el-button>
</el-tooltip>
<el-tooltip content="有序列表" placement="top">
<el-button type="text" @click="formatText('insertOrderedList')" class="toolbar-btn">
<i class="el-icon-sort"></i>
</el-button>
</el-tooltip>
<el-tooltip content="减少缩进" placement="top">
<el-button type="text" @click="formatText('outdent')" class="toolbar-btn">
<i class="el-icon-back"></i>
</el-button>
</el-tooltip>
<el-tooltip content="增加缩进" placement="top">
<el-button type="text" @click="formatText('indent')" class="toolbar-btn">
<i class="el-icon-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="全屏" placement="top">
<el-button type="text" @click="toggleFullscreen" class="toolbar-btn">
<i :class="isFullscreen ? 'el-icon-crop' : 'el-icon-full-screen'"></i>
</el-button>
</el-tooltip>
<div class="word-count">
{{ wordCount }}
</div>
</div>
</div>
<div class="editor-content">
<div
ref="editor"
class="editor-area"
contenteditable="true"
:data-placeholder="placeholder"
@input="onInput"
@keydown="onKeydown"
@focus="onFocus"
@blur="onBlur"
>
</div>
</div>
<input
ref="fileInput"
type="file"
style="display: none"
@change="handleFileChange"
/>
</div>
</template>
<script>
export default {
name: 'StepEditor',
data() {
return {
stepTitle: '',
stepIndex: 0,
content: '',
placeholder: '在此输入内容...',
uploadType: '',
savedContent: {},
wordCount: 0,
isFullscreen: false
}
},
created() {
this.stepTitle = this.$route.query.title || '编辑器'
this.stepIndex = parseInt(this.$route.query.index) || 0
this.loadSavedContent()
},
methods: {
goBack() {
this.$router.push('/childRoom')
},
formatText(command) {
document.execCommand(command, false, null)
this.$refs.editor.focus()
},
importDocument() {
this.uploadType = 'document'
this.$refs.fileInput.accept = '.txt,.md,.docx'
this.$refs.fileInput.click()
},
importImage() {
this.uploadType = 'image'
this.$refs.fileInput.accept = 'image/*'
this.$refs.fileInput.click()
},
handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
if (this.uploadType === 'document') {
this.handleDocumentUpload(file)
} else if (this.uploadType === 'image') {
this.handleImageUpload(file)
}
event.target.value = ''
},
handleDocumentUpload(file) {
const fileName = file.name.toLowerCase()
const fileExt = fileName.substring(fileName.lastIndexOf('.'))
if (fileExt === '.docx') {
this.handleDocxUpload(file)
} else if (fileExt === '.txt' || fileExt === '.md') {
this.handleTextFileUpload(file)
} else {
this.$message.warning('不支持的文件格式,请使用 .txt、.md 或 .docx 文件')
}
},
handleDocxUpload(file) {
this.$message.warning('正在解析 Word 文档...')
const reader = new FileReader()
reader.onload = (e) => {
try {
const arrayBuffer = e.target.result
this.parseDocx(arrayBuffer)
} catch (error) {
console.error('解析 Word 文档失败:', error)
this.$message.error('解析 Word 文档失败,请将文档另存为 .txt 或 .md 格式后重试')
}
}
reader.readAsArrayBuffer(file)
},
async parseDocx(arrayBuffer) {
try {
const mammoth = await import('mammoth')
const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer })
const text = result.value
this.insertText(text)
this.$message.success('Word 文档导入成功')
} catch (error) {
console.error('mammoth.js 加载失败:', error)
this.$message.error('需要安装 mammoth.js 库才能解析 Word 文档,请运行: npm install mammoth')
}
},
handleTextFileUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
let text = e.target.result
if (!text || text.trim() === '') {
this.$message.warning('文档内容为空')
return
}
if (this.isGarbled(text)) {
this.tryGBKEncoding(file)
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'UTF-8')
},
isGarbled(text) {
const garbageChars = text.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g)
return garbageChars && garbageChars.length > text.length * 0.3
},
tryGBKEncoding(file) {
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target.result
if (this.isGarbled(text)) {
this.$message.error('文档编码无法识别,请确保文档使用 UTF-8 或 GBK 编码')
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'GBK')
},
handleImageUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
const img = `<img src="${e.target.result}" style="max-width: 100%; height: auto; margin: 10px 0;" />`
this.insertHTML(img)
this.$message.success('图片导入成功')
}
reader.readAsDataURL(file)
},
insertLink() {
this.$prompt('请输入链接地址', '插入链接', '', {
confirmButtonText: '确定',
cancelButtonText: '取消'
}).then(({ value }) => {
if (value) {
const link = `<a href="${value}" target="_blank" style="color: #409EFF; text-decoration: underline;">${value}</a>`
this.insertHTML(link)
}
})
},
insertText(text) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const textNode = document.createTextNode(text)
range.insertNode(textNode)
} else {
editor.innerHTML += `<p>${text}</p>`
}
},
insertHTML(html) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const div = document.createElement('div')
div.innerHTML = html
range.insertNode(div)
} else {
editor.innerHTML += html
}
},
onInput() {
this.content = this.$refs.editor.innerHTML
this.updateWordCount()
},
updateWordCount() {
const text = this.$refs.editor.innerText || ''
this.wordCount = text.replace(/\s+/g, '').length
},
toggleFullscreen() {
this.isFullscreen = !this.isFullscreen
const container = document.querySelector('.step-editor-container')
if (this.isFullscreen) {
if (container.requestFullscreen) {
container.requestFullscreen()
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen()
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen()
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
}
},
onFocus() {
const editor = this.$refs.editor
if (editor.innerText.trim() === '' || editor.innerText === this.placeholder) {
editor.innerHTML = ''
}
},
onBlur() {
const editor = this.$refs.editor
if (editor.innerHTML.trim() === '' || editor.innerText.trim() === '') {
editor.innerHTML = ''
}
},
onKeydown(event) {
if (event.ctrlKey || event.metaKey) {
if (event.key === 's') {
event.preventDefault()
this.saveContent()
}
}
},
saveContent() {
this.content = this.$refs.editor.innerHTML
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
savedData[`step_${this.stepIndex}`] = {
title: this.stepTitle,
content: this.content,
updateTime: new Date().toISOString()
}
localStorage.setItem('stepEditorContent', JSON.stringify(savedData))
this.$message.success('保存成功')
},
loadSavedContent() {
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
const stepData = savedData[`step_${this.stepIndex}`]
if (stepData && stepData.content) {
this.$nextTick(() => {
this.$refs.editor.innerHTML = stepData.content
this.content = stepData.content
})
}
}
}
}
</script>
<style scoped>
.step-editor-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fafafa;
display: flex;
flex-direction: column;
z-index: 2000;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
height: 56px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
padding: 8px 12px;
font-size: 14px;
color: #666;
}
.back-btn:hover {
color: #409EFF;
}
.step-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.save-btn {
padding: 8px 24px;
font-size: 14px;
border-radius: 4px;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 0;
padding: 0 32px;
height: 44px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 4px;
color: #666;
font-size: 16px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #f5f5f5;
color: #409EFF;
}
.format-icon {
font-size: 16px;
font-weight: 700;
font-family: Arial, sans-serif;
font-style: normal;
}
.format-icon.italic {
font-style: italic;
}
.format-icon.underline {
text-decoration: underline;
}
.format-icon.strike {
text-decoration: line-through;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: #e8e8e8;
margin: 0 16px;
}
.word-count {
font-size: 12px;
color: #999;
padding: 0 12px;
min-width: 60px;
text-align: right;
}
.editor-content {
flex: 1;
overflow: hidden;
padding: 32px;
display: flex;
justify-content: center;
}
.editor-area {
width: 100%;
max-width: 840px;
height: 100%;
background: #fff;
padding: 48px 64px;
overflow-y: auto;
outline: none;
line-height: 1.8;
font-size: 15px;
color: #333;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.editor-area:focus {
outline: none;
}
.editor-area img {
max-width: 100%;
height: auto;
margin: 16px 0;
border-radius: 4px;
}
.editor-area p {
margin: 12px 0;
line-height: 1.8;
}
.editor-area ul,
.editor-area ol {
margin: 12px 0;
padding-left: 24px;
}
.editor-area li {
margin: 6px 0;
line-height: 1.8;
}
.editor-area blockquote {
border-left: 3px solid #409EFF;
padding: 12px 16px;
margin: 16px 0;
color: #666;
background: #f8f9fa;
border-radius: 0 4px 4px 0;
}
.editor-area:empty:before {
content: attr(data-placeholder);
color: #c0c4cc;
pointer-events: none;
}
</style>

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

@ -0,0 +1,858 @@
<template>
<div class="task-page-content">
<input
ref="bgInput"
type="file"
accept="image/*"
style="display: none"
@change="handleBackgroundSelect"
/>
<input
ref="iconImageInput"
type="file"
accept="image/*"
style="display: none"
@change="handleIconImageSelect"
/>
<!-- 可编辑画布区域 -->
<div
ref="canvas"
class="task-canvas"
:class="{ 'insert-icon': insertMode === 'icon', 'insert-textbox': insertMode === 'textbox' }"
:style="canvasStyle"
@click="onCanvasClick"
@mousedown="onCanvasMouseDown"
@mousemove="onCanvasMouseMove"
@mouseup="onCanvasMouseUp"
@mouseleave="onCanvasMouseUp"
>
<!-- 图标元素插入的图片 -->
<div
v-for="icon in icons"
:key="icon.id"
class="canvas-icon"
:class="{ selected: selectedId === icon.id }"
:style="getIconStyle(icon)"
@mousedown.stop="selectElement(icon.id, $event)"
>
<div class="icon-body" :style="{ transform: `rotate(${icon.rotation || 0}deg)` }">
<img v-if="icon.src" :src="icon.src" class="icon-image" />
<i v-else class="el-icon-picture-outline icon-placeholder"></i>
</div>
<div class="icon-resize-handle" v-if="selectedId === icon.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResize($event, icon, pos)"
></div>
<div class="rotate-handle" @mousedown.stop="startRotate($event, icon)"></div>
<div class="delete-handle" @mousedown.stop="deleteIcon(icon.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 文本框元素Office 风格全透明可随时拖拽和调整大小 -->
<div
v-for="box in textBoxes"
:key="box.id"
class="canvas-textbox"
:class="{ selected: selectedId === box.id }"
:style="getTextBoxStyle(box)"
@mousedown="onTextBoxMouseDown(box, $event)"
>
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div>
<!-- Office 风格格式工具栏选中时显示在文本框上方 -->
<div v-if="selectedId === box.id" class="textbox-format-toolbar" @mousedown.stop>
<el-select v-model="box.fontFamily" size="mini" placeholder="字体" class="format-font" @change="debouncedSave">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="box.fontSize" size="mini" placeholder="字号" class="format-size" @change="debouncedSave">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="box.color" class="format-color" @change="debouncedSave" />
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResizeTextBox($event, box, pos)"
></div>
<div class="textbox-rotate-handle" @mousedown.stop="startRotateTextBox($event, box)"></div>
<div class="delete-handle textbox-delete" @mousedown.stop="deleteTextBox(box.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 绘制中的文本框 -->
<div
v-if="drawingTextBox"
class="drawing-textbox"
:style="drawingTextBoxStyle"
></div>
</div>
</div>
</template>
<script>
import { getTaskPageData, saveTaskPageData } from '@/api/system/routes'
let idCounter = 0
function genId() {
return 'el_' + (++idCounter) + '_' + Date.now()
}
const FONT_OPTIONS = ['宋体', '黑体', '微软雅黑', '楷体', '仿宋', 'Arial', 'Times New Roman', 'Verdana', 'Georgia']
const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
const DEFAULT_FONT = { fontSize: 14, fontFamily: '微软雅黑', color: '#333333' }
export default {
name: 'TaskPageContent',
props: {
roomId: {
type: [Number, String],
default: null
},
backgroundImage: {
type: String,
default: null
}
},
data() {
return {
fontOptions: FONT_OPTIONS,
fontSizeOptions: FONT_SIZE_OPTIONS,
insertMode: null,
pendingIconImage: null,
icons: [],
textBoxes: [],
selectedId: null,
resizeHandles: ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'],
// //
dragState: null,
//
drawingTextBox: false,
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
}
},
watch: {
backgroundImage: { handler() { this.debouncedSave() }, immediate: false },
icons: { handler() { this.debouncedSave() }, deep: true },
textBoxes: { handler() { this.debouncedSave() }, deep: true }
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
document.addEventListener('keydown', this._keydownHandler)
this.loadFromRedis()
this.$nextTick(() => this.syncTextBoxContent())
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
document.removeEventListener('keydown', this._keydownHandler)
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
this.saveToRedis()
},
computed: {
canvasStyle() {
const style = {}
if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})`
style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
}
return style
},
drawingTextBoxStyle() {
const style = {}
const left = Math.min(this.drawStartX, this.drawCurrentX)
const top = Math.min(this.drawStartY, this.drawCurrentY)
const width = Math.abs(this.drawCurrentX - this.drawStartX)
const height = Math.abs(this.drawCurrentY - this.drawStartY)
style.left = left + 'px'
style.top = top + 'px'
style.width = Math.max(width, 20) + 'px'
style.height = Math.max(height, 20) + 'px'
return style
}
},
methods: {
handleInsertCommand(cmd) {
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
this.$refs.bgInput.click()
} else if (cmd === 'icon') {
this.$refs.iconImageInput.value = ''
this.$refs.iconImageInput.click()
}
},
handleBackgroundSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null
e.target.value = ''
},
handleIconImageSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.pendingIconImage = ev.target.result
this.insertMode = 'icon'
}
reader.readAsDataURL(file)
e.target.value = ''
},
onCanvasClick(e) {
if (this.insertMode === 'icon' && this.pendingIconImage) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
this.icons.push({
id: genId(),
x,
y,
width: 60,
height: 60,
rotation: 0,
src: this.pendingIconImage
})
this.pendingIconImage = null
this.insertMode = null
}
if (this.insertMode !== 'textbox' && !this.drawingTextBox) {
if (!e.target.closest('.canvas-textbox') && !e.target.closest('.canvas-icon')) {
this.selectedId = null
}
}
},
onCanvasMouseDown(e) {
if (this.insertMode === 'textbox' && !this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawingTextBox = true
this.drawStartX = e.clientX - rect.left
this.drawStartY = e.clientY - rect.top
this.drawCurrentX = this.drawStartX
this.drawCurrentY = this.drawStartY
}
},
onCanvasMouseMove(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawCurrentX = e.clientX - rect.left
this.drawCurrentY = e.clientY - rect.top
}
if (this.dragState) {
this.$refs.canvas.style.cursor = this.dragState.cursor || 'move'
}
},
onCanvasMouseUp(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = Math.min(this.drawStartX, this.drawCurrentX)
const y = Math.min(this.drawStartY, this.drawCurrentY)
const w = Math.max(Math.abs(this.drawCurrentX - this.drawStartX), 20)
const h = Math.max(Math.abs(this.drawCurrentY - this.drawStartY), 20)
this.textBoxes.push({
id: genId(),
x,
y,
width: w,
height: h,
text: '',
rotation: 0,
fontSize: DEFAULT_FONT.fontSize,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
this.drawingTextBox = false
this.insertMode = null
}
this.dragState = null
this.$refs.canvas && (this.$refs.canvas.style.cursor = '')
},
onTextBoxMouseDown(box, e) {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
selectElement(id, e) {
this.selectedId = id
const icon = this.icons.find(i => i.id === id)
const textbox = this.textBoxes.find(t => t.id === id)
const el = icon || textbox
if (!el) return
const rect = this.$refs.canvas.getBoundingClientRect()
const offsetX = e.clientX - rect.left - el.x
const offsetY = e.clientY - rect.top - el.y
this.dragState = {
type: 'drag',
id,
isIcon: !!icon,
startX: e.clientX,
startY: e.clientY,
origX: el.x,
origY: el.y,
offsetX,
offsetY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'drag') return
if (this.dragState.isIcon) return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.origX += dx
this.dragState.origY += dy
el.x = this.dragState.origX
el.y = this.dragState.origY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
}
const onUp = (ev) => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
if (this.dragState?.type === 'drag' && this.dragState.isIcon) {
const r = this.$refs.canvas.getBoundingClientRect()
el.x = ev.clientX - r.left - this.dragState.offsetX
el.y = ev.clientY - r.top - this.dragState.offsetY
}
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResize(e, icon, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: icon.id,
pos,
startX: e.clientX,
startY: e.clientY,
origW: icon.width,
origH: icon.height,
origX: icon.x,
origY: icon.y
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos, origW, origH, origX, origY } = this.dragState
let w = icon.width
let h = icon.height
let x = icon.x
let y = icon.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = icon.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = icon.y + dy }
icon.width = w
icon.height = h
icon.x = x
icon.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startRotate(e, icon) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = icon.x + icon.width / 2
const cy = icon.y + icon.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = icon.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
icon.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResizeTextBox(e, box, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: box.id,
pos,
startX: e.clientX,
startY: e.clientY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos } = this.dragState
let w = box.width
let h = box.height
let x = box.x
let y = box.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = box.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = box.y + dy }
box.width = w
box.height = h
box.x = x
box.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
getIconStyle(icon) {
return {
left: icon.x + 'px',
top: icon.y + 'px',
width: icon.width + 'px',
height: icon.height + 'px'
}
},
getTextBoxStyle(box) {
return {
left: box.x + 'px',
top: box.y + 'px',
width: box.width + 'px',
height: box.height + 'px',
transform: `rotate(${box.rotation || 0}deg)`
}
},
getTextBoxInputStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: box.color || DEFAULT_FONT.color
}
},
deleteIcon(id) {
const idx = this.icons.findIndex(i => i.id === id)
if (idx >= 0) this.icons.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
syncTextBoxContent() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
this.textBoxes.forEach((box, idx) => {
const el = inputs[idx]
if (el && document.activeElement !== el && el.innerText !== box.text) {
el.innerText = box.text
}
})
},
async loadFromRedis() {
if (this.roomId == null) return
try {
const res = await getTaskPageData({ roomId: this.roomId })
let data = res && res.data
if (!data) return
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (_) {
return
}
}
const raw = data
if (raw.icons && Array.isArray(raw.icons)) {
this.icons = raw.icons.map(i => ({
id: i.id || genId(),
x: Number(i.x) || 0,
y: Number(i.y) || 0,
width: Number(i.width) || 60,
height: Number(i.height) || 60,
rotation: Number(i.rotation) || 0,
src: i.src || ''
}))
}
if (raw.textBoxes && Array.isArray(raw.textBoxes)) {
this.textBoxes = raw.textBoxes.map(t => ({
id: t.id || genId(),
x: Number(t.x) || 0,
y: Number(t.y) || 0,
width: Number(t.width) || 100,
height: Number(t.height) || 60,
text: String(t.text || ''),
rotation: Number(t.rotation) || 0,
fontSize: Number(t.fontSize) || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}))
}
if (raw.background) {
this.$emit('background-change', raw.background)
}
} catch (e) {
console.warn('TaskPage loadFromRedis failed:', e)
}
},
saveToRedis() {
if (this.roomId == null) return
const payload = {
background: this.backgroundImage || null,
icons: this.icons.map(i => ({
id: i.id,
x: i.x,
y: i.y,
width: i.width,
height: i.height,
rotation: i.rotation || 0,
src: i.src
})),
textBoxes: this.textBoxes.map(t => ({
id: t.id,
x: t.x,
y: t.y,
width: t.width,
height: t.height,
text: t.text || '',
rotation: t.rotation || 0,
fontSize: t.fontSize || DEFAULT_FONT.fontSize,
fontFamily: t.fontFamily || DEFAULT_FONT.fontFamily,
color: t.color || DEFAULT_FONT.color
}))
}
saveTaskPageData({
roomId: this.roomId,
data: JSON.stringify(payload)
}).catch(e => {
console.warn('TaskPage saveToRedis failed:', e)
})
},
debouncedSave() {
if (this._saveTimer) clearTimeout(this._saveTimer)
this._saveTimer = setTimeout(() => {
this._saveTimer = null
this.saveToRedis()
}, 300)
},
deleteTextBox(id) {
const idx = this.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) this.textBoxes.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
onKeydown(e) {
if (!this.selectedId) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.contentEditable === 'true') return
e.preventDefault()
const icon = this.icons.find(i => i.id === this.selectedId)
const box = this.textBoxes.find(t => t.id === this.selectedId)
if (icon) this.deleteIcon(icon.id)
else if (box) this.deleteTextBox(box.id)
}
},
startRotateTextBox(e, box) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = box.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
box.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
}
}
</script>
<style scoped>
.task-page-content {
display: flex;
flex-direction: column;
height: 100%;
}
.task-canvas {
flex: 1;
position: relative;
min-height: 200px;
background: #fafafa;
}
.task-canvas.insert-icon {
cursor: crosshair;
}
.task-canvas.insert-textbox {
cursor: crosshair;
}
.canvas-icon {
position: absolute;
cursor: default;
border: 2px solid transparent;
}
.canvas-icon.selected {
border-color: #008aff;
z-index: 10;
}
.icon-body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border-radius: 0;
overflow: hidden;
}
.icon-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.icon-placeholder {
font-size: 24px;
color: #008aff;
}
.delete-handle {
position: absolute;
top: -28px;
right: -4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
pointer-events: auto;
color: #f56c6c;
font-size: 14px;
}
.delete-handle:hover {
background: #f56c6c;
color: #fff;
border-color: #f56c6c;
}
.textbox-delete {
top: -28px;
right: -4px;
}
.icon-resize-handle {
position: absolute;
left: -4px;
right: -4px;
top: -4px;
bottom: -4px;
pointer-events: none;
}
.icon-resize-handle .handle {
position: absolute;
width: 8px;
height: 8px;
background: #008aff;
border: 1px solid #fff;
border-radius: 2px;
pointer-events: auto;
}
.icon-resize-handle .handle.nw { left: -4px; top: -4px; cursor: nw-resize; }
.icon-resize-handle .handle.n { left: 50%; top: -4px; margin-left: -4px; cursor: n-resize; }
.icon-resize-handle .handle.ne { right: -4px; top: -4px; cursor: ne-resize; }
.icon-resize-handle .handle.e { right: -4px; top: 50%; margin-top: -4px; cursor: e-resize; }
.icon-resize-handle .handle.se { right: -4px; bottom: -4px; cursor: se-resize; }
.icon-resize-handle .handle.s { left: 50%; bottom: -4px; margin-left: -4px; cursor: s-resize; }
.icon-resize-handle .handle.sw { left: -4px; bottom: -4px; cursor: sw-resize; }
.icon-resize-handle .handle.w { left: -4px; top: 50%; margin-top: -4px; cursor: w-resize; }
.rotate-handle {
position: absolute;
top: -24px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #008aff;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
/* 文本框:默认显示细黑边框 */
.canvas-textbox {
position: absolute;
cursor: default;
border: 1px solid #333;
background: transparent;
border-radius: 0;
overflow: visible;
}
/* 编辑模式:虚线边框 + 空心圆控制点 + 旋转手柄 */
.canvas-textbox.selected {
border: 1px dashed #333;
z-index: 10;
}
.textbox-resize-handle {
position: absolute;
left: -6px;
right: -6px;
top: -6px;
bottom: -6px;
pointer-events: none;
}
.textbox-resize-handle .handle {
position: absolute;
width: 10px;
height: 10px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
pointer-events: auto;
box-sizing: border-box;
}
.textbox-resize-handle .handle.nw { left: -5px; top: -5px; cursor: nw-resize; }
.textbox-resize-handle .handle.n { left: 50%; top: -5px; margin-left: -5px; cursor: n-resize; }
.textbox-resize-handle .handle.ne { right: -5px; top: -5px; cursor: ne-resize; }
.textbox-resize-handle .handle.e { right: -5px; top: 50%; margin-top: -5px; cursor: e-resize; }
.textbox-resize-handle .handle.se { right: -5px; bottom: -5px; cursor: se-resize; }
.textbox-resize-handle .handle.s { left: 50%; bottom: -5px; margin-left: -5px; cursor: s-resize; }
.textbox-resize-handle .handle.sw { left: -5px; bottom: -5px; cursor: sw-resize; }
.textbox-resize-handle .handle.w { left: -5px; top: 50%; margin-top: -5px; cursor: w-resize; }
.textbox-rotate-handle {
position: absolute;
top: -28px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
/* Office 风格格式工具栏:选中文本框时显示在上方 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
}
.format-font {
width: 120px;
}
.format-size {
width: 70px;
}
.format-color {
vertical-align: middle;
}
.format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
}
.textbox-drag-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
cursor: default;
background: transparent;
}
.textbox-input {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
overflow: auto;
background: transparent;
color: #333;
}
.drawing-textbox {
position: absolute;
border: 2px dashed #008aff;
background: rgba(0, 138, 255, 0.05);
pointer-events: none;
}
</style>

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

@ -0,0 +1,767 @@
<template>
<div class="understanding-step-content">
<input
ref="bgInput"
type="file"
accept="image/*"
style="display: none"
@change="handleBackgroundSelect"
/>
<input
ref="iconImageInput"
type="file"
accept="image/*"
style="display: none"
@change="handleIconImageSelect"
/>
<!-- 可编辑画布区域子标题已移至 SixStepsOverlay 蓝色标题栏 -->
<div
ref="canvas"
class="task-canvas"
:class="{ 'insert-icon': insertMode === 'icon', 'insert-textbox': insertMode === 'textbox' }"
:style="canvasStyle"
@click="onCanvasClick"
@mousedown="onCanvasMouseDown"
@mousemove="onCanvasMouseMove"
@mouseup="onCanvasMouseUp"
@mouseleave="onCanvasMouseUp"
>
<!-- 图标元素 -->
<div
v-for="icon in currentIcons"
:key="icon.id"
class="canvas-icon"
:class="{ selected: selectedId === icon.id }"
:style="getIconStyle(icon)"
@mousedown.stop="selectElement(icon.id, $event)"
>
<div class="icon-body" :style="{ transform: `rotate(${icon.rotation || 0}deg)` }">
<img v-if="icon.src" :src="icon.src" class="icon-image" />
<i v-else class="el-icon-picture-outline icon-placeholder"></i>
</div>
<div class="icon-resize-handle" v-if="selectedId === icon.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResize($event, icon, pos)"
></div>
<div class="rotate-handle" @mousedown.stop="startRotate($event, icon)"></div>
<div class="delete-handle" @mousedown.stop="deleteIcon(icon.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 文本框元素Office 风格支持字体字号颜色 -->
<div
v-for="box in currentTextBoxes"
:key="box.id"
class="canvas-textbox"
:class="{ selected: selectedId === box.id }"
:style="getTextBoxStyle(box)"
@mousedown="onTextBoxMouseDown(box, $event)"
>
<div class="textbox-drag-bar" @mousedown.stop="selectElement(box.id, $event)"></div>
<!-- Office 风格格式工具栏 -->
<div v-if="selectedId === box.id" class="textbox-format-toolbar" @mousedown.stop>
<el-select v-model="box.fontFamily" size="mini" placeholder="字体" class="format-font">
<el-option v-for="f in fontOptions" :key="f" :label="f" :value="f" />
</el-select>
<el-select v-model="box.fontSize" size="mini" placeholder="字号" class="format-size">
<el-option v-for="s in fontSizeOptions" :key="s" :label="String(s)" :value="s" />
</el-select>
<el-color-picker v-model="box.color" class="format-color" />
</div>
<div
class="textbox-input"
contenteditable="true"
:style="getTextBoxInputStyle(box)"
@blur="box.text = $event.target.innerText"
@mousedown.stop="selectedId = box.id"
></div>
<div class="textbox-resize-handle" v-if="selectedId === box.id">
<div
v-for="pos in resizeHandles"
:key="pos"
class="handle"
:class="pos"
@mousedown.stop="startResizeTextBox($event, box, pos)"
></div>
<div class="textbox-rotate-handle" @mousedown.stop="startRotateTextBox($event, box)"></div>
<div class="delete-handle textbox-delete" @mousedown.stop="deleteTextBox(box.id)" title="删除">
<i class="el-icon-delete"></i>
</div>
</div>
</div>
<!-- 绘制中的文本框 -->
<div
v-if="drawingTextBox"
class="drawing-textbox"
:style="drawingTextBoxStyle"
></div>
</div>
</div>
</template>
<script>
let idCounter = 0
function genId() {
return 'el_' + (++idCounter) + '_' + Date.now()
}
const FONT_OPTIONS = ['宋体', '黑体', '微软雅黑', '楷体', '仿宋', 'Arial', 'Times New Roman', 'Verdana', 'Georgia']
const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
const DEFAULT_FONT = { fontSize: 14, fontFamily: '微软雅黑', color: '#333333' }
export default {
name: 'UnderstandingStepContent',
props: {
backgroundImage: {
type: String,
default: null
},
activeSubIndex: {
type: Number,
default: 0
}
},
data() {
return {
fontOptions: FONT_OPTIONS,
fontSizeOptions: FONT_SIZE_OPTIONS,
subContents: [
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] },
{ icons: [], textBoxes: [] }
],
insertMode: null,
pendingIconImage: null,
selectedId: null,
resizeHandles: ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'],
dragState: null,
drawingTextBox: false,
drawStartX: 0,
drawStartY: 0,
drawCurrentX: 0,
drawCurrentY: 0
}
},
computed: {
currentIcons() {
return this.subContents[this.activeSubIndex]?.icons || []
},
currentTextBoxes() {
return this.subContents[this.activeSubIndex]?.textBoxes || []
},
canvasStyle() {
const style = {}
if (this.backgroundImage) {
style.backgroundImage = `url(${this.backgroundImage})`
style.backgroundSize = '100% 100%'
style.backgroundPosition = 'center'
style.backgroundRepeat = 'no-repeat'
}
return style
},
drawingTextBoxStyle() {
const style = {}
const left = Math.min(this.drawStartX, this.drawCurrentX)
const top = Math.min(this.drawStartY, this.drawCurrentY)
const width = Math.abs(this.drawCurrentX - this.drawStartX)
const height = Math.abs(this.drawCurrentY - this.drawStartY)
style.left = left + 'px'
style.top = top + 'px'
style.width = Math.max(width, 20) + 'px'
style.height = Math.max(height, 20) + 'px'
return style
}
},
watch: {
activeSubIndex() {
this.selectedId = null
}
},
mounted() {
this._keydownHandler = (e) => this.onKeydown(e)
document.addEventListener('keydown', this._keydownHandler)
this.$nextTick(() => this.syncTextBoxContent())
},
updated() {
this.$nextTick(() => this.syncTextBoxContent())
},
beforeDestroy() {
document.removeEventListener('keydown', this._keydownHandler)
},
methods: {
handleInsertCommand(cmd) {
this.insertMode = cmd
if (cmd === 'background') {
this.$refs.bgInput.value = ''
this.$refs.bgInput.click()
} else if (cmd === 'icon') {
this.$refs.iconImageInput.value = ''
this.$refs.iconImageInput.click()
}
},
handleBackgroundSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.$emit('background-change', ev.target.result)
}
reader.readAsDataURL(file)
this.insertMode = null
e.target.value = ''
},
handleIconImageSelect(e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
this.pendingIconImage = ev.target.result
this.insertMode = 'icon'
}
reader.readAsDataURL(file)
e.target.value = ''
},
onCanvasClick(e) {
if (this.insertMode === 'icon' && this.pendingIconImage) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const sub = this.subContents[this.activeSubIndex]
sub.icons.push({
id: genId(),
x,
y,
width: 60,
height: 60,
rotation: 0,
src: this.pendingIconImage
})
this.pendingIconImage = null
this.insertMode = null
}
if (this.insertMode !== 'textbox' && !this.drawingTextBox) {
if (!e.target.closest('.canvas-textbox') && !e.target.closest('.canvas-icon')) {
this.selectedId = null
}
}
},
onCanvasMouseDown(e) {
if (this.insertMode === 'textbox' && !this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawingTextBox = true
this.drawStartX = e.clientX - rect.left
this.drawStartY = e.clientY - rect.top
this.drawCurrentX = this.drawStartX
this.drawCurrentY = this.drawStartY
}
},
onCanvasMouseMove(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
this.drawCurrentX = e.clientX - rect.left
this.drawCurrentY = e.clientY - rect.top
}
if (this.dragState) {
this.$refs.canvas.style.cursor = this.dragState.cursor || 'move'
}
},
onCanvasMouseUp(e) {
if (this.drawingTextBox) {
const rect = this.$refs.canvas.getBoundingClientRect()
const x = Math.min(this.drawStartX, this.drawCurrentX)
const y = Math.min(this.drawStartY, this.drawCurrentY)
const w = Math.max(Math.abs(this.drawCurrentX - this.drawStartX), 20)
const h = Math.max(Math.abs(this.drawCurrentY - this.drawStartY), 20)
const sub = this.subContents[this.activeSubIndex]
sub.textBoxes.push({
id: genId(),
x,
y,
width: w,
height: h,
text: '',
rotation: 0,
fontSize: DEFAULT_FONT.fontSize,
fontFamily: DEFAULT_FONT.fontFamily,
color: DEFAULT_FONT.color
})
this.drawingTextBox = false
this.insertMode = null
}
this.dragState = null
this.$refs.canvas && (this.$refs.canvas.style.cursor = '')
},
onTextBoxMouseDown(box, e) {
if (e.target.closest('.textbox-input')) return
this.selectElement(box.id, e)
},
selectElement(id, e) {
this.selectedId = id
const icon = this.currentIcons.find(i => i.id === id)
const textbox = this.currentTextBoxes.find(t => t.id === id)
const el = icon || textbox
if (!el) return
const rect = this.$refs.canvas.getBoundingClientRect()
const offsetX = e.clientX - rect.left - el.x
const offsetY = e.clientY - rect.top - el.y
this.dragState = {
type: 'drag',
id,
isIcon: !!icon,
startX: e.clientX,
startY: e.clientY,
origX: el.x,
origY: el.y,
offsetX,
offsetY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'drag') return
if (this.dragState.isIcon) return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.origX += dx
this.dragState.origY += dy
el.x = this.dragState.origX
el.y = this.dragState.origY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
}
const onUp = (ev) => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
if (this.dragState?.type === 'drag' && this.dragState.isIcon) {
const r = this.$refs.canvas.getBoundingClientRect()
el.x = ev.clientX - r.left - this.dragState.offsetX
el.y = ev.clientY - r.top - this.dragState.offsetY
}
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResize(e, icon, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: icon.id,
pos,
startX: e.clientX,
startY: e.clientY,
origW: icon.width,
origH: icon.height,
origX: icon.x,
origY: icon.y
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos } = this.dragState
let w = icon.width
let h = icon.height
let x = icon.x
let y = icon.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = icon.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = icon.y + dy }
icon.width = w
icon.height = h
icon.x = x
icon.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startRotate(e, icon) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = icon.x + icon.width / 2
const cy = icon.y + icon.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = icon.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
icon.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
startResizeTextBox(e, box, pos) {
e.stopPropagation()
this.dragState = {
type: 'resize',
id: box.id,
pos,
startX: e.clientX,
startY: e.clientY
}
const onMove = (ev) => {
if (this.dragState?.type !== 'resize') return
const dx = ev.clientX - this.dragState.startX
const dy = ev.clientY - this.dragState.startY
this.dragState.startX = ev.clientX
this.dragState.startY = ev.clientY
const { pos } = this.dragState
let w = box.width
let h = box.height
let x = box.x
let y = box.y
if (pos.includes('e')) { w = Math.max(20, w + dx) }
if (pos.includes('w')) { w = Math.max(20, w - dx); x = box.x + dx }
if (pos.includes('s')) { h = Math.max(20, h + dy) }
if (pos.includes('n')) { h = Math.max(20, h - dy); y = box.y + dy }
box.width = w
box.height = h
box.x = x
box.y = y
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
this.dragState = null
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
},
getIconStyle(icon) {
return {
left: icon.x + 'px',
top: icon.y + 'px',
width: icon.width + 'px',
height: icon.height + 'px'
}
},
getTextBoxStyle(box) {
return {
left: box.x + 'px',
top: box.y + 'px',
width: box.width + 'px',
height: box.height + 'px',
transform: `rotate(${box.rotation || 0}deg)`
}
},
getTextBoxInputStyle(box) {
return {
fontSize: (box.fontSize || DEFAULT_FONT.fontSize) + 'px',
fontFamily: box.fontFamily || DEFAULT_FONT.fontFamily,
color: box.color || DEFAULT_FONT.color
}
},
deleteIcon(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.icons.findIndex(i => i.id === id)
if (idx >= 0) sub.icons.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
syncTextBoxContent() {
if (!this.$refs.canvas) return
const inputs = this.$refs.canvas.querySelectorAll('.textbox-input')
const boxes = this.currentTextBoxes
boxes.forEach((box, idx) => {
const el = inputs[idx]
if (el && document.activeElement !== el && el.innerText !== box.text) {
el.innerText = box.text
}
})
},
deleteTextBox(id) {
const sub = this.subContents[this.activeSubIndex]
const idx = sub.textBoxes.findIndex(t => t.id === id)
if (idx >= 0) sub.textBoxes.splice(idx, 1)
if (this.selectedId === id) this.selectedId = null
},
onKeydown(e) {
if (!this.selectedId) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.contentEditable === 'true') return
e.preventDefault()
const icon = this.currentIcons.find(i => i.id === this.selectedId)
const box = this.currentTextBoxes.find(t => t.id === this.selectedId)
if (icon) this.deleteIcon(icon.id)
else if (box) this.deleteTextBox(box.id)
}
},
startRotateTextBox(e, box) {
e.stopPropagation()
const rect = this.$refs.canvas.getBoundingClientRect()
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
const startAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx)
const startRot = box.rotation || 0
const onMove = (ev) => {
const angle = Math.atan2(ev.clientY - rect.top - cy, ev.clientX - rect.left - cx)
const delta = ((angle - startAngle) * 180 / Math.PI)
box.rotation = (startRot + delta + 360) % 360
}
const onUp = () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
}
}
</script>
<style scoped>
.understanding-step-content {
display: flex;
flex-direction: column;
height: 100%;
}
.task-canvas {
flex: 1;
position: relative;
min-height: 200px;
background: #fafafa;
}
.task-canvas.insert-icon {
cursor: crosshair;
}
.task-canvas.insert-textbox {
cursor: crosshair;
}
.canvas-icon {
position: absolute;
cursor: default;
border: 2px solid transparent;
}
.canvas-icon.selected {
border-color: #008aff;
z-index: 10;
}
.icon-body {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border-radius: 0;
overflow: hidden;
}
.icon-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.icon-placeholder {
font-size: 24px;
color: #008aff;
}
.delete-handle {
position: absolute;
top: -28px;
right: -4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
pointer-events: auto;
color: #f56c6c;
font-size: 14px;
}
.delete-handle:hover {
background: #f56c6c;
color: #fff;
border-color: #f56c6c;
}
.textbox-delete {
top: -28px;
right: -4px;
}
.icon-resize-handle {
position: absolute;
left: -4px;
right: -4px;
top: -4px;
bottom: -4px;
pointer-events: none;
}
.icon-resize-handle .handle {
position: absolute;
width: 8px;
height: 8px;
background: #008aff;
border: 1px solid #fff;
border-radius: 2px;
pointer-events: auto;
}
.icon-resize-handle .handle.nw { left: -4px; top: -4px; cursor: nw-resize; }
.icon-resize-handle .handle.n { left: 50%; top: -4px; margin-left: -4px; cursor: n-resize; }
.icon-resize-handle .handle.ne { right: -4px; top: -4px; cursor: ne-resize; }
.icon-resize-handle .handle.e { right: -4px; top: 50%; margin-top: -4px; cursor: e-resize; }
.icon-resize-handle .handle.se { right: -4px; bottom: -4px; cursor: se-resize; }
.icon-resize-handle .handle.s { left: 50%; bottom: -4px; margin-left: -4px; cursor: s-resize; }
.icon-resize-handle .handle.sw { left: -4px; bottom: -4px; cursor: sw-resize; }
.icon-resize-handle .handle.w { left: -4px; top: 50%; margin-top: -4px; cursor: w-resize; }
.rotate-handle {
position: absolute;
top: -24px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #008aff;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
.canvas-textbox {
position: absolute;
cursor: default;
border: 1px solid #333;
background: transparent;
border-radius: 0;
overflow: visible;
}
.canvas-textbox.selected {
border: 1px dashed #333;
z-index: 10;
}
.textbox-resize-handle {
position: absolute;
left: -6px;
right: -6px;
top: -6px;
bottom: -6px;
pointer-events: none;
}
.textbox-resize-handle .handle {
position: absolute;
width: 10px;
height: 10px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
pointer-events: auto;
box-sizing: border-box;
}
.textbox-resize-handle .handle.nw { left: -5px; top: -5px; cursor: nw-resize; }
.textbox-resize-handle .handle.n { left: 50%; top: -5px; margin-left: -5px; cursor: n-resize; }
.textbox-resize-handle .handle.ne { right: -5px; top: -5px; cursor: ne-resize; }
.textbox-resize-handle .handle.e { right: -5px; top: 50%; margin-top: -5px; cursor: e-resize; }
.textbox-resize-handle .handle.se { right: -5px; bottom: -5px; cursor: se-resize; }
.textbox-resize-handle .handle.s { left: 50%; bottom: -5px; margin-left: -5px; cursor: s-resize; }
.textbox-resize-handle .handle.sw { left: -5px; bottom: -5px; cursor: sw-resize; }
.textbox-resize-handle .handle.w { left: -5px; top: 50%; margin-top: -5px; cursor: w-resize; }
.textbox-rotate-handle {
position: absolute;
top: -28px;
left: 50%;
margin-left: -6px;
width: 12px;
height: 12px;
background: #fff;
border: 1px solid #999;
border-radius: 50%;
cursor: grab;
pointer-events: auto;
}
/* Office 风格格式工具栏 */
.textbox-format-toolbar {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
}
.format-font { width: 120px; }
.format-size { width: 70px; }
.format-color { vertical-align: middle; }
.format-color ::v-deep .el-color-picker__trigger {
width: 24px;
height: 24px;
padding: 2px;
}
.textbox-drag-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
cursor: default;
background: transparent;
}
.textbox-input {
position: absolute;
top: 8px;
left: 0;
right: 0;
bottom: 0;
padding: 8px;
font-size: 14px;
outline: none;
overflow: auto;
background: transparent;
color: #333;
}
.drawing-textbox {
position: absolute;
border: 2px dashed #008aff;
background: rgba(0, 138, 255, 0.05);
pointer-events: none;
}
</style>

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

@ -15,6 +15,7 @@
:coordinateFormat="coordinateFormat"
:bottomPanelVisible="bottomPanelVisible"
:map-drag-enabled="mapDragEnabled"
:hide-map-info="sixStepsOverlayVisible"
:route-locked="routeLocked"
:route-locked-by-other-ids="routeLockedByOtherRouteIds"
:deduction-time-minutes="deductionMinutesFromK"
@ -210,7 +211,7 @@
@open-import-dialog="showImportDialog = true"
/>
<!-- 左下角工具面板 -->
<bottom-left-panel v-show="!screenshotMode" @bottom-panel-visible="handleBottomPanelVisible" :room-id="currentRoomId" />
<bottom-left-panel v-show="!screenshotMode" @bottom-panel-visible="handleBottomPanelVisible" @six-steps-overlay-visible="sixStepsOverlayVisible = $event" :room-id="currentRoomId" />
<!-- 底部时间轴最初版本的样式- 蓝色主题 -->
<div
v-show="!screenshotMode"
@ -226,7 +227,19 @@
<i class="el-icon-time"></i>
<span class="time-text">{{ currentTime }}</span>
</div>
<div class="timeline-slider">
<div
ref="timelineSliderWrap"
class="timeline-slider"
@mousemove="onTimelineHover"
@mouseleave="onTimelineLeave"
>
<div
v-show="timelineHoverVisible"
class="timeline-hover-time"
:style="{ left: timelineHoverPercent + '%' }"
>
{{ timelineHoverTime }}
</div>
<el-slider
v-model="timeProgress"
:max="100"
@ -242,25 +255,12 @@
>
<i :class="isPlaying ? 'el-icon-video-pause' : 'el-icon-video-play'"></i>
</button>
<div class="speed-control">
<button
class="control-btn blue-control-btn"
@click="decreaseSpeed"
:disabled="playbackSpeed <= 1"
title="减速"
>
<i class="el-icon-arrow-down"></i>
</button>
<span class="speed-text">{{ playbackSpeed }}x</span>
<button
class="control-btn blue-control-btn"
@click="increaseSpeed"
:disabled="playbackSpeed >= 25"
title="加速"
>
<i class="el-icon-arrow-up"></i>
</button>
</div>
<el-dropdown trigger="click" @command="setPlaybackSpeed" class="speed-control">
<span class="speed-text clickable">{{ playbackSpeed }}x<i class="el-icon-arrow-down el-icon--right"></i></span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="s in speedOptions" :key="s" :command="s">{{ s }}x</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body>
@ -276,14 +276,8 @@
<el-input-number v-model="addHoldForm.radius" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<template v-if="addHoldForm.holdType === 'hold_ellipse'">
<el-form-item label="长半轴(米)">
<el-input-number v-model="addHoldForm.semiMajor" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="短半轴(米)">
<el-input-number v-model="addHoldForm.semiMinor" :min="50" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="长轴方位(度)">
<el-input-number v-model="addHoldForm.headingDeg" :min="-180" :max="180" style="width:100%" />
<el-form-item label="跑道边长(km)">
<el-input-number v-model="addHoldForm.edgeLengthKm" :min="1" :max="200" :step="1" style="width:100%" />
</el-form-item>
</template>
<el-form-item label="盘旋方向">
@ -512,6 +506,8 @@ export default {
showImportDialog: false,
// /
bottomPanelVisible: false,
//
sixStepsOverlayVisible: false,
//
screenshotMode: false,
@ -650,12 +646,18 @@ export default {
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs
showAddHoldDialog: false,
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName }
addHoldForm: { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null },
addHoldForm: { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: null },
missionDrawingActive: false,
missionDrawingPointsCount: 0,
isPlaying: false,
playbackSpeed: 1,
speedOptions: [64, 32, 16, 8, 4, 2, 1],
playbackInterval: null,
/** 播放时上一帧时间戳(毫秒),用于按真实经过时间推进,避免 setInterval 不准导致时间轴变慢 */
_playbackLastTickTime: null,
timelineHoverTime: '',
timelineHoverVisible: false,
timelineHoverPercent: 0,
/** 导弹从 Redis 加载过的房间+航线组合 key,避免重复加载 */
_missilesLoadKey: null,
@ -1091,14 +1093,15 @@ export default {
let pointType;
let holdParams;
let turnAngle;
const preserveTurnAngle = () => isFirstOrLast ? 0 : (wp.turnAngle != null && wp.turnAngle !== '' ? Number(wp.turnAngle) : 0);
if (isHold) {
pointType = 'normal';
holdParams = null;
turnAngle = isFirstOrLast ? 0 : (Number(wp.turnAngle) || 45);
turnAngle = preserveTurnAngle();
} else {
pointType = 'hold_circle';
holdParams = JSON.stringify({ radius: 15000, clockwise: true });
turnAngle = 0;
turnAngle = preserveTurnAngle();
}
try {
const payload = {
@ -1145,6 +1148,10 @@ export default {
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
if (r.waypoints.some(wp => this.isHoldWaypoint(wp))) {
this.getPositionAtMinutesFromK(r.waypoints, minMinutes, minMinutes, maxMinutes, routeId);
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
@ -1235,6 +1242,10 @@ export default {
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 (wp.segmentMode != null) payload.segmentMode = wp.segmentMode;
if (wp.color != null) payload.color = wp.color;
if (wp.pixelSize != null) payload.pixelSize = wp.pixelSize;
if (wp.outlineColor != null) payload.outlineColor = wp.outlineColor;
try {
const response = await updateWaypoints(payload);
if (response.code === 200) {
@ -1245,7 +1256,96 @@ export default {
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId);
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged);
}
// /K
const routeForPlatform = this.routes.find(r => r.id === routeId) || route;
if (idx > 0) {
const prev = waypoints[idx - 1];
const distM = this.segmentDistance(
{ lat: prev.lat, lng: prev.lng, alt: prev.alt },
{ lat: merged.lat, lng: merged.lng, alt: merged.alt }
);
const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime);
if (prev.segmentMode === 'fixed_speed') {
const speedKmh = Number(prev.segmentTargetSpeed ?? prev.speed) || 800;
const newMinutesFromK = prevMinutes + (distM / 1000) / speedKmh * 60;
const newStartTime = this.minutesToStartTimeWithSeconds(newMinutesFromK);
const startPayload = { ...merged, startTime: newStartTime };
if (merged.segmentMode != null) startPayload.segmentMode = merged.segmentMode;
try {
const r2 = await updateWaypoints(startPayload);
if (r2.code === 200) {
Object.assign(merged, { startTime: newStartTime });
if (idx !== -1) waypoints.splice(idx, 1, merged);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId);
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged);
}
}
} catch (e) {
console.warn('定速重算相对K时失败', e);
}
} else if (merged.segmentMode === 'fixed_time') {
const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime);
const deltaMin = currMinutes - prevMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const speedPayload = { ...prev, speed: speedVal };
if (prev.segmentMode != null) speedPayload.segmentMode = prev.segmentMode;
try {
const r2 = await updateWaypoints(speedPayload);
if (r2.code === 200) {
Object.assign(prev, { speed: speedVal });
const prevIdx = idx - 1;
waypoints.splice(prevIdx, 1, prev);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === prev.id);
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, prev);
}
}
} catch (e) {
console.warn('定时重算上一航点速度失败', e);
}
}
}
}
// 使 K
if (idx >= 0 && idx < waypoints.length - 1) {
const next = waypoints[idx + 1];
if (next.segmentMode === 'fixed_time') {
const distToNextM = this.segmentDistance(
{ lat: merged.lat, lng: merged.lng, alt: merged.alt },
{ lat: next.lat, lng: next.lng, alt: next.alt }
);
const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime);
const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime);
const deltaMin = nextMinutes - currMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const currPayload = { ...merged, speed: speedVal };
if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode;
if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize;
if (merged.labelColor != null) currPayload.labelColor = merged.labelColor;
if (merged.color != null) currPayload.color = merged.color;
if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize;
if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor;
try {
const r2 = await updateWaypoints(currPayload);
if (r2.code === 200) {
Object.assign(merged, { speed: speedVal });
waypoints.splice(idx, 1, merged);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId);
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged);
}
}
} catch (e) {
console.warn('下一航点为定时点重算本航点速度失败', e);
}
}
}
}
if (this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && routeForPlatform.platformId) {
@ -1557,6 +1657,10 @@ export default {
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap && waypoints.length > 0) {
const r = this.routes.find(rr => rr.id === routeId);
if (r) {
if (waypoints.some(wp => this.isHoldWaypoint(wp))) {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, routeId);
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
@ -1688,7 +1792,7 @@ export default {
getDefaultRouteAttributes() {
const defaultAttrs = {
waypointStyle: { pixelSize: 10, color: '#f1f5f9', outlineColor: '#64748b', outlineWidth: 1.5 },
lineStyle: { style: 'solid', width: 3, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
lineStyle: { style: 'solid', width: 2, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
};
return JSON.stringify(defaultAttrs);
},
@ -1935,9 +2039,14 @@ export default {
}
}));
this.$nextTick(() => {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
this.activeRouteIds.forEach(id => {
const route = this.routes.find(r => r.id === id);
if (route && route.waypoints && route.waypoints.length > 0 && this.$refs.cesiumMap) {
// /K使/
if (route.waypoints.some(wp => this.isHoldWaypoint(wp))) {
this.getPositionAtMinutesFromK(route.waypoints, minMinutes, minMinutes, maxMinutes, id);
}
this.$refs.cesiumMap.removeRouteById(id);
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
}
@ -1978,7 +2087,7 @@ export default {
openAddHoldDuringDrawing() {
this.addHoldContext = { mode: 'drawing' };
this.addHoldForm = { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.addHoldForm = { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.showAddHoldDialog = true;
},
@ -2149,6 +2258,9 @@ export default {
if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams;
if (updatedWaypoint.labelFontSize != null) payload.labelFontSize = updatedWaypoint.labelFontSize;
if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor;
if (updatedWaypoint.segmentMode != null) payload.segmentMode = updatedWaypoint.segmentMode;
if (updatedWaypoint.segmentTargetMinutes !== undefined) payload.segmentTargetMinutes = updatedWaypoint.segmentTargetMinutes;
if (updatedWaypoint.segmentTargetSpeed !== undefined) payload.segmentTargetSpeed = updatedWaypoint.segmentTargetSpeed;
if (updatedWaypoint.pixelSize != null) payload.pixelSize = updatedWaypoint.pixelSize;
if (updatedWaypoint.color != null) payload.color = updatedWaypoint.color;
if (updatedWaypoint.outlineColor != null) payload.outlineColor = updatedWaypoint.outlineColor;
@ -2166,6 +2278,78 @@ export default {
const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id);
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
}
// K使 K+
if (merged.segmentMode === 'fixed_time' && index > 0) {
const prev = sd.waypoints[index - 1];
const distM = this.segmentDistance(
{ lat: prev.lat, lng: prev.lng, alt: prev.alt },
{ lat: merged.lat, lng: merged.lng, alt: merged.alt }
);
const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime);
const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime);
const deltaMin = currMinutes - prevMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const prevPayload = { ...prev, speed: speedVal };
if (prev.segmentMode != null) prevPayload.segmentMode = prev.segmentMode;
if (prev.labelFontSize != null) prevPayload.labelFontSize = prev.labelFontSize;
if (prev.labelColor != null) prevPayload.labelColor = prev.labelColor;
if (prev.color != null) prevPayload.color = prev.color;
if (prev.pixelSize != null) prevPayload.pixelSize = prev.pixelSize;
if (prev.outlineColor != null) prevPayload.outlineColor = prev.outlineColor;
try {
const r2 = await updateWaypoints(prevPayload);
if (r2.code === 200) {
Object.assign(prev, { speed: speedVal });
sd.waypoints.splice(index - 1, 1, prev);
if (routeInList && routeInList.waypoints) {
const prevIdxInList = routeInList.waypoints.findIndex(p => p.id === prev.id);
if (prevIdxInList !== -1) routeInList.waypoints.splice(prevIdxInList, 1, prev);
}
}
} catch (e) {
console.warn('定时点重算上一航点速度失败', e);
}
}
}
// 使 K
if (index < sd.waypoints.length - 1) {
const next = sd.waypoints[index + 1];
if (next.segmentMode === 'fixed_time') {
const distToNextM = this.segmentDistance(
{ lat: merged.lat, lng: merged.lng, alt: merged.alt },
{ lat: next.lat, lng: next.lng, alt: next.alt }
);
const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime);
const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime);
const deltaMin = nextMinutes - currMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const currPayload = { ...merged, speed: speedVal };
if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode;
if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize;
if (merged.labelColor != null) currPayload.labelColor = merged.labelColor;
if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize;
if (merged.color != null) currPayload.color = merged.color;
if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor;
try {
const r2 = await updateWaypoints(currPayload);
if (r2.code === 200) {
Object.assign(merged, { speed: speedVal });
sd.waypoints.splice(index, 1, merged);
if (routeInList && routeInList.waypoints) {
const idxInList = routeInList.waypoints.findIndex(p => p.id === merged.id);
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
}
}
} catch (e) {
console.warn('下一航点为定时点重算本航点速度失败', e);
}
}
}
}
if (this.$refs.cesiumMap) {
if (roomId && sd.platformId) {
try {
@ -2184,6 +2368,11 @@ export default {
}
this.showWaypointDialog = false;
this.$message.success('航点信息已持久化至数据库');
if (this.$refs.cesiumMap && (this.$refs.cesiumMap.setRouteHoldRadii || this.$refs.cesiumMap.setRouteHoldEllipseParams)) {
const rid = sd.id;
if (this.$refs.cesiumMap.setRouteHoldRadii) this.$refs.cesiumMap.setRouteHoldRadii(rid, {});
if (this.$refs.cesiumMap.setRouteHoldEllipseParams) this.$refs.cesiumMap.setRouteHoldEllipseParams(rid, {});
}
this.$nextTick(() => this.updateDeductionPositions());
this.wsConnection?.sendSyncWaypoints?.(this.selectedRouteDetails.id);
// Redis
@ -3004,8 +3193,17 @@ export default {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
}
this._playbackLastTickTime = Date.now();
// 1 = playbackSpeed setInterval
this.playbackInterval = setInterval(() => {
this.timeProgress += this.playbackSpeed * 0.1;
const now = Date.now();
const elapsedSec = (now - (this._playbackLastTickTime || now)) / 1000;
this._playbackLastTickTime = now;
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const span = Math.max(0, maxMinutes - minMinutes) || 120;
const spanSeconds = span * 60;
// elapsedSec playbackSpeed * elapsedSec (elapsedSec * playbackSpeed / spanSeconds) * 100
this.timeProgress += (elapsedSec * this.playbackSpeed / spanSeconds) * 100;
if (this.timeProgress >= 100) {
this.timeProgress = 0;
}
@ -3020,18 +3218,9 @@ export default {
}
},
increaseSpeed() {
if (this.playbackSpeed < 25) {
this.playbackSpeed++;
if (this.isPlaying) {
this.startPlayback();
}
}
},
decreaseSpeed() {
if (this.playbackSpeed > 1) {
this.playbackSpeed--;
setPlaybackSpeed(speed) {
if (this.speedOptions.includes(speed)) {
this.playbackSpeed = speed;
if (this.isPlaying) {
this.startPlayback();
}
@ -3045,10 +3234,11 @@ export default {
this.deductionMinutesFromK = currentMinutesFromK;
const sign = currentMinutesFromK >= 0 ? '+' : '-';
const absMin = Math.abs(Math.floor(currentMinutesFromK));
const hours = Math.floor(absMin / 60);
const minutes = absMin % 60;
this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
const totalSeconds = Math.floor(Math.abs(currentMinutesFromK) * 60);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
// 线
this.combatTime = this.currentTime;
this.updateDeductionPositions();
@ -3152,6 +3342,17 @@ export default {
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
},
/** 将 startTime(如 K+00:19:30)转为相对 K 的分钟数(含秒,保留小数) */
waypointStartTimeToMinutesDecimal(s) {
if (!s || typeof s !== 'string') return 0;
const m = s.match(/K([+-])(\d{2}):(\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);
const sec = m[4] != null ? parseInt(m[4], 10) : 0;
return sign * (h * 60 + min + sec / 60);
},
/** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */
minutesToStartTime(minutes) {
const m = Math.floor(Number(minutes));
@ -3162,6 +3363,18 @@ export default {
const min = abs % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
},
/** 将相对 K 的分钟数(可含小数)转为 startTime,精确到秒(如 K+00:19:30) */
minutesToStartTimeWithSeconds(minutes) {
const num = Number(minutes);
if (!Number.isFinite(num)) return 'K+00:00:00';
const sign = num >= 0 ? '+' : '-';
const abs = Math.abs(num);
const totalSec = Math.round(abs * 60);
const h = Math.floor(totalSec / 3600);
const min = Math.floor((totalSec % 3600) / 60);
const sec = totalSec % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
},
isHoldWaypoint(wp) {
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal';
@ -3172,7 +3385,14 @@ export default {
if (!raw) return null;
try {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
return { radius: p.radius, semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, clockwise: p.clockwise !== false };
return {
radius: p.radius,
semiMajor: p.semiMajor ?? p.semiMajorAxis,
semiMinor: p.semiMinor ?? p.semiMinorAxis,
headingDeg: p.headingDeg ?? 0,
clockwise: p.clockwise !== false,
edgeLength: p.edgeLength != null ? Number(p.edgeLength) : 20000
};
} catch (e) {
return null;
}
@ -3275,25 +3495,44 @@ export default {
const exitIdx = segmentEndIndices[i];
const toNextSlice = path.slice(exitIdx, (segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1) + 1);
const distToEntry = this.pathSliceDistance(toEntrySlice);
const speedKmh = points[i].speed || 800;
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh);
const arrivalEntry = effectiveTime[i] + travelToEntryMin;
const holdWpForSegment = waypoints[i + 1];
const segTarget = holdWpForSegment && (holdWpForSegment.segmentTargetMinutes ?? holdWpForSegment.displayStyle?.segmentTargetMinutes);
const hasFixedTime = holdWpForSegment && holdWpForSegment.segmentMode === 'fixed_time' && (segTarget != null && segTarget !== '');
let arrivalEntry;
let speedKmhForLeg = points[i].speed || 800;
if (hasFixedTime) {
const targetMin = Number(segTarget);
const deltaMin = targetMin - effectiveTime[i];
if (deltaMin > 0.001 && distToEntry > 0) {
arrivalEntry = targetMin;
speedKmhForLeg = (distToEntry / 1000) / (deltaMin / 60);
speedKmhForLeg = Math.round(speedKmhForLeg * 10) / 10;
} else {
arrivalEntry = effectiveTime[i] + (distToEntry / 1000) * (60 / speedKmhForLeg);
}
} else {
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmhForLeg);
arrivalEntry = effectiveTime[i] + travelToEntryMin;
}
const holdEndTime = points[i + 1].minutes; // K+10
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : (toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt });
let loopEndIdx = 1;
for (let k = 1; k < Math.min(holdPathSlice.length, 120); k++) {
for (let k = 1; k < Math.min(holdPathSlice.length, 200); k++) {
if (this.segmentDistance(holdPathSlice[0], holdPathSlice[k]) < 80) { loopEndIdx = k; break; }
}
const holdClosedLoopPath = holdPathSlice.slice(0, loopEndIdx + 1);
const holdLoopLength = this.pathSliceDistance(holdClosedLoopPath) || 1;
let exitIdxOnLoop = 0;
//
let exitIdxOnLoop = holdPathSlice.length - 1;
let minD = 1e9;
for (let k = 0; k <= loopEndIdx; k++) {
for (let k = 0; k < holdPathSlice.length; k++) {
const d = this.segmentDistance(holdPathSlice[k], exitPos);
if (d < minD) { minD = d; exitIdxOnLoop = k; }
}
const holdExitDistanceOnLoop = this.pathSliceDistance(holdPathSlice.slice(0, exitIdxOnLoop + 1));
const speedMpMin = (speedKmh * 1000) / 60;
const holdSpeedKmh = points[i + 1].speed || 800;
const HOLD_SPEED_KMH = 800;
const speedMpMin = (HOLD_SPEED_KMH * 1000) / 60;
const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin;
let n = Math.ceil((requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength);
if (n < 0 || !Number.isFinite(n)) n = 0;
@ -3314,7 +3553,7 @@ export default {
});
}
const distExitToNext = this.pathSliceDistance(toNextSlice);
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh);
const travelExitMin = (distExitToNext / 1000) * (60 / holdSpeedKmh);
const arrivalNext = segmentEndTime + travelExitMin;
effectiveTime[i + 1] = holdEndTime;
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext;
@ -3330,7 +3569,8 @@ export default {
const holdEntryAngle = holdCenter && entryPos && holdRadius != null
? this.angleFromCenterToPoint(holdCenter.lng, holdCenter.lat, entryPos.lng, entryPos.lat)
: null;
segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice });
segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice, speedKmh: speedKmhForLeg });
const holdEntryToExitPath = holdClosedLoopPath.slice(0, exitIdxOnLoop + 1);
segments.push({
startTime: arrivalEntry,
endTime: segmentEndTime,
@ -3342,16 +3582,18 @@ export default {
holdClosedLoopPath,
holdLoopLength,
holdExitDistanceOnLoop,
speedKmh: points[i].speed || 800,
holdEntryToExitPath,
holdN: n,
speedKmh: HOLD_SPEED_KMH,
holdEndTime,
holdCenter,
holdRadius,
holdCircumference,
holdClockwise,
holdEntryAngle
});
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice });
i++;
continue;
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice, speedKmh: holdSpeedKmh });
continue; // i++ WP2WP3
}
const dist = this.segmentDistance(points[i], points[i + 1]);
const speedKmh = points[i].speed || 800;
@ -3378,7 +3620,7 @@ export default {
effectiveTime[i + 1] = Math.max(actualArrival, scheduled);
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
const posNext = { lng: points[i + 1].lng, lat: points[i + 1].lat, alt: points[i + 1].alt };
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i });
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i, speedKmh: speedKmh });
if (actualArrival < effectiveTime[i + 1]) {
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i });
}
@ -3447,12 +3689,24 @@ export default {
return s.startPos;
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
// K
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60);
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength;
const tPath = distOnLoop / s.holdLoopLength;
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath);
const n = Math.max(0, s.holdN != null ? s.holdN : 0);
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength;
if (totalFly <= 0) return s.startPos;
if (distM >= totalFly) return s.endPos;
if (distM < n * s.holdLoopLength) {
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength;
const tPath = distOnLoop / s.holdLoopLength;
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath);
}
const distToExit = distM - n * s.holdLoopLength;
const exitDist = s.holdExitDistanceOnLoop || 1;
const tPath = Math.min(1, distToExit / exitDist);
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) {
return this.getPositionAlongPathSlice(s.holdEntryToExitPath, tPath);
}
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength);
}
return this.getPositionAlongPathSlice(s.holdPath, t);
}
@ -3481,7 +3735,10 @@ export default {
const cesiumMap = this.$refs.cesiumMap;
let pathData = null;
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) {
const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints);
const cachedRadii = (routeId != null && cesiumMap._routeHoldRadiiByRoute && cesiumMap._routeHoldRadiiByRoute[routeId]) ? cesiumMap._routeHoldRadiiByRoute[routeId] : {};
const cachedEllipse = (routeId != null && cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId]) ? cesiumMap._routeHoldEllipseParamsByRoute[routeId] : {};
const opts = (Object.keys(cachedRadii).length > 0 || Object.keys(cachedEllipse).length > 0) ? { holdRadiusByLegIndex: cachedRadii, holdEllipseParamsByLegIndex: cachedEllipse } : {};
const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints, opts);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
@ -3494,28 +3751,44 @@ export default {
const s = segments[idx];
if (s.type !== 'hold' || s.holdCenter == null) continue;
const i = s.legIndex;
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const holdEndTime = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(waypoints[i + 1]?.startTime);
const holdWp = waypoints[i + 1];
const segTarget = holdWp && (holdWp.segmentTargetMinutes ?? holdWp.displayStyle?.segmentTargetMinutes);
const arrivalAtHold = (holdWp && holdWp.segmentMode === 'fixed_time' && segTarget != null && segTarget !== '')
? Number(segTarget) : s.startTime;
const holdDurationMin = Math.max(0, holdEndTime - arrivalAtHold);
const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWp?.speed) || 800);
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const holdWp = waypoints[i + 1];
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
if (!prevWp || !holdWp) continue;
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0);
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0);
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian;
const clockwise = s.holdClockwise !== false;
const isEllipse = (waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse') || s.holdRadius == null;
if (isEllipse && cesiumMap.computeEllipseParamsForDuration) {
const isHoldEllipse = waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse';
const isEllipse = isHoldEllipse || s.holdRadius == null;
if (isEllipse && !isHoldEllipse && cesiumMap.computeEllipseParamsForDuration) {
const holdParams = this.parseHoldParams(holdWp);
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0;
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500;
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300;
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM, headingDeg, a0, b0);
if (out && out.semiMajor != null && out.semiMinor != null) holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg };
if (out && out.semiMajor != null && out.semiMinor != null) {
holdEllipseParamsByLegIndex[i] = {
semiMajor: out.semiMajor,
semiMinor: out.semiMinor,
headingDeg
};
}
} else if (!isEllipse && cesiumMap.computeHoldRadiusForDuration) {
const R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM);
if (R != null && Number.isFinite(R)) holdRadiusByLegIndex[i] = R;
let R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM);
if (R == null || !Number.isFinite(R)) {
R = totalHoldDistM / (2 * Math.PI);
}
if (R != null && Number.isFinite(R) && R > 0) {
holdRadiusByLegIndex[i] = R;
}
}
}
const hasCircle = Object.keys(holdRadiusByLegIndex).length > 0;
@ -3523,7 +3796,7 @@ export default {
if (hasCircle || hasEllipse) {
let pathData2 = null;
let segments2 = null;
for (let iter = 0; iter < 2; iter++) {
for (let iter = 0; iter < 4; iter++) {
const ret2 = cesiumMap.getRoutePathWithSegmentIndices(waypoints, { holdRadiusByLegIndex, holdEllipseParamsByLegIndex });
if (!ret2.path || ret2.path.length === 0 || !ret2.segmentEndIndices || ret2.segmentEndIndices.length === 0) break;
pathData2 = { path: ret2.path, segmentEndIndices: ret2.segmentEndIndices, holdArcRanges: ret2.holdArcRanges || {} };
@ -3536,20 +3809,26 @@ export default {
const s = segments2[idx];
if (s.type !== 'hold' || s.holdRadius == null || s.holdCenter == null) continue;
const i = s.legIndex;
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const holdWpCircle = waypoints[i + 1];
const holdEndTimeCircle = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(holdWpCircle?.startTime);
const segTargetCircle = holdWpCircle && (holdWpCircle.segmentTargetMinutes ?? holdWpCircle.displayStyle?.segmentTargetMinutes);
const arrivalAtHoldCircle = (holdWpCircle && holdWpCircle.segmentMode === 'fixed_time' && segTargetCircle != null && segTargetCircle !== '')
? Number(segTargetCircle) : s.startTime;
const holdDurationMin = Math.max(0, holdEndTimeCircle - arrivalAtHoldCircle);
const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWpCircle?.speed) || 800);
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const holdWp = waypoints[i + 1];
const holdWp = holdWpCircle;
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
if (!prevWp || !holdWp) continue;
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0);
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0);
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian;
const Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM);
if (Rnew != null && Number.isFinite(Rnew)) {
if (holdRadiusByLegIndex[i] == null || Math.abs(Rnew - holdRadiusByLegIndex[i]) > 1) changed = true;
let Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM);
if (Rnew == null || !Number.isFinite(Rnew)) Rnew = totalHoldDistM / (2 * Math.PI);
if (Rnew != null && Number.isFinite(Rnew) && Rnew > 0) {
nextRadii[i] = Rnew;
if (holdRadiusByLegIndex[i] == null || Math.abs(nextRadii[i] - holdRadiusByLegIndex[i]) > 1) changed = true;
}
}
Object.assign(holdRadiusByLegIndex, nextRadii);
@ -3560,9 +3839,14 @@ export default {
if (s.type !== 'hold' || s.holdRadius != null || s.holdCenter == null) continue;
const i = s.legIndex;
const holdWp = waypoints[i + 1];
if ((holdWp && (holdWp.pointType || holdWp.point_type) === 'hold_ellipse')) continue;
const holdParams = this.parseHoldParams(holdWp);
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const holdEndTime = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(holdWp?.startTime);
const segTargetEllipse = holdWp && (holdWp.segmentTargetMinutes ?? holdWp.displayStyle?.segmentTargetMinutes);
const arrivalAtHold = (holdWp && holdWp.segmentMode === 'fixed_time' && segTargetEllipse != null && segTargetEllipse !== '')
? Number(segTargetEllipse) : s.startTime;
const holdDurationMin = Math.max(0, holdEndTime - arrivalAtHold);
const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWp?.speed) || 800);
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
@ -3575,13 +3859,15 @@ export default {
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300;
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM, headingDeg, a0, b0);
if (out && out.semiMajor != null) {
const smj = out.semiMajor;
const smn = out.semiMinor;
const old = holdEllipseParamsByLegIndex[i];
if (!old || Math.abs(out.semiMajor - old.semiMajor) > 1) changed = true;
holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg };
if (!old || Math.abs(smj - old.semiMajor) > 1) changed = true;
holdEllipseParamsByLegIndex[i] = { semiMajor: smj, semiMinor: smn, headingDeg };
}
}
}
if (!changed || iter === 1) break;
if (!changed || iter === 3) break;
}
if (pathData2) pathData = pathData2;
if (segments2) segments = segments2;
@ -3687,7 +3973,7 @@ export default {
if (this.addHoldContext.mode === 'drawing') {
const holdParams = this.addHoldForm.holdType === 'hold_circle'
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise }
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise };
: { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, clockwise: this.addHoldForm.clockwise };
if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) {
this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams);
}
@ -3709,7 +3995,7 @@ export default {
const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1;
const holdParams = this.addHoldForm.holdType === 'hold_circle'
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise }
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise };
: { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, clockwise: this.addHoldForm.clockwise };
const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes))
? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes))
: (nextWp.startTime || 'K+01:00');
@ -3780,10 +4066,24 @@ export default {
const span = Math.max(0, maxMinutes - minMinutes) || 120;
const minutesFromK = minMinutes + (val / 100) * span;
const sign = minutesFromK >= 0 ? '+' : '-';
const absMin = Math.abs(Math.floor(minutesFromK));
const hours = Math.floor(absMin / 60);
const minutes = absMin % 60;
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
const totalSeconds = Math.floor(Math.abs(minutesFromK) * 60);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
},
onTimelineHover(e) {
const wrap = this.$refs.timelineSliderWrap;
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
this.timelineHoverPercent = percent;
this.timelineHoverTime = this.formatTimeTooltip(percent);
this.timelineHoverVisible = true;
},
onTimelineLeave() {
this.timelineHoverVisible = false;
},
async selectPlan(plan) {
if (plan && plan.id) {
@ -3933,6 +4233,10 @@ export default {
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
} catch (_) {}
}
if (waypoints.some(wp => this.isHoldWaypoint(wp))) {
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, route.id);
}
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
@ -4478,6 +4782,33 @@ export default {
.timeline-slider {
flex: 1;
margin: 0 20px;
position: relative;
}
.timeline-hover-time {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
transform: translateX(-50%);
padding: 4px 10px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.timeline-hover-time::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(0, 0, 0, 0.85);
}
.compact-slider {
@ -4544,6 +4875,16 @@ export default {
min-width: 24px;
text-align: center;
}
.speed-text.clickable {
cursor: pointer;
padding: 2px 4px;
display: inline-flex;
align-items: center;
}
.speed-text .el-icon--right {
margin-left: 4px;
font-size: 10px;
}
.system-status {
display: flex;

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

@ -231,10 +231,20 @@
<el-input v-else v-model.number="scope.row.alt" size="mini" placeholder="高度" />
</template>
</el-table-column>
<el-table-column label="速度" min-width="80">
<el-table-column label="定速/定时" width="100" align="center">
<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="速度" />
<span v-if="!waypointsEditMode">{{ segmentModeLabel(scope.row.segmentMode) }}</span>
<el-select v-else v-model="scope.row.segmentMode" size="mini" placeholder="无" clearable style="width: 100%">
<el-option label="无" :value="null" />
<el-option label="定速" value="fixed_speed" />
<el-option label="定时" value="fixed_time" />
</el-select>
</template>
</el-table-column>
<el-table-column label="速度(km/h)" min-width="100">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNumOneDecimal(scope.row.speed) }}</span>
<el-input-number v-else v-model.number="scope.row.speed" size="mini" :precision="1" :min="0" :controls="false" placeholder="速度" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="转弯坡度(°)" min-width="100">
@ -252,8 +262,8 @@
</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" />
<span v-if="!waypointsEditMode">{{ formatNumOneDecimal(scope.row.minutesFromK) }}</span>
<el-input-number v-else v-model.number="scope.row.minutesFromK" size="mini" :precision="1" :controls="false" placeholder="0" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="字号" width="70">
@ -274,7 +284,7 @@
<el-table-column label="标记大小" width="80">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.pixelSize) }}</span>
<el-input v-else v-model.number="scope.row.pixelSize" size="mini" placeholder="12" :min="4" :max="24" />
<el-input v-else v-model.number="scope.row.pixelSize" size="mini" placeholder="10" :min="4" :max="24" />
</template>
</el-table-column>
<el-table-column label="标记颜色" width="100">
@ -352,14 +362,14 @@ export default {
id: '',
name: ''
},
// 线 attributes 退
// 线线 attributes 退
defaultStyle: {
waypoint: { pixelSize: 10, color: '#f1f5f9', outlineColor: '#64748b', outlineWidth: 1.5 },
line: { style: 'solid', width: 3, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
line: { style: 'solid', width: 2, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
},
styleForm: {
waypoint: { pixelSize: 10, color: '#f1f5f9', outlineColor: '#64748b', outlineWidth: 1.5 },
line: { style: 'solid', width: 3, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
line: { style: 'solid', width: 2, color: '#64748b', gapColor: '#cbd5e1', dashLength: 20 }
},
presetColors: [
'#f1f5f9', '#64748b', '#334155', '#94a3b8', '#e2e8f0', '#0078FF', '#409EFF', '#67C23A',
@ -601,44 +611,79 @@ export default {
attrs.lineStyle = { ...this.styleForm.line }
return JSON.stringify(attrs)
},
/** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */
/** 将 startTime 字符串(如 K+00:40:00 或 K+00:19:30)转为相对 K 的分钟数(支持秒,保留一位小数精度) */
startTimeToMinutes(s) {
if (!s || typeof s !== 'string') return 0
const m = s.match(/K([+-])(\d{2}):(\d{2})/)
const m = s.match(/K([+-])(\d{2}):(\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)
const sec = m[4] != null ? parseInt(m[4], 10) : 0
return sign * (h * 60 + min + sec / 60)
},
/** 将相对 K 的分钟数转为 startTime 字符串 */
/** 将相对 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 totalSec = Math.round(num * 60)
const h = Math.floor(totalSec / 3600)
const min = Math.floor((totalSec % 3600) / 60)
const sec = totalSec % 60
return `K+${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
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`
const totalSec = Math.round(abs * 60)
const h = Math.floor(totalSec / 3600)
const min = Math.floor((totalSec % 3600) / 60)
const sec = totalSec % 60
return `K-${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
},
segmentModeLabel(mode) {
if (mode === 'fixed_speed') return '定速'
if (mode === 'fixed_time') return '定时'
return '无'
},
/** 数值保留一位小数显示,空/非数字显示 — */
formatNumOneDecimal(val) {
if (val === undefined || val === null || val === '') return '—'
const n = Number(val)
return isNaN(n) ? String(val) : Number(n.toFixed(1))
},
/** 两航点间近似距离(米),含高度差,用于定时点反算上一航点速度 */
segmentDistance(wp1, wp2) {
const R = 6371000
const lat1 = (wp1.lat * Math.PI) / 180
const lat2 = (wp2.lat * Math.PI) / 180
const dlat = ((wp2.lat - wp1.lat) * Math.PI) / 180
const dlng = ((wp2.lng - wp1.lng) * Math.PI) / 180
const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const horizontal = R * c
const dalt = (wp2.alt || 0) - (wp1.alt || 0)
return Math.sqrt(horizontal * horizontal + dalt * dalt)
},
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),
this.waypointsTableData = (waypoints || []).map(wp => {
const disp = wp.displayStyle;
return {
...wp,
minutesFromK: this.startTimeToMinutes(wp.startTime),
segmentMode: wp.segmentMode || (disp && disp.segmentMode) || null,
segmentTargetMinutes: (disp && disp.segmentTargetMinutes != null) ? Number(disp.segmentTargetMinutes) : (wp.segmentTargetMinutes != null ? Number(wp.segmentTargetMinutes) : null),
segmentTargetSpeed: (disp && disp.segmentTargetSpeed != null) ? Number(disp.segmentTargetSpeed) : (wp.segmentTargetSpeed != null ? Number(wp.segmentTargetSpeed) : null),
labelFontSize: wp.labelFontSize != null ? Number(wp.labelFontSize) : 14,
labelColor: wp.labelColor || '#333333',
pixelSize: wp.pixelSize != null ? Number(wp.pixelSize) : 10,
color: wp.color || '#f1f5f9',
outlineColor: wp.outlineColor || '#64748b'
}))
};
});
this.waypointsEditMode = false
},
formatNum(val) {
@ -656,7 +701,23 @@ export default {
},
getWaypointsPayloadForSave() {
const routeId = this.form.id
return this.waypointsTableData.map(row => ({
const rows = this.waypointsTableData
// (K)K+K
const prevSpeedByIndex = {}
for (let i = 1; i < rows.length; i++) {
if (rows[i].segmentMode === 'fixed_time') {
const prev = rows[i - 1]
const curr = rows[i]
const currArrivalMin = (curr.segmentTargetMinutes != null && curr.segmentTargetMinutes !== '') ? Number(curr.segmentTargetMinutes) : Number(curr.minutesFromK)
const deltaMin = currArrivalMin - Number(prev.minutesFromK)
if (deltaMin > 0.001) {
const distM = this.segmentDistance(prev, curr)
const speedKmh = (distM / 1000) / (deltaMin / 60)
prevSpeedByIndex[i - 1] = Math.round(speedKmh * 10) / 10
}
}
}
return rows.map((row, idx) => ({
id: row.id,
routeId,
seq: row.seq,
@ -664,11 +725,14 @@ export default {
lng: row.lng,
lat: row.lat,
alt: row.alt,
speed: row.speed,
speed: prevSpeedByIndex[idx] != null ? prevSpeedByIndex[idx] : (row.speed != null ? Math.round(Number(row.speed) * 10) / 10 : null),
startTime: this.minutesToStartTime(row.minutesFromK),
turnAngle: row.turnAngle != null ? row.turnAngle : 0,
pointType: row.pointType || null,
holdParams: row.holdParams || null,
segmentMode: row.segmentMode || null,
segmentTargetMinutes: row.segmentTargetMinutes != null ? row.segmentTargetMinutes : null,
segmentTargetSpeed: row.segmentTargetSpeed != null ? row.segmentTargetSpeed : null,
labelFontSize: row.labelFontSize != null ? row.labelFontSize : 14,
labelColor: row.labelColor || '#333333',
color: row.color != null && row.color !== '' ? row.color : '#f1f5f9',
@ -1065,6 +1129,21 @@ export default {
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-wrap .el-table__body-wrapper::-webkit-scrollbar {
height: 12px;
}
.waypoints-table-wrap .el-table__body-wrapper::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 6px;
}
.waypoints-table-wrap .el-table__body-wrapper::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 6px;
}
.waypoints-table-wrap .el-table__body-wrapper::-webkit-scrollbar-thumb:hover {
background: #909399;
}
.waypoints-table {
width: 100%;
}

174
ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

@ -66,16 +66,52 @@
></el-input-number>
</el-form-item>
<el-form-item label="速度" prop="speed">
<el-form-item label="速度(km/h)" prop="speed">
<el-input-number
v-model="formData.speed"
:min="0"
:precision="1"
controls-position="right"
placeholder="请输入速度"
class="full-width-input"
></el-input-number>
</el-form-item>
<el-form-item label="定速/定时">
<el-radio-group v-model="formData.segmentMode" class="segment-mode-group">
<el-radio :label="null"></el-radio>
<el-radio label="fixed_speed">定速</el-radio>
<el-radio label="fixed_time">定时</el-radio>
</el-radio-group>
<div class="form-tip form-hold-tip">定速移动下一航点会按距离÷速度重算下一航点的相对K时定时移动本航点会按距离÷时间重算上一航点的速度</div>
<template v-if="formData.segmentMode === 'fixed_time'">
<el-form-item label="定时到达(分)" class="segment-target-item">
<el-input-number
v-model="formData.segmentTargetMinutes"
:min="-9999"
:max="9999"
:precision="1"
controls-position="right"
placeholder="到达本航点的相对K时,可与下方相对K时分离以支持“定时到达+盘旋至相对K时出发”"
class="full-width-input"
/>
<div class="form-tip form-hold-tip">定时到达时间可与相对K时不同如盘旋点到达K+5盘旋至K+10再前往下一航点则此处填5相对K时填10</div>
</el-form-item>
</template>
<template v-else-if="formData.segmentMode === 'fixed_speed'">
<el-form-item label="定速(km/h)" class="segment-target-item">
<el-input-number
v-model="formData.segmentTargetSpeed"
:min="0"
:precision="1"
controls-position="right"
placeholder="本航段使用的固定速度"
class="full-width-input"
/>
</el-form-item>
</template>
</el-form-item>
<el-form-item v-if="!isHoldWaypoint" label="转弯坡度" prop="turnAngle">
<el-input-number
v-model="formData.turnAngle"
@ -95,26 +131,27 @@
<el-radio label="hold_ellipse">椭圆</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="formData.pointType === 'hold_circle'" label="半径(米)">
<el-input-number v-model="formData.holdRadius" :min="100" :max="50000" class="full-width-input" />
</el-form-item>
<template v-if="formData.pointType === 'hold_ellipse'">
<el-form-item label="长半轴(米)">
<el-input-number v-model="formData.holdSemiMajor" :min="100" :max="50000" class="full-width-input" />
</el-form-item>
<el-form-item label="短半轴(米)">
<el-input-number v-model="formData.holdSemiMinor" :min="50" :max="50000" class="full-width-input" />
</el-form-item>
<el-form-item label="长轴方位(度)">
<el-input-number v-model="formData.holdHeadingDeg" :min="-180" :max="180" class="full-width-input" />
</el-form-item>
</template>
<el-form-item label="盘旋方向">
<el-radio-group v-model="formData.holdClockwise">
<el-radio :label="true">顺时针</el-radio>
<el-radio :label="false">逆时针</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="isHoldWaypoint && formData.pointType === 'hold_ellipse'">
<el-form-item label="跑道边长(km)">
<el-input-number
v-model="formData.holdEdgeLengthKm"
:min="1"
:max="200"
:step="1"
controls-position="right"
placeholder="默认 20"
class="full-width-input"
/>
<div class="form-tip form-hold-tip">两条直边每边长度默认 20km两端弧的转弯半径由本航点转弯坡度与速度自动计算</div>
</el-form-item>
</template>
<div v-if="isHoldWaypoint" class="form-tip form-hold-tip">盘旋点需设置定时且定时 &lt; 相对K时到达时间早于切出时间才有盘旋时长半径/长短轴由相对K时定时与速度自动计算并与最小值比较若计算值小于最小值将提示冲突请调整相对K时或速度</div>
</template>
<el-form-item label="相对K时(分)" prop="minutesFromK">
@ -122,6 +159,7 @@
v-model="formData.minutesFromK"
:min="-9999"
:max="9999"
:precision="1"
controls-position="right"
placeholder="正数 K 后,负数 K 前"
class="full-width-input"
@ -170,15 +208,15 @@ export default {
speed: 800,
turnAngle: 0,
minutesFromK: 0,
segmentMode: null,
segmentTargetMinutes: null,
segmentTargetSpeed: null,
currentIndex: -1,
totalPoints: 0,
isBankDisabled: false,
pointType: 'normal',
holdRadius: 500,
holdSemiMajor: 500,
holdSemiMinor: 300,
holdHeadingDeg: 0,
holdClockwise: true,
holdEdgeLengthKm: 20,
labelFontSize: 14,
labelColor: '#334155',
pixelSize: 10,
@ -229,16 +267,14 @@ export default {
const locked = (index === 0) || (total > 0 && index === total - 1);
const pt = (this.waypoint.pointType || this.waypoint.point_type) || 'normal';
let holdRadius = 500, holdSemiMajor = 500, holdSemiMinor = 300, holdHeadingDeg = 0, holdClockwise = true;
let holdClockwise = true;
let holdEdgeLengthKm = 20;
try {
const raw = this.waypoint.holdParams || this.waypoint.hold_params;
if (raw) {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
holdRadius = p.radius != null ? p.radius : 500;
holdSemiMajor = p.semiMajor ?? p.semiMajorAxis ?? 500;
holdSemiMinor = p.semiMinor ?? p.semiMinorAxis ?? 300;
holdHeadingDeg = p.headingDeg ?? 0;
holdClockwise = p.clockwise !== false;
if (p.edgeLength != null) holdEdgeLengthKm = Math.max(1, Math.min(200, Number(p.edgeLength) / 1000));
}
} catch (e) {}
const labelFontSize = this.waypoint.labelFontSize != null ? Number(this.waypoint.labelFontSize) : 14;
@ -246,21 +282,23 @@ export default {
const pixelSize = this.waypoint.pixelSize != null ? Number(this.waypoint.pixelSize) : 10;
const color = this.waypoint.color || '#f1f5f9';
const outlineColor = this.waypoint.outlineColor != null ? this.waypoint.outlineColor : '#64748b';
const segmentMode = this.waypoint.segmentMode || (this.waypoint.displayStyle && this.waypoint.displayStyle.segmentMode) || null;
const disp = this.waypoint.displayStyle;
this.formData = {
name: this.waypoint.name || '',
alt: this.waypoint.alt !== undefined && this.waypoint.alt !== null ? Number(this.waypoint.alt) : 0,
speed: this.waypoint.speed !== undefined && this.waypoint.speed !== null ? Number(this.waypoint.speed) : 0,
minutesFromK: this.startTimeToMinutes(this.waypoint.startTime),
segmentMode: segmentMode === 'fixed_speed' || segmentMode === 'fixed_time' ? segmentMode : null,
segmentTargetMinutes: (disp && disp.segmentTargetMinutes != null) ? Number(disp.segmentTargetMinutes) : (this.waypoint.segmentTargetMinutes != null ? Number(this.waypoint.segmentTargetMinutes) : null),
segmentTargetSpeed: (disp && disp.segmentTargetSpeed != null) ? Number(disp.segmentTargetSpeed) : (this.waypoint.segmentTargetSpeed != null ? Number(this.waypoint.segmentTargetSpeed) : (this.waypoint.speed != null ? Number(this.waypoint.speed) : null)),
currentIndex: index,
totalPoints: total,
isBankDisabled: locked,
turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0),
turnAngle: locked ? 0 : (this.waypoint.turnAngle != null ? Number(this.waypoint.turnAngle) : 45),
pointType: pt,
holdRadius,
holdSemiMajor,
holdSemiMinor,
holdHeadingDeg,
holdClockwise,
holdEdgeLengthKm,
labelFontSize: Math.min(28, Math.max(10, labelFontSize)),
labelColor,
pixelSize: Math.min(24, Math.max(4, pixelSize)),
@ -279,33 +317,46 @@ export default {
},
saveWaypoint() {
this.$refs.formRef.validate((valid) => {
if (valid) {
const { minutesFromK, ...rest } = this.formData;
const startTimeStr = this.minutesToStartTime(minutesFromK);
const payload = {
...this.waypoint,
...rest,
startTime: startTimeStr,
labelFontSize: this.formData.labelFontSize,
labelColor: this.formData.labelColor,
pixelSize: this.formData.pixelSize,
color: this.formData.color,
outlineColor: this.formData.outlineColor
};
if (this.formData.pointType && this.formData.pointType !== 'normal') {
payload.pointType = this.formData.pointType;
payload.holdParams = this.formData.pointType === 'hold_circle'
? JSON.stringify({ radius: this.formData.holdRadius, clockwise: this.formData.holdClockwise })
: JSON.stringify({
semiMajor: this.formData.holdSemiMajor,
semiMinor: this.formData.holdSemiMinor,
headingDeg: this.formData.holdHeadingDeg,
clockwise: this.formData.holdClockwise
});
if (!valid) return;
const { minutesFromK, ...rest } = this.formData;
if (this.isHoldWaypoint && this.formData.segmentMode === 'fixed_time') {
const target = this.formData.segmentTargetMinutes;
if (target == null || target === '') {
this.$message.warning('盘旋点需设置定时到达时间,以便计算盘旋半径/长短轴');
return;
}
if (Number(target) >= Number(minutesFromK)) {
this.$message.warning('盘旋点要求定时 < 相对K时(到达时间须早于切出时间),请调整定时或相对K时');
return;
}
}
const startTimeStr = this.minutesToStartTime(minutesFromK);
const segmentTargetMinutes = this.formData.segmentMode === 'fixed_time' && this.formData.segmentTargetMinutes != null ? this.formData.segmentTargetMinutes : null;
const segmentTargetSpeed = this.formData.segmentMode === 'fixed_speed' && this.formData.segmentTargetSpeed != null ? this.formData.segmentTargetSpeed : null;
const payload = {
...this.waypoint,
...rest,
startTime: startTimeStr,
segmentMode: this.formData.segmentMode || null,
segmentTargetMinutes,
segmentTargetSpeed,
displayStyle: { ...(this.waypoint.displayStyle || {}), segmentMode: this.formData.segmentMode, segmentTargetMinutes, segmentTargetSpeed },
labelFontSize: this.formData.labelFontSize,
labelColor: this.formData.labelColor,
pixelSize: this.formData.pixelSize,
color: this.formData.color,
outlineColor: this.formData.outlineColor
};
if (this.formData.pointType && this.formData.pointType !== 'normal') {
payload.pointType = this.formData.pointType;
const holdParams = { clockwise: this.formData.holdClockwise };
if (this.formData.pointType === 'hold_ellipse') {
holdParams.edgeLength = (this.formData.holdEdgeLengthKm != null ? this.formData.holdEdgeLengthKm : 20) * 1000;
}
this.$emit('save', payload);
this.closeDialog();
payload.holdParams = JSON.stringify(holdParams);
}
this.$emit('save', payload);
this.closeDialog();
});
},
/** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */
@ -448,6 +499,21 @@ export default {
color: #e6a23c;
}
.form-hold-tip {
margin-bottom: 12px;
padding: 6px 0;
}
.segment-mode-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.segment-target-item {
margin-top: 8px;
}
.color-value {
margin-left: 8px;
font-size: 12px;

Loading…
Cancel
Save