20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
1 parent b6f9a4c912
commit 72993ecdcd
179 files changed
+20050 -633

No files matched your search

@@ -0,0 +1,274 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { Maximize, Minimize, X } from '@lucide/vue'
import Button from '../button/button.vue'
import { focusFirst, trapFocus } from '../utils/focus.js'
import { lockScroll, unlockScroll } from '../utils/scroll.js'
import { bindDrag, bindResize } from '../utils/drag.js'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
/** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
open: { type: Boolean, default: undefined },
/** 非受控时的初始打开状态 */
defaultOpen: { type: Boolean, default: false },
/** 标题文字 */
title: { type: String, default: '' },
/** 面板宽度(px 数字或任意 CSS 宽度字符串) */
width: { type: [Number, String], default: 520 },
/** 垂直居中显示 */
centered: { type: Boolean, default: false },
/** 是否显示遮罩 */
mask: { type: Boolean, default: true },
/** 点击遮罩关闭 */
maskClosable: { type: Boolean, default: true },
/** Esc 关闭 */
keyboard: { type: Boolean, default: true },
/** 显示右上角关闭按钮 */
closable: { type: Boolean, default: true },
/** 显示右上角全屏切换按钮 */
fullscreen: { type: Boolean, default: true },
/** 允许拖动右下角手柄调整宽高 */
resizable: { type: Boolean, default: true },
/** 显示底部按钮区(确定/取消);配合 #footer 插槽自定义,传 false 隐藏 */
footer: { type: Boolean, default: true },
/** 确定按钮文字 */
okText: { type: String, default: '确定' },
/** 取消按钮文字 */
cancelText: { type: String, default: '取消' },
/** 确定按钮加载态;加载期间阻止一切关闭操作 */
confirmLoading: { type: Boolean, default: false },
})
const emit = defineEmits(['update:open', 'ok', 'cancel'])
/* ---------- 公共状态 ---------- */
const innerOpen = ref(props.defaultOpen)
const panelRef = ref(null)
const headerRef = ref(null)
// 标题/描述与面板的关联 id,useId 保证多实例并存时不冲突
const titleId = useId()
// 打开前的活动元素,关闭后还原焦点
let triggerEl = null
// 拖拽句柄 { reset, destroy },面板打开期间存在
let dragHandle = null
// 缩放句柄数组 { destroy },面板打开期间存在
let resizeHandles = []
// 全屏状态;关闭后重置,下次打开不继承
const isFullscreen = ref(false)
// 缩放后的尺寸(覆盖 width prop);关闭后重置
const resizeSize = ref(null)
const resizeERef = ref(null)
const resizeWRef = ref(null)
const resizeSRef = ref(null)
const resizeNRef = ref(null)
const isOpen = computed({
get: () => (props.open !== undefined ? props.open : innerOpen.value),
set: (value) => {
if (props.open === undefined) innerOpen.value = value
emit('update:open', value)
},
})
const panelWidth = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
)
// 面板尺寸:缩放后使用实际宽高(px),否则用 width prop
const panelStyle = computed(() => {
if (resizeSize.value) {
return {
width: `${resizeSize.value.width}px`,
height: `${resizeSize.value.height}px`,
}
}
return { width: panelWidth.value }
})
/* ---------- 公共方法 ---------- */
// 所有关闭入口(遮罩/Esc/关闭按钮/取消按钮)统一走这里;
// confirmLoading 期间不响应,避免异步提交流程被打断
function requestClose() {
if (props.confirmLoading) return
emit('cancel')
setOpen(false)
}
function setOpen(value) {
isOpen.value = value
}
function onOkClick() {
emit('ok')
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// 遮罩点击关闭:wrap 铺满视口且层级高于 mask,事件实际落在 wrap 上;
// 只响应点击 wrap 自身(面板外空白/遮罩区域),点击面板内部不关闭
function onWrapClick(event) {
if (event.target === event.currentTarget && props.maskClosable) {
requestClose()
}
}
/* ---------- 键盘与焦点 ---------- */
function onPanelKeydown(event) {
if (props.keyboard && event.key === 'Escape') {
requestClose()
return
}
// Tab 在面板内循环;焦点被陷阱约束在面板中,Esc 与 Tab 必然到达此监听
trapFocus(event, panelRef.value)
}
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ---------- */
watch(isOpen, (value) => {
if (value) {
// 记录触发元素,关闭后还原焦点
triggerEl = document.activeElement
lockScroll()
nextTick(() => {
if (!isOpen.value || !panelRef.value) return
focusFirst(panelRef.value)
panelRef.value.addEventListener('keydown', onPanelKeydown)
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
dragHandle = bindDrag(panelRef.value, headerRef.value)
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
if (props.resizable) {
resizeHandles = [
{ el: resizeERef.value, direction: 'e' },
{ el: resizeWRef.value, direction: 'w' },
{ el: resizeSRef.value, direction: 's' },
{ el: resizeNRef.value, direction: 'n' },
]
.filter((item) => item.el)
.map(({ el, direction }) =>
bindResize(panelRef.value, el, {
direction,
maxWidth: window.innerWidth - 32,
maxHeight: window.innerHeight - 124,
onResize: (size) => {
resizeSize.value = size
},
}),
)
}
})
} else {
unlockScroll()
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
if (dragHandle) {
dragHandle.destroy()
dragHandle = null
}
resizeHandles.forEach((h) => h.destroy())
resizeHandles = []
// 重置全屏与缩放状态,下次打开不继承
isFullscreen.value = false
resizeSize.value = null
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
triggerEl = null
}
})
// 全屏与拖拽互斥:进入全屏时停用拖拽并复位位置,退出后重新启用
watch(isFullscreen, (value) => {
if (!isOpen.value) return
if (value) {
if (dragHandle) {
dragHandle.reset()
dragHandle.destroy()
dragHandle = null
}
} else if (panelRef.value && headerRef.value) {
dragHandle = bindDrag(panelRef.value, headerRef.value)
}
})
onBeforeUnmount(() => {
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
if (dragHandle) dragHandle.destroy()
resizeHandles.forEach((h) => h.destroy())
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<Transition name="fms-modal-mask">
<div v-if="isOpen && mask" class="fms-modal-mask" aria-hidden="true"></div>
</Transition>
<Transition name="fms-modal">
<div
v-if="isOpen"
class="fms-modal-wrap"
:class="{ 'fms-modal-centered': centered }"
@click="onWrapClick"
>
<div
ref="panelRef"
class="fms-modal"
:class="{ 'fms-modal-fullscreen': isFullscreen }"
role="dialog"
aria-modal="true"
:aria-label="title ? undefined : '对话框'"
:aria-labelledby="title ? titleId : undefined"
tabindex="-1"
:style="panelStyle"
>
<div ref="headerRef" class="fms-modal-header">
<p v-if="title" :id="titleId" class="fms-modal-title">{{ title }}</p>
<div class="fms-modal-header-actions">
<button
v-if="fullscreen"
type="button"
class="fms-modal-header-btn"
:aria-label="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
<button
v-if="closable"
type="button"
class="fms-modal-header-btn"
aria-label="关闭"
@click="requestClose"
>
<X :size="14" />
</button>
</div>
</div>
<div class="fms-modal-body">
<slot />
</div>
<div v-if="footer" class="fms-modal-footer">
<slot name="footer">
<Button type="outline" :disabled="confirmLoading" @click="requestClose">
{{ cancelText }}
</Button>
<Button :loading="confirmLoading" @click="onOkClick">{{ okText }}</Button>
</slot>
</div>
<!-- 四边缩放手柄:拖动调整面板宽高,全屏时不可用 -->
<template v-if="resizable && !isFullscreen">
<div ref="resizeERef" class="fms-modal-resizer fms-modal-resizer-e" aria-hidden="true"></div>
<div ref="resizeWRef" class="fms-modal-resizer fms-modal-resizer-w" aria-hidden="true"></div>
<div ref="resizeSRef" class="fms-modal-resizer fms-modal-resizer-s" aria-hidden="true"></div>
<div ref="resizeNRef" class="fms-modal-resizer fms-modal-resizer-n" aria-hidden="true"></div>
</template>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>