506 lines
11 KiB
Vue
506 lines
11 KiB
Vue
<template>
|
||
<div
|
||
class="floating-window"
|
||
:style="windowStyle"
|
||
:data-window-id="window.id"
|
||
@click="handleWindowActivate"
|
||
>
|
||
<!-- 标题栏 -->
|
||
<div
|
||
class="floating-window-titlebar"
|
||
:style="titleBarStyle"
|
||
@mousedown="handleTitleBarMouseDown"
|
||
>
|
||
<span v-if="window.icon" class="window-icon">{{ window.icon }}</span>
|
||
<span class="window-title">{{ window.title }}</span>
|
||
|
||
<!-- 窗口操作按钮 -->
|
||
<div class="window-controls">
|
||
<button
|
||
class="window-btn window-btn-dock"
|
||
@click.stop="handleDock"
|
||
title="停靠窗口"
|
||
>
|
||
📌
|
||
</button>
|
||
<button
|
||
class="window-btn window-btn-minimize"
|
||
@click.stop="handleMinimize"
|
||
title="最小化窗口"
|
||
>
|
||
━
|
||
</button>
|
||
<button
|
||
class="window-btn window-btn-maximize"
|
||
@click.stop="handleMaximize"
|
||
:title="window.maximized ? '还原窗口' : '最大化窗口'"
|
||
>
|
||
{{ window.maximized ? '□' : '◻' }}
|
||
</button>
|
||
<button
|
||
class="window-btn window-btn-close"
|
||
@click.stop="handleClose"
|
||
title="关闭窗口"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 内容区 -->
|
||
<div
|
||
class="floating-window-content"
|
||
:style="contentStyle"
|
||
>
|
||
<!-- 渲染窗口内容 -->
|
||
<template v-if="window.component">
|
||
<component
|
||
:is="window.component"
|
||
v-bind="window.props || {}"
|
||
:style="{ height: '100%', width: '100%', overflow: 'auto' }"
|
||
/>
|
||
</template>
|
||
<template v-else-if="window.content">
|
||
<div class="window-default-content">
|
||
{{ window.content }}
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="window-empty-content">
|
||
无内容
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 调整大小手柄 -->
|
||
<template v-if="!window.maximized">
|
||
<!-- 左上 -->
|
||
<div
|
||
class="resize-handle resize-tl"
|
||
@mousedown="startResize({ top: true, left: true }, $event)"
|
||
/>
|
||
<!-- 右上 -->
|
||
<div
|
||
class="resize-handle resize-tr"
|
||
@mousedown="startResize({ top: true, right: true }, $event)"
|
||
/>
|
||
<!-- 左下 -->
|
||
<div
|
||
class="resize-handle resize-bl"
|
||
@mousedown="startResize({ bottom: true, left: true }, $event)"
|
||
/>
|
||
<!-- 右下 -->
|
||
<div
|
||
class="resize-handle resize-br"
|
||
@mousedown="startResize({ bottom: true, right: true }, $event)"
|
||
/>
|
||
<!-- 上 -->
|
||
<div
|
||
class="resize-handle resize-t"
|
||
@mousedown="startResize({ top: true }, $event)"
|
||
/>
|
||
<!-- 下 -->
|
||
<div
|
||
class="resize-handle resize-b"
|
||
@mousedown="startResize({ bottom: true }, $event)"
|
||
/>
|
||
<!-- 左 -->
|
||
<div
|
||
class="resize-handle resize-l"
|
||
@mousedown="startResize({ left: true }, $event)"
|
||
/>
|
||
<!-- 右 -->
|
||
<div
|
||
class="resize-handle resize-r"
|
||
@mousedown="startResize({ right: true }, $event)"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||
|
||
// 定义组件属性
|
||
const props = defineProps({
|
||
// 窗口数据
|
||
window: {
|
||
type: Object,
|
||
required: true
|
||
}
|
||
});
|
||
|
||
// 定义事件
|
||
const emit = defineEmits([
|
||
'close', // 关闭窗口
|
||
'dock', // 停靠窗口
|
||
'move', // 移动窗口
|
||
'minimize', // 最小化窗口
|
||
'maximize' // 最大化窗口
|
||
]);
|
||
|
||
// 窗口位置和尺寸状态
|
||
const position = ref({
|
||
x: props.window.x || 100,
|
||
y: props.window.y || 100
|
||
});
|
||
|
||
const size = ref({
|
||
width: props.window.width || 400,
|
||
height: props.window.height || 300
|
||
});
|
||
|
||
// 窗口状态
|
||
const isDragging = ref(false);
|
||
const dragStartPos = ref({ x: 0, y: 0 });
|
||
const isResizing = ref(false);
|
||
const resizeStartPos = ref({ x: 0, y: 0 });
|
||
const resizeEdges = ref({});
|
||
|
||
// 计算窗口样式
|
||
const windowStyle = computed(() => {
|
||
const style = {
|
||
position: 'fixed',
|
||
top: `${position.value.y}px`,
|
||
left: `${position.value.x}px`,
|
||
width: `${size.value.width}px`,
|
||
height: `${size.value.height}px`,
|
||
backgroundColor: 'white',
|
||
border: '1px solid #e5e7eb',
|
||
borderRadius: '4px',
|
||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||
zIndex: props.window.zIndex || 50,
|
||
display: props.window.minimized ? 'none' : 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
transition: 'box-shadow 0.2s ease'
|
||
};
|
||
|
||
// 最大化状态样式
|
||
if (props.window.maximized) {
|
||
style.top = '0px';
|
||
style.left = '0px';
|
||
style.width = '100vw';
|
||
style.height = '100vh';
|
||
style.borderRadius = '0px';
|
||
style.zIndex = 100;
|
||
}
|
||
|
||
return style;
|
||
});
|
||
|
||
// 计算标题栏样式
|
||
const titleBarStyle = computed(() => {
|
||
return {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: '0 12px',
|
||
height: '36px',
|
||
backgroundColor: '#f3f4f6',
|
||
borderBottom: '1px solid #e5e7eb',
|
||
cursor: 'move',
|
||
userSelect: 'none',
|
||
fontSize: '13px',
|
||
fontWeight: '500'
|
||
};
|
||
});
|
||
|
||
// 计算内容区样式
|
||
const contentStyle = computed(() => {
|
||
return {
|
||
flex: 1,
|
||
overflow: 'hidden',
|
||
position: 'relative'
|
||
};
|
||
});
|
||
|
||
// 处理标题栏鼠标按下事件
|
||
const handleTitleBarMouseDown = (event) => {
|
||
if (event.target.tagName.toLowerCase() === 'button') {
|
||
return; // 如果点击的是按钮,不触发拖拽
|
||
}
|
||
|
||
isDragging.value = true;
|
||
dragStartPos.value = {
|
||
x: event.clientX - position.value.x,
|
||
y: event.clientY - position.value.y
|
||
};
|
||
|
||
// 提升窗口层级
|
||
if (!props.window.maximized) {
|
||
props.window.zIndex = Math.max(...Array.from(document.querySelectorAll('.floating-window')).map(el => parseInt(el.style.zIndex) || 50)) + 1;
|
||
}
|
||
|
||
// 阻止默认行为
|
||
event.preventDefault();
|
||
};
|
||
|
||
// 处理窗口关闭
|
||
const handleClose = () => {
|
||
emit('close', props.window.id);
|
||
};
|
||
|
||
// 处理窗口最小化
|
||
const handleMinimize = () => {
|
||
emit('minimize', props.window.id);
|
||
};
|
||
|
||
// 处理窗口最大化/还原
|
||
const handleMaximize = () => {
|
||
emit('maximize', props.window.id);
|
||
};
|
||
|
||
// 处理窗口停靠
|
||
const handleDock = () => {
|
||
emit('dock', props.window.id);
|
||
};
|
||
|
||
// 开始调整大小
|
||
const startResize = (edges, event) => {
|
||
isResizing.value = true;
|
||
resizeStartPos.value = {
|
||
x: event.clientX,
|
||
y: event.clientY
|
||
};
|
||
resizeEdges.value = edges;
|
||
|
||
// 阻止默认行为
|
||
event.preventDefault();
|
||
};
|
||
|
||
// 处理鼠标移动
|
||
const handleMouseMove = (event) => {
|
||
if (isDragging.value) {
|
||
// 处理窗口移动
|
||
const newX = event.clientX - dragStartPos.value.x;
|
||
const newY = event.clientY - dragStartPos.value.y;
|
||
|
||
position.value = {
|
||
x: newX,
|
||
y: newY
|
||
};
|
||
|
||
emit('move', props.window.id, newX, newY);
|
||
} else if (isResizing.value) {
|
||
// 处理窗口调整大小
|
||
const deltaX = event.clientX - resizeStartPos.value.x;
|
||
const deltaY = event.clientY - resizeStartPos.value.y;
|
||
|
||
let newWidth = size.value.width;
|
||
let newHeight = size.value.height;
|
||
let newX = position.value.x;
|
||
let newY = position.value.y;
|
||
|
||
// 根据边缘调整尺寸和位置
|
||
if (resizeEdges.value.left) {
|
||
newWidth = Math.max(200, size.value.width - deltaX);
|
||
newX = position.value.x + deltaX;
|
||
}
|
||
if (resizeEdges.value.right) {
|
||
newWidth = Math.max(200, size.value.width + deltaX);
|
||
}
|
||
if (resizeEdges.value.top) {
|
||
newHeight = Math.max(150, size.value.height - deltaY);
|
||
newY = position.value.y + deltaY;
|
||
}
|
||
if (resizeEdges.value.bottom) {
|
||
newHeight = Math.max(150, size.value.height + deltaY);
|
||
}
|
||
|
||
// 更新窗口状态
|
||
size.value = {
|
||
width: newWidth,
|
||
height: newHeight
|
||
};
|
||
|
||
position.value = {
|
||
x: newX,
|
||
y: newY
|
||
};
|
||
|
||
// 通知父组件
|
||
emit('move', props.window.id, newX, newY);
|
||
|
||
// 更新拖拽起点
|
||
resizeStartPos.value = {
|
||
x: event.clientX,
|
||
y: event.clientY
|
||
};
|
||
}
|
||
};
|
||
|
||
// 处理鼠标松开
|
||
const handleMouseUp = () => {
|
||
isDragging.value = false;
|
||
isResizing.value = false;
|
||
};
|
||
|
||
// 处理窗口激活
|
||
const handleWindowActivate = () => {
|
||
// 提升窗口层级
|
||
if (!props.window.maximized) {
|
||
props.window.zIndex = Math.max(...Array.from(document.querySelectorAll('.floating-window')).map(el => parseInt(el.style.zIndex) || 50)) + 1;
|
||
}
|
||
};
|
||
|
||
// 生命周期钩子
|
||
onMounted(() => {
|
||
// 同步外部窗口状态
|
||
if (props.window.x !== undefined) position.value.x = props.window.x;
|
||
if (props.window.y !== undefined) position.value.y = props.window.y;
|
||
if (props.window.width !== undefined) size.value.width = props.window.width;
|
||
if (props.window.height !== undefined) size.value.height = props.window.height;
|
||
|
||
// 添加全局事件监听
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// 清理全局事件监听
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 窗口图标样式 */
|
||
.window-icon {
|
||
margin-right: 8px;
|
||
font-size: 14px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 窗口标题样式 */
|
||
.window-title {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 窗口控制按钮组样式 */
|
||
.window-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 窗口按钮通用样式 */
|
||
.window-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 4px 6px;
|
||
border-radius: 2px;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.window-btn:hover {
|
||
background-color: #e5e7eb;
|
||
color: #111827;
|
||
}
|
||
|
||
/* 关闭按钮特殊样式 */
|
||
.window-btn-close:hover {
|
||
background-color: #ef4444 !important;
|
||
color: white !important;
|
||
}
|
||
|
||
/* 默认内容样式 */
|
||
.window-default-content {
|
||
padding: 16px;
|
||
height: 100%;
|
||
width: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* 空内容样式 */
|
||
.window-empty-content {
|
||
padding: 16px;
|
||
height: 100%;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 调整大小手柄样式 */
|
||
.resize-handle {
|
||
position: absolute;
|
||
background-color: transparent;
|
||
z-index: 10;
|
||
}
|
||
|
||
/* 四角手柄 */
|
||
.resize-tl {
|
||
top: -4px;
|
||
left: -4px;
|
||
width: 8px;
|
||
height: 8px;
|
||
cursor: nwse-resize;
|
||
}
|
||
|
||
.resize-tr {
|
||
top: -4px;
|
||
right: -4px;
|
||
width: 8px;
|
||
height: 8px;
|
||
cursor: nesw-resize;
|
||
}
|
||
|
||
.resize-bl {
|
||
bottom: -4px;
|
||
left: -4px;
|
||
width: 8px;
|
||
height: 8px;
|
||
cursor: nesw-resize;
|
||
}
|
||
|
||
.resize-br {
|
||
bottom: -4px;
|
||
right: -4px;
|
||
width: 8px;
|
||
height: 8px;
|
||
cursor: nwse-resize;
|
||
}
|
||
|
||
/* 四边手柄 */
|
||
.resize-t {
|
||
top: -4px;
|
||
left: 8px;
|
||
right: 8px;
|
||
height: 8px;
|
||
cursor: ns-resize;
|
||
}
|
||
|
||
.resize-b {
|
||
bottom: -4px;
|
||
left: 8px;
|
||
right: 8px;
|
||
height: 8px;
|
||
cursor: ns-resize;
|
||
}
|
||
|
||
.resize-l {
|
||
left: -4px;
|
||
top: 8px;
|
||
bottom: 8px;
|
||
width: 8px;
|
||
cursor: ew-resize;
|
||
}
|
||
|
||
.resize-r {
|
||
right: -4px;
|
||
top: 8px;
|
||
bottom: 8px;
|
||
width: 8px;
|
||
cursor: ew-resize;
|
||
}
|
||
</style> |