This commit is contained in:
oneao committed 2025-09-18 17:28:22 +08:00
1 parent 8eedf281fd
commit 35bc29f356
13 files changed
+675 -171

No files matched your search

@@ -1,67 +1,156 @@
@use '../_styles/_variables.scss' as *;
$modal-prefix: '#{$prefix}-modal';
$btn-prefix: '#{$prefix}-btn';
.#{$modal-prefix}-wrapper {
.#{$modal-prefix}-container {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
pointer-events: auto;
outline: none;
width: 100%;
height: 100%;
.#{$modal-prefix}-mask {
position: fixed;
inset: 0;
background: $bg-color-mask;
pointer-events: auto;
width: 100%;
height: 100%;
}
.#{$modal-prefix}-position-top {
align-items: flex-start;
padding-top: 20vh;
}
.#{$modal-prefix}-position-center {
align-items: center;
}
.#{$modal-prefix}-position-bottom {
align-items: flex-end;
padding-bottom: 20vh;
}
.#{$modal-prefix}-wrapper {
position: relative;
overflow: auto;
display: flex;
justify-content: center;
width: 100%;
height: 100%;
.#{$modal-prefix} {
background: #fff;
border-radius: 4px;
overflow: hidden;
font-size: 14px;
margin: 0;
width: 480px;
height: 300px;
display: flex;
flex-direction: column;
&-header {
display: flex;
flex-shrink: 0;
align-items: center;
width: 100%;
font-size: 16px;
padding: 12px 16px;
border-bottom: 1px solid $separator-color;
word-break: break-all;
}
&-title {
display: flex;
align-items: center;
line-height: 1;
}
&-icon {
font-size: 24px;
margin-right: 6px;
&-info {
color: $primary-color;
}
&-success {
color: $success-color;
}
&-warning {
color: $warning-color;
}
&-danger {
color: $danger-color;
}
&-question {
color: $primary-color;
}
}
&-close {
flex-shrink: 0;
margin-left: auto;
color: $text-color-assist;
cursor: pointer;
border-radius: $border-radius;
width: 20px;
height: 20px;
&:hover {
background-color: $default-color-hover;
}
}
&-body {
flex: 1 1 auto;
padding: 12px 16px;
overflow-y: auto;
}
&-footer {
padding: 12px 16px;
flex-shrink: 0;
border-top: 1px solid $separator-color;
text-align: right;
.#{$btn-prefix}:not(:first-child) {
margin-left: 12px;
}
}
}
.#{$modal-prefix}-fullscreen {
width: 100%;
height: 100%;
}
}
}
/* 内容动画 - 修复版 */
.#{$modal-prefix}-content-fade-enter-active,
.#{$modal-prefix}-content-fade-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.#{$modal-prefix}-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
.#{$modal-prefix}-content-fade-enter-from,
.#{$modal-prefix}-content-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
.#{$modal-prefix} {
position: relative;
z-index: 1001;
background: #fff;
border-radius: 8px;
min-width: 300px;
max-width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
.#{$modal-prefix}-content-fade-enter-to,
.#{$modal-prefix}-content-fade-leave-from {
opacity: 1;
transform: scale(1);
}
/* 遮罩动画 - 修复版 */
.#{$modal-prefix}-mask-fade-enter-active,
.#{$modal-prefix}-mask-fade-leave-active {
transition: opacity 0.3s ease;
}
.#{$modal-prefix}-header {
padding: 12px 16px;
font-weight: 600;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.#{$modal-prefix}-close {
border: none;
background: transparent;
font-size: 18px;
cursor: pointer;
}
.#{$modal-prefix}-body {
padding: 16px;
}
.#{$modal-prefix}-footer {
padding: 12px 16px;
border-top: 1px solid #eee;
text-align: right;
}
.#{$modal-prefix}-fade-enter-active,
.#{$modal-prefix}-fade-leave-active {
transition: opacity 0.2s ease;
}
.#{$modal-prefix}-fade-enter-from,
.#{$modal-prefix}-fade-leave-to {
.#{$modal-prefix}-mask-fade-enter-from,
.#{$modal-prefix}-mask-fade-leave-to {
opacity: 0;
}
@@ -1,85 +1,226 @@
import { defineComponent, ref, Teleport, Transition, watch } from 'vue'
import type { PropType } from 'vue'
import { computed, defineComponent, onMounted, ref, Teleport, Transition, watch } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import { addModal, getMaskZIndex, removeModal } from './modalManager'
import Button from '../button/index'
import Icon from '../icon/index'
export default defineComponent({
name: 'Modal',
props: {
visible: { type: Boolean, default: false },
mask: { type: Boolean, default: true },
maskClosable: { type: Boolean, default: true },
class: { type: [String, Array, Object], default: '' },
style: { type: Object, default: () => ({}) },
lazy: { type: Boolean, default: true }, // 是否懒加载
visible: {
type: Boolean,
default: undefined,
},
defaultVisible: {
type: Boolean,
default: false,
},
mask: {
type: Boolean,
default: true,
},
maskClosable: {
type: Boolean,
default: true,
},
position: {
type: String as PropType<'top' | 'center' | 'bottom'>,
default: 'center',
},
type: {
type: String as PropType<'default' | 'info' | 'success' | 'warning' | 'danger' | 'question'>,
default: 'default',
},
showClose: {
type: Boolean,
default: true,
},
confirmBtn: {
type: null,
default: () => ({ label: '确认', type: 'solid', status: 'primary', size: 'small' }),
},
cancelBtn: {
type: null,
default: () => ({ label: '取消', type: 'solid', status: 'default', size: 'small' }),
},
onBeforeConfirm: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
onBeforeCancel: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
destroyOnClose: {
type: Boolean,
default: false,
},
appendTo: {
type: [String, Object] as PropType<string | HTMLElement>,
default: 'body',
},
fullscreen: {
type: Boolean,
default: false,
},
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
emits: ['update:visible', 'close'],
emits: ['update:visible', 'confirm', 'cancel', 'open', 'close', 'beforeOpen', 'beforeClose'],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('modal')
const zIndex = ref(0)
const isMounted = ref(false) // DOM 是否挂载
const targetEl = ref<HTMLElement>(document.body)
const confirmLoading = ref(false)
// 打开 Modal
const openModal = () => {
zIndex.value = addModal()
isMounted.value = true
}
const _visible = ref(props.defaultVisible)
// 关闭 Modal
const closeModal = () => {
removeModal(zIndex.value)
zIndex.value = 0
// 不卸载 DOM,仍保持 isMounted = true
}
const isVisible = computed(() => props.visible ?? _visible.value)
watch(
() => props.visible,
(val) => {
if (val)
openModal()
else closeModal()
},
{ immediate: true },
)
const isMounted = ref(isVisible.value)
const isRootVisible = ref(isVisible.value)
watch(isVisible, (value) => {
if (_visible.value !== value) {
_visible.value = value
}
if (value) {
emit('beforeOpen')
isMounted.value = true
isRootVisible.value = true
}
else {
emit('beforeClose')
}
})
onMounted(() => {
if (props.appendTo instanceof HTMLElement) {
targetEl.value = props.appendTo
}
else if (typeof props.appendTo === 'string') {
const el = document.querySelector(props.appendTo)
if (el)
targetEl.value = el as HTMLElement
}
})
const handleMaskClick = () => {
if (props.maskClosable) {
emit('update:visible', false)
emit('close')
}
}
const handleClose = () => {
emit('update:visible', false)
const handleCancel = async () => {
let allow = true
if (props.onBeforeCancel) {
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeCancel!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch {
allow = false
}
}
if (allow) {
emit('update:visible', false)
emit('cancel')
confirmLoading.value = false
}
}
const handleConfirm = async () => {
let allow = true
if (props.onBeforeConfirm) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeConfirm!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch { allow = false }
}
if (allow) {
emit('update:visible', false)
emit('confirm')
}
confirmLoading.value = false
}
const handleAfterEnter = () => {
emit('open')
}
const handleAfterLeave = () => {
isRootVisible.value = false
if (props.destroyOnClose) {
isMounted.value = false
}
emit('close')
}
return () => (
<Teleport to="body">
{/* DOM 永远挂载,只用 v-show 控制显示 */}
<div
class={`${prefixCls}-wrapper`}
style={{
zIndex: zIndex.value,
display: props.visible ? 'flex' : 'none',
}}
>
{props.mask && (
<div
class={`${prefixCls}-mask`}
style={{ zIndex: getMaskZIndex() }}
onClick={handleMaskClick}
/>
)}
<div class={[prefixCls, props.class]} style={{ ...props.style, zIndex: zIndex.value }}>
<div class={`${prefixCls}-header`}>
<span class={`${prefixCls}-title`}>{slots.title?.()}</span>
<button class={`${prefixCls}-close`} onClick={handleClose}>×</button>
const typeIconMap: Record<string, string> = {
success: 'ix:success-filled',
danger: 'ix:error-filled',
warning: 'ix:warning-filled',
info: 'ix:info-filled',
question: 'ix:question-filled',
}
return () => {
return (
<Teleport to={targetEl.value}>
{isMounted.value && (
<div v-show={isRootVisible.value} class={`${prefixCls}-container`} style={{ position: 'fixed', zIndex: 1000 }}>
{props.mask && (
<Transition name={`${prefixCls}-mask-fade`} appear>
<div v-show={isVisible.value} class={`${prefixCls}-mask`} onClick={handleMaskClick} />
</Transition>
)}
<Transition
name={`${prefixCls}-content-fade`}
appear
onAfterEnter={handleAfterEnter}
onAfterLeave={handleAfterLeave}
>
<div v-show={isVisible.value} class={[`${prefixCls}-wrapper`, `${prefixCls}-position-${props.position}`, props.class]} style={{ ...props.style }}>
<div class={`${prefixCls} ${props.fullscreen ? `${prefixCls}-fullscreen` : ''}`}>
<div class={`${prefixCls}-header`}>
{props.type !== 'default' && <Icon class={[`${prefixCls}-icon`, `${prefixCls}-icon-${props.type}`]} icon={typeIconMap[props.type]} />}
<span class={`${prefixCls}-title`}>{slots.title?.()}</span>
{props.showClose && <Icon class={`${prefixCls}-close`} icon="ix:close" />}
</div>
<div class={`${prefixCls}-body`}>{slots.default?.()}</div>
<div class={`${prefixCls}-footer`}>
{slots.footer?.() ?? (
<>
<Button {...props.cancelBtn} onClick={handleCancel}>{props.cancelBtn.label}</Button>
<Button loading={confirmLoading.value} {...props.confirmBtn} onClick={handleConfirm}>{props.confirmBtn.label}</Button>
</>
)}
</div>
</div>
</div>
</Transition>
</div>
<div class={`${prefixCls}-body`}>{slots.default?.()}</div>
{slots.footer && <div class={`${prefixCls}-footer`}>{slots.footer()}</div>}
</div>
</div>
</Teleport>
)
)}
</Teleport>
)
}
},
})
@@ -1,15 +1,8 @@
let zIndexBase = 1000
const modalStack: number[] = [] // 当前打开的 Modal zIndex
const freeZIndex: number[] = [] // 已关闭 Modal 可复用 zIndex
export function addModal() {
let zIndex: number
if (freeZIndex.length > 0) {
zIndex = freeZIndex.pop()!
}
else {
zIndex = ++zIndexBase
}
const zIndex = ++zIndexBase // 每次直接递增
modalStack.push(zIndex)
return zIndex
}
@@ -17,8 +10,7 @@ export function addModal() {
export function removeModal(zIndex: number) {
const idx = modalStack.indexOf(zIndex)
if (idx !== -1) {
modalStack.splice(idx, 1)
freeZIndex.push(zIndex) // 回收 zIndex
modalStack.splice(idx, 1) // 只移出 stack,不回收 zIndex
}
}
@@ -0,0 +1,224 @@
import type { PropType } from 'vue'
import { computed, defineComponent, onMounted, ref, Teleport, Transition, watch } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Button from '../button/index'
import Icon from '../icon/index'
export default defineComponent({
name: 'Modal',
props: {
visible: {
type: Boolean,
default: undefined,
},
defaultVisible: {
type: Boolean,
default: false,
},
mask: {
type: Boolean,
default: true,
},
maskClosable: {
type: Boolean,
default: true,
},
position: {
type: String as PropType<'top' | 'center' | 'bottom'>,
default: 'center',
},
type: {
type: String as PropType<'default' | 'info' | 'success' | 'warning' | 'danger' | 'question'>,
default: 'default',
},
showClose: {
type: Boolean,
default: true,
},
confirmBtn: {
type: null,
default: () => ({ label: '确认', type: 'solid', status: 'primary', size: 'small' }),
},
cancelBtn: {
type: null,
default: () => ({ label: '取消', type: 'solid', status: 'default', size: 'small' }),
},
onBeforeConfirm: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
onBeforeCancel: {
type: Function as PropType<(done: (ok: boolean) => void) => void | boolean | Promise<boolean>>,
},
destroyOnClose: {
type: Boolean,
default: false,
},
appendTo: {
type: [String, Object] as PropType<string | HTMLElement>,
default: 'body',
},
class: {
type: [String, Array, Object],
default: '',
},
style: {
type: Object,
default: () => ({}),
},
},
emits: ['update:visible', 'confirm', 'cancel', 'open', 'close', 'beforeOpen', 'beforeClose'],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('modal')
const targetEl = ref<HTMLElement>(document.body)
const confirmLoading = ref(false)
const _visible = ref(props.defaultVisible)
const _rendered = ref(props.defaultVisible)
const _mounted = ref(props.defaultVisible)
watch(() => props.visible, (val) => {
if (val !== undefined) {
_visible.value = val
if (val) {
_mounted.value = true
_rendered.value = true
}
}
}, { immediate: true })
onMounted(() => {
if (props.appendTo instanceof HTMLElement) {
targetEl.value = props.appendTo
}
else if (typeof props.appendTo === 'string') {
const el = document.querySelector(props.appendTo)
if (el)
targetEl.value = el as HTMLElement
}
})
const handleMaskClick = () => {
if (props.maskClosable) {
emit('update:visible', false)
}
}
const handleCancel = async () => {
let allow = true
if (props.onBeforeCancel) {
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeCancel!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch {
allow = false
}
}
if (allow) {
emit('update:visible', false)
emit('cancel')
confirmLoading.value = false
}
}
const handleConfirm = async () => {
let allow = true
if (props.onBeforeConfirm) {
confirmLoading.value = true
try {
allow = await new Promise<boolean>((resolve) => {
const result = props.onBeforeConfirm!(ok => resolve(ok))
if (typeof result === 'boolean')
resolve(result)
else if (result instanceof Promise)
result.then(resolve).catch(() => resolve(false))
})
}
catch { allow = false }
}
if (allow) {
emit('update:visible', false)
emit('confirm')
}
confirmLoading.value = false
}
const handleBeforeEnter = () => {
emit('beforeOpen')
}
const handleAfterEnter = () => {
emit('open')
}
const handleBeforeLeave = () => {
emit('beforeClose')
}
const handleAfterLeave = () => {
_mounted.value = false
emit('close')
}
const typeIconMap: Record<string, string> = {
success: 'ix:success-filled',
danger: 'ix:error-filled',
warning: 'ix:warning-filled',
info: 'ix:info-filled',
question: 'ix:question-filled',
}
return () => {
const shouldRenderPopup = props.destroyOnClose
? _visible.value
: _visible.value || _rendered.value
return (
<Teleport to={targetEl.value}>
{shouldRenderPopup && (
<div v-show={_mounted.value} class={`${prefixCls}-container`} style={{ position: 'fixed', zIndex: 1000 }}>
{props.mask && (
<Transition name={`${prefixCls}-mask-fade`} appear>
<div v-show={_visible.value} class={`${prefixCls}-mask`} onClick={handleMaskClick} />
</Transition>
)}
<Transition
name={`${prefixCls}-content-fade`}
appear
onBeforeEnter={handleBeforeEnter}
onAfterEnter={handleAfterEnter}
onBeforeLeave={handleBeforeLeave}
onAfterLeave={handleAfterLeave}
>
<div v-show={_visible.value} class={[`${prefixCls}-wrapper`, `${prefixCls}-position-${props.position}`, props.class]} style={{ ...props.style }}>
<div class={`${prefixCls}`}>
<div class={`${prefixCls}-header`}>
{props.type !== 'default' && <Icon class={[`${prefixCls}-icon`, `${prefixCls}-icon-${props.type}`]} icon={typeIconMap[props.type]} />}
<span class={`${prefixCls}-title`}>{slots.title?.()}</span>
{props.showClose && <Icon class={`${prefixCls}-close`} icon="ix:close" />}
</div>
<div class={`${prefixCls}-body`}>{slots.default?.()}</div>
<div class={`${prefixCls}-footer`}>
{slots.footer?.() ?? (
<>
<Button {...props.cancelBtn} onClick={handleCancel}>{props.cancelBtn.label}</Button>
<Button loading={confirmLoading.value} {...props.confirmBtn} onClick={handleConfirm}>{props.confirmBtn.label}</Button>
</>
)}
</div>
</div>
</div>
</Transition>
</div>
)}
</Teleport>
)
}
},
})