Compare commits
4 Commits
0fdb456e2c
...
ef58d663b5
| Author | SHA1 | Date |
|---|---|---|
|
|
ef58d663b5 | 3 weeks ago |
|
|
4b97960ad9 | 4 weeks ago |
|
|
53f5b0fbaa | 4 weeks ago |
|
|
b0ed224634 | 4 weeks ago |
16 changed files with 3613 additions and 2029 deletions
@ -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; |
|
||||
@ -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">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> |
|
||||
</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> </p><p> </p><p> </p><p> </p><p> </p></div> |
|
||||
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p> </p><p> </p><p> </p><p> </p><p> </p></div> |
|
||||
</div> |
|
||||
<div> |
|
||||
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p> </p><p> </p><p> </p><p> </p><p> </p></div> |
|
||||
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p> </p><p> </p><p> </p><p> </p><p> </p></div> |
|
||||
</div> |
|
||||
<div> |
|
||||
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p> </p><p> </p><p> </p><p> </p><p> </p></div> |
|
||||
<div style="margin-bottom: 80px;"><h2 style="font-size: 18px; font-weight: bold; display: inline-block;">XXX组:</h2><p> </p><p> </p><p> </p><p> </p><p> </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> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">次要目标:</p> |
|
||||
<p> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">风险等级:</p> |
|
||||
<p> </p> |
|
||||
<p> </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> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">风险识别:</p> |
|
||||
<p> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">应对措施:</p> |
|
||||
<p> </p> |
|
||||
<p> </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> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">时间节点:</p> |
|
||||
<p> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">实时监控:</p> |
|
||||
<p> </p> |
|
||||
<p> </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> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">效果评估:</p> |
|
||||
<p> </p> |
|
||||
<p> </p> |
|
||||
<p style="font-size: 16px; font-weight: bold; margin: 20px 0 10px 0;">总结改进:</p> |
|
||||
<p> </p> |
|
||||
<p> </p> |
|
||||
</div> |
|
||||
` |
|
||||
} else { |
|
||||
this.$refs.editor.innerHTML = '' |
|
||||
} |
|
||||
this.content = this.$refs.editor.innerHTML |
|
||||
this.updateWordCount() |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
</script> |
|
||||
</body> |
|
||||
</html> |
|
||||
File diff suppressed because it is too large
@ -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> |
||||
|
<!-- 理解步骤:子标题(点名、接收解析任务、XXXX、XXXX)放在理解和插入之间 --> |
||||
|
<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> |
||||
@ -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> |
|
||||
@ -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> |
||||
@ -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> |
||||
Loading…
Reference in new issue