9 changed files with 1923 additions and 1633 deletions
@ -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> |
|||
@ -0,0 +1,434 @@ |
|||
<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" |
|||
: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: { |
|||
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,683 @@ |
|||
<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> |
|||
<div |
|||
class="textbox-input" |
|||
contenteditable="true" |
|||
@blur="box.text = $event.target.innerText" |
|||
@mousedown.stop="selectedId = box.id" |
|||
>{{ box.text }}</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() |
|||
} |
|||
|
|||
export default { |
|||
name: 'TaskPageContent', |
|||
props: { |
|||
backgroundImage: { |
|||
type: String, |
|||
default: null |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
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 |
|||
} |
|||
}, |
|||
mounted() { |
|||
this._keydownHandler = (e) => this.onKeydown(e) |
|||
document.addEventListener('keydown', this._keydownHandler) |
|||
}, |
|||
beforeDestroy() { |
|||
document.removeEventListener('keydown', this._keydownHandler) |
|||
}, |
|||
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 |
|||
}) |
|||
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)` |
|||
} |
|||
}, |
|||
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 |
|||
}, |
|||
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; |
|||
} |
|||
|
|||
|
|||
.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,700 @@ |
|||
<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> |
|||
|
|||
<!-- 文本框元素 --> |
|||
<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> |
|||
<div |
|||
class="textbox-input" |
|||
contenteditable="true" |
|||
@blur="box.text = $event.target.innerText" |
|||
@mousedown.stop="selectedId = box.id" |
|||
>{{ box.text }}</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() |
|||
} |
|||
|
|||
export default { |
|||
name: 'UnderstandingStepContent', |
|||
props: { |
|||
backgroundImage: { |
|||
type: String, |
|||
default: null |
|||
}, |
|||
activeSubIndex: { |
|||
type: Number, |
|||
default: 0 |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
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) |
|||
}, |
|||
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 |
|||
}) |
|||
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)` |
|||
} |
|||
}, |
|||
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 |
|||
}, |
|||
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; |
|||
} |
|||
|
|||
.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