Browse Source

4T功能

mh
cuitw 1 month ago
parent
commit
0ca5b849ba
  1. 1
      ruoyi-ui/src/assets/icons/svg/T.svg
  2. 582
      ruoyi-ui/src/views/childRoom/FourTPanel.vue

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

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

After

Width:  |  Height:  |  Size: 430 B

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

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