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