Files
2026-05-13 16:24:00 +08:00

366 lines
9.7 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { getOS, clamp } from '../utils'
interface Props {
id: string
title?: string
defaultWidth?: number
defaultHeight?: number
minWidth?: number
minHeight?: number
}
const props = withDefaults(defineProps<Props>(), {
title: '',
defaultWidth: 800,
defaultHeight: 600,
minWidth: 400,
minHeight: 300
})
const settingsStore = useSettingsStore()
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const isDragging = ref(false)
const isResizing = ref(false)
const resizeDirection = ref<ResizeDirection | null>(null)
const dragStartPos = ref({ x: 0, y: 0 })
const elementStartPos = ref({ x: 0, y: 0 })
const elementStartSize = ref({ width: 0, height: 0 })
const os = computed(() => getOS())
const isOpen = computed(() => settingsStore.isWindowOpen(props.id))
const windowState = computed(() => settingsStore.getWindowState(props.id))
const position = computed(() => windowState.value?.position ?? { x: 100, y: 100 })
const size = computed(() => windowState.value?.size ?? { width: props.defaultWidth, height: props.defaultHeight })
const zIndex = computed(() => windowState.value?.zIndex ?? 100)
const blurAmount = computed(() => settingsStore.theme.blurAmount ?? 20)
const backgroundColor = computed(() => settingsStore.theme.backgroundColor ?? 'rgba(30, 30, 30, 0.7)')
function close() {
settingsStore.closeWindow(props.id)
}
function bringToFront() {
settingsStore.bringWindowToFront(props.id)
}
function handleDragStart(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.window-close-btn')) return
if ((e.target as HTMLElement).closest('.resize-handle')) return
e.preventDefault()
bringToFront()
isDragging.value = true
dragStartPos.value = { x: e.clientX, y: e.clientY }
elementStartPos.value = { ...position.value }
document.addEventListener('mousemove', handleDragMove)
document.addEventListener('mouseup', handleDragEnd)
}
function handleDragMove(e: MouseEvent) {
if (!isDragging.value) return
const deltaX = e.clientX - dragStartPos.value.x
const deltaY = e.clientY - dragStartPos.value.y
const maxX = window.innerWidth - size.value.width - 20
const maxY = window.innerHeight - size.value.height - 100
const newX = clamp(elementStartPos.value.x + deltaX, 20, maxX)
const newY = clamp(elementStartPos.value.y + deltaY, 20, maxY)
settingsStore.updateWindowPosition(props.id, { x: newX, y: newY })
}
function handleDragEnd() {
isDragging.value = false
document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd)
}
function handleResizeStart(e: MouseEvent, direction: ResizeDirection) {
e.preventDefault()
e.stopPropagation()
bringToFront()
isResizing.value = true
resizeDirection.value = direction
dragStartPos.value = { x: e.clientX, y: e.clientY }
elementStartPos.value = { ...position.value }
elementStartSize.value = { ...size.value }
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing.value || !resizeDirection.value) return
const deltaX = e.clientX - dragStartPos.value.x
const deltaY = e.clientY - dragStartPos.value.y
const dir = resizeDirection.value
let newWidth = elementStartSize.value.width
let newHeight = elementStartSize.value.height
let newX = elementStartPos.value.x
let newY = elementStartPos.value.y
if (dir.includes('e')) {
newWidth = clamp(elementStartSize.value.width + deltaX, props.minWidth, window.innerWidth - position.value.x - 20)
}
if (dir.includes('w')) {
const maxDeltaX = elementStartSize.value.width - props.minWidth
const actualDeltaX = clamp(deltaX, -maxDeltaX, elementStartPos.value.x - 20)
newWidth = elementStartSize.value.width - actualDeltaX
newX = elementStartPos.value.x + actualDeltaX
}
if (dir.includes('s')) {
newHeight = clamp(elementStartSize.value.height + deltaY, props.minHeight, window.innerHeight - position.value.y - 100)
}
if (dir.includes('n')) {
const maxDeltaY = elementStartSize.value.height - props.minHeight
const actualDeltaY = clamp(deltaY, -maxDeltaY, elementStartPos.value.y - 20)
newHeight = elementStartSize.value.height - actualDeltaY
newY = elementStartPos.value.y + actualDeltaY
}
settingsStore.updateWindowPosition(props.id, { x: newX, y: newY })
settingsStore.updateWindowSize(props.id, { width: newWidth, height: newHeight })
}
function handleResizeEnd() {
isResizing.value = false
resizeDirection.value = null
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleDragMove)
document.removeEventListener('mouseup', handleDragEnd)
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)
})
</script>
<template>
<Teleport to="body">
<Transition name="window-fade">
<div
v-if="isOpen"
class="window"
:class="{ 'window-dragging': isDragging }"
:style="{
left: position.x + 'px',
top: position.y + 'px',
width: size.width + 'px',
height: size.height + 'px',
zIndex: zIndex,
background: backgroundColor,
backdropFilter: 'blur(' + blurAmount + 'px)',
WebkitBackdropFilter: 'blur(' + blurAmount + 'px)'
}"
@mousedown="bringToFront"
>
<div class="window-header" @mousedown="handleDragStart">
<div class="window-controls" :class="{ 'controls-left': os === 'mac', 'controls-right': os !== 'mac' }">
<button class="window-close-btn" @click="close" title="关闭">
<svg v-if="os === 'mac'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<span class="window-title">{{ title }}</span>
<div class="window-controls-placeholder"></div>
</div>
<div class="window-content">
<slot></slot>
</div>
<div class="resize-handle resize-n" @mousedown="(e) => handleResizeStart(e, 'n')"></div>
<div class="resize-handle resize-s" @mousedown="(e) => handleResizeStart(e, 's')"></div>
<div class="resize-handle resize-e" @mousedown="(e) => handleResizeStart(e, 'e')"></div>
<div class="resize-handle resize-w" @mousedown="(e) => handleResizeStart(e, 'w')"></div>
<div class="resize-handle resize-ne" @mousedown="(e) => handleResizeStart(e, 'ne')"></div>
<div class="resize-handle resize-nw" @mousedown="(e) => handleResizeStart(e, 'nw')"></div>
<div class="resize-handle resize-se" @mousedown="(e) => handleResizeStart(e, 'se')"></div>
<div class="resize-handle resize-sw" @mousedown="(e) => handleResizeStart(e, 'sw')"></div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.window {
position: fixed;
border-radius: 12px;
background: v-bind('backgroundColor');
backdrop-filter: blur(v-bind('blurAmount + "px"'));
-webkit-backdrop-filter: blur(v-bind('blurAmount + "px"'));
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
display: flex;
flex-direction: column;
}
.window-dragging {
cursor: grabbing;
}
.window-header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
cursor: grab;
user-select: none;
min-height: 20px;
}
.window-controls {
display: flex;
align-items: center;
gap: 8px;
}
.window-controls-left {
order: -1;
margin-right: 12px;
}
.window-controls-right {
order: 1;
margin-left: 12px;
}
.window-close-btn {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff5f57;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease;
}
.window-close-btn:hover {
background: #ff3b30;
}
.window-close-btn svg {
width: 8px;
height: 8px;
color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.2s ease;
}
.window-close-btn:hover svg {
opacity: 1;
}
.window-title {
flex: 1;
text-align: center;
font-size: 14px;
font-weight: 600;
color: white;
}
.window-controls-placeholder {
width: 12px;
}
.window-content {
flex: 1;
overflow: auto;
padding: 0;
}
.resize-handle {
position: absolute;
z-index: 10;
}
.resize-n, .resize-s {
left: 10px;
right: 10px;
height: 6px;
cursor: ns-resize;
}
.resize-n {
top: -3px;
}
.resize-s {
bottom: -3px;
}
.resize-e, .resize-w {
top: 10px;
bottom: 10px;
width: 6px;
cursor: ew-resize;
}
.resize-e {
right: -3px;
}
.resize-w {
left: -3px;
}
.resize-ne, .resize-nw, .resize-se, .resize-sw {
width: 12px;
height: 12px;
}
.resize-ne {
top: -3px;
right: -3px;
cursor: nesw-resize;
}
.resize-nw {
top: -3px;
left: -3px;
cursor: nwse-resize;
}
.resize-se {
bottom: -3px;
right: -3px;
cursor: nwse-resize;
}
.resize-sw {
bottom: -3px;
left: -3px;
cursor: nesw-resize;
}
.window-fade-enter-active,
.window-fade-leave-active {
transition: all 0.3s ease;
}
.window-fade-enter-from,
.window-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>