2025-12-31 20:05:29 +08:00
|
|
|
<template>
|
|
|
|
|
<div v-show="visible" class="fp-root" :style="rootStyle" @mousedown.stop>
|
|
|
|
|
<div class="fp-header" @mousedown.prevent.stop="onDragStart">
|
|
|
|
|
<div class="fp-title">{{ title }}</div>
|
|
|
|
|
<div class="fp-actions">
|
|
|
|
|
<el-button type="text" class="fp-btn" @click.stop="toggleMinimize">
|
|
|
|
|
<i :class="minimized ? 'el-icon-caret-bottom' : 'el-icon-caret-top'"></i>
|
|
|
|
|
</el-button>
|
|
|
|
|
<el-button type="text" class="fp-btn" @click.stop="close">
|
|
|
|
|
<i class="el-icon-close"></i>
|
|
|
|
|
</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="!minimized" class="fp-body">
|
|
|
|
|
<slot />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Resize handle / 缩放手柄 -->
|
|
|
|
|
<div v-show="!minimized" class="fp-resize" @mousedown.prevent.stop="onResizeStart"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
// English UI + Chinese comments
|
|
|
|
|
export default {
|
|
|
|
|
name: 'FloatingPanel',
|
|
|
|
|
props: {
|
|
|
|
|
title: { type: String, default: 'Floating Panel' },
|
|
|
|
|
storageKey: { type: String, required: true },
|
|
|
|
|
defaultX: { type: Number, default: 20 },
|
|
|
|
|
defaultY: { type: Number, default: 20 },
|
|
|
|
|
defaultW: { type: Number, default: 420 },
|
|
|
|
|
defaultH: { type: Number, default: 520 }
|
|
|
|
|
},
|
|
|
|
|
data() {
|
|
|
|
|
return {
|
|
|
|
|
visible: true,
|
|
|
|
|
minimized: false,
|
|
|
|
|
x: this.defaultX,
|
|
|
|
|
y: this.defaultY,
|
|
|
|
|
w: this.defaultW,
|
|
|
|
|
h: this.defaultH,
|
|
|
|
|
|
|
|
|
|
dragging: false,
|
|
|
|
|
resizing: false,
|
|
|
|
|
startMouseX: 0,
|
|
|
|
|
startMouseY: 0,
|
|
|
|
|
startX: 0,
|
|
|
|
|
startY: 0,
|
|
|
|
|
startW: 0,
|
|
|
|
|
startH: 0
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
computed: {
|
|
|
|
|
rootStyle() {
|
|
|
|
|
return {
|
|
|
|
|
left: this.x + 'px',
|
|
|
|
|
top: this.y + 'px',
|
|
|
|
|
width: this.w + 'px',
|
|
|
|
|
height: this.minimized ? 'auto' : this.h + 'px'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
created() {
|
|
|
|
|
this.restore()
|
|
|
|
|
},
|
|
|
|
|
beforeDestroy() {
|
|
|
|
|
this.detachEvents()
|
|
|
|
|
},
|
|
|
|
|
methods: {
|
|
|
|
|
// 外部可调用:重新打开
|
|
|
|
|
open() {
|
|
|
|
|
this.visible = true
|
|
|
|
|
this.persist()
|
|
|
|
|
},
|
|
|
|
|
close() {
|
|
|
|
|
this.visible = false
|
|
|
|
|
this.persist()
|
|
|
|
|
this.$emit('close')
|
|
|
|
|
},
|
|
|
|
|
toggleMinimize() {
|
|
|
|
|
this.minimized = !this.minimized
|
|
|
|
|
this.persist()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
restore() {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(this.storageKey)
|
|
|
|
|
if (!raw) return
|
|
|
|
|
const s = JSON.parse(raw)
|
|
|
|
|
if (typeof s.visible === 'boolean') this.visible = s.visible
|
|
|
|
|
if (typeof s.minimized === 'boolean') this.minimized = s.minimized
|
|
|
|
|
if (typeof s.x === 'number') this.x = s.x
|
|
|
|
|
if (typeof s.y === 'number') this.y = s.y
|
|
|
|
|
if (typeof s.w === 'number') this.w = s.w
|
|
|
|
|
if (typeof s.h === 'number') this.h = s.h
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
persist() {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
this.storageKey,
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
visible: this.visible,
|
|
|
|
|
minimized: this.minimized,
|
|
|
|
|
x: this.x,
|
|
|
|
|
y: this.y,
|
|
|
|
|
w: this.w,
|
|
|
|
|
h: this.h
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Drag
|
|
|
|
|
onDragStart(e) {
|
|
|
|
|
this.dragging = true
|
|
|
|
|
this.startMouseX = e.clientX
|
|
|
|
|
this.startMouseY = e.clientY
|
|
|
|
|
this.startX = this.x
|
|
|
|
|
this.startY = this.y
|
|
|
|
|
this.attachEvents()
|
|
|
|
|
},
|
|
|
|
|
onDragMove(e) {
|
|
|
|
|
if (!this.dragging) return
|
|
|
|
|
const dx = e.clientX - this.startMouseX
|
|
|
|
|
const dy = e.clientY - this.startMouseY
|
|
|
|
|
this.x = Math.max(0, this.startX + dx)
|
|
|
|
|
this.y = Math.max(0, this.startY + dy)
|
|
|
|
|
},
|
|
|
|
|
onDragEnd() {
|
|
|
|
|
if (!this.dragging) return
|
|
|
|
|
this.dragging = false
|
|
|
|
|
this.persist()
|
|
|
|
|
this.detachEvents()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Resize
|
|
|
|
|
onResizeStart(e) {
|
|
|
|
|
this.resizing = true
|
|
|
|
|
this.startMouseX = e.clientX
|
|
|
|
|
this.startMouseY = e.clientY
|
|
|
|
|
this.startW = this.w
|
|
|
|
|
this.startH = this.h
|
|
|
|
|
this.attachEvents()
|
|
|
|
|
},
|
|
|
|
|
onResizeMove(e) {
|
|
|
|
|
if (!this.resizing) return
|
|
|
|
|
const dx = e.clientX - this.startMouseX
|
|
|
|
|
const dy = e.clientY - this.startMouseY
|
|
|
|
|
const minW = 320
|
|
|
|
|
const minH = 220
|
|
|
|
|
this.w = Math.max(minW, this.startW + dx)
|
|
|
|
|
this.h = Math.max(minH, this.startH + dy)
|
|
|
|
|
},
|
|
|
|
|
onResizeEnd() {
|
|
|
|
|
if (!this.resizing) return
|
|
|
|
|
this.resizing = false
|
|
|
|
|
this.persist()
|
|
|
|
|
this.detachEvents()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
attachEvents() {
|
|
|
|
|
window.addEventListener('mousemove', this.onGlobalMove)
|
|
|
|
|
window.addEventListener('mouseup', this.onGlobalUp)
|
|
|
|
|
},
|
|
|
|
|
detachEvents() {
|
|
|
|
|
window.removeEventListener('mousemove', this.onGlobalMove)
|
|
|
|
|
window.removeEventListener('mouseup', this.onGlobalUp)
|
|
|
|
|
},
|
|
|
|
|
onGlobalMove(e) {
|
|
|
|
|
// 复用一套全局事件
|
|
|
|
|
this.onDragMove(e)
|
|
|
|
|
this.onResizeMove(e)
|
|
|
|
|
},
|
|
|
|
|
onGlobalUp() {
|
|
|
|
|
this.onDragEnd()
|
|
|
|
|
this.onResizeEnd()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.fp-root {
|
|
|
|
|
position: fixed;
|
2026-01-03 09:46:47 +08:00
|
|
|
z-index: 2000;
|
2025-12-31 20:05:29 +08:00
|
|
|
background: #ffffff;
|
|
|
|
|
border: 1px solid #dcdfe6;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
2026-01-03 09:46:47 +08:00
|
|
|
/* overflow: hidden; */
|
2025-12-31 20:05:29 +08:00
|
|
|
}
|
|
|
|
|
.fp-header {
|
|
|
|
|
height: 36px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
padding: 0 10px;
|
|
|
|
|
background: linear-gradient(135deg, #f5f7fa, #eef2f7);
|
|
|
|
|
border-bottom: 1px solid #ebeef5;
|
|
|
|
|
cursor: move;
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
.fp-title {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #303133;
|
|
|
|
|
}
|
|
|
|
|
.fp-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
}
|
|
|
|
|
.fp-btn {
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
}
|
|
|
|
|
.fp-body {
|
|
|
|
|
height: calc(100% - 36px);
|
|
|
|
|
padding: 10px;
|
|
|
|
|
overflow: auto;
|
|
|
|
|
}
|
|
|
|
|
.fp-resize {
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: 14px;
|
|
|
|
|
height: 14px;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
cursor: se-resize;
|
|
|
|
|
background: linear-gradient(135deg, transparent 50%, rgba(64, 158, 255, 0.35) 50%);
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|