Browse Source
* feat: add modal component * feat: add drawer component * feat: apply new modal and drawer components to the layout * chore: typo * feat: add some unit testspull/4231/head
committed by
GitHub
96 changed files with 2701 additions and 744 deletions
@ -1,6 +1,6 @@ |
|||||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
import { StorageManager } from './storage-manager'; |
import { StorageManager } from '../storage-manager'; |
||||
|
|
||||
describe('storageManager', () => { |
describe('storageManager', () => { |
||||
let storageManager: StorageManager; |
let storageManager: StorageManager; |
||||
@ -1,6 +1,6 @@ |
|||||
import { describe, expect, it } from 'vitest'; |
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
import { diff } from './diff'; |
import { diff } from '../diff'; |
||||
|
|
||||
describe('diff function', () => { |
describe('diff function', () => { |
||||
it('should return an empty object when comparing identical objects', () => { |
it('should return an empty object when comparing identical objects', () => { |
||||
@ -1,6 +1,6 @@ |
|||||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
import { getElementVisibleRect } from './dom'; // 假设函数位于 utils.ts 中
|
import { getElementVisibleRect } from '../dom'; // 假设函数位于 utils.ts 中
|
||||
|
|
||||
describe('getElementVisibleRect', () => { |
describe('getElementVisibleRect', () => { |
||||
// 设置浏览器视口尺寸的 mock
|
// 设置浏览器视口尺寸的 mock
|
||||
@ -1,6 +1,6 @@ |
|||||
import { describe, expect, it } from 'vitest'; |
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
import { filterTree, mapTree, traverseTreeValues } from './tree'; |
import { filterTree, mapTree, traverseTreeValues } from '../tree'; |
||||
|
|
||||
describe('traverseTreeValues', () => { |
describe('traverseTreeValues', () => { |
||||
interface Node { |
interface Node { |
||||
@ -1,6 +1,6 @@ |
|||||
import { describe, expect, it } from 'vitest'; |
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
import { uniqueByField } from './unique'; |
import { uniqueByField } from '../unique'; |
||||
|
|
||||
describe('uniqueByField', () => { |
describe('uniqueByField', () => { |
||||
it('should return an array with unique items based on id field', () => { |
it('should return an array with unique items based on id field', () => { |
||||
@ -1,6 +1,6 @@ |
|||||
import { expect, it } from 'vitest'; |
import { expect, it } from 'vitest'; |
||||
|
|
||||
import { updateCSSVariables } from './update-css-variables'; |
import { updateCSSVariables } from '../update-css-variables'; |
||||
|
|
||||
it('updateCSSVariables should update CSS variables in :root selector', () => { |
it('updateCSSVariables should update CSS variables in :root selector', () => { |
||||
// 模拟初始的内联样式表内容
|
// 模拟初始的内联样式表内容
|
||||
@ -1,6 +1,6 @@ |
|||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
import { openWindow } from './window'; // 假设你的函数在 'openWindow' 文件中
|
import { openWindow } from '../window'; // 假设你的函数在 'openWindow' 文件中
|
||||
|
|
||||
describe('openWindow', () => { |
describe('openWindow', () => { |
||||
// 保存原始的 window.open 函数
|
// 保存原始的 window.open 函数
|
||||
@ -0,0 +1,47 @@ |
|||||
|
import type { Ref } from 'vue'; |
||||
|
import { computed, getCurrentInstance, useAttrs, useSlots } from 'vue'; |
||||
|
|
||||
|
import { |
||||
|
getFirstNonNullOrUndefined, |
||||
|
kebabToCamelCase, |
||||
|
} from '@vben-core/shared'; |
||||
|
|
||||
|
/** |
||||
|
* 依次从插槽、attrs、props、state 中获取值 |
||||
|
* @param key |
||||
|
* @param props |
||||
|
* @param state |
||||
|
*/ |
||||
|
export function usePriorityValue< |
||||
|
T extends Record<string, any>, |
||||
|
S extends Record<string, any>, |
||||
|
K extends keyof T = keyof T, |
||||
|
>(key: K, props: T, state: Readonly<Ref<NoInfer<S>>> | undefined) { |
||||
|
const instance = getCurrentInstance(); |
||||
|
const slots = useSlots(); |
||||
|
const attrs = useAttrs() as T; |
||||
|
|
||||
|
const value = computed((): T[K] => { |
||||
|
// props不管有没有传,都会有默认值,会影响这里的顺序,
|
||||
|
// 通过判断原始props是否有值来判断是否传入
|
||||
|
const rawProps = (instance?.vnode?.props || {}) as T; |
||||
|
|
||||
|
const standardRwaProps = {} as T; |
||||
|
|
||||
|
for (const [key, value] of Object.entries(rawProps)) { |
||||
|
standardRwaProps[kebabToCamelCase(key) as K] = value; |
||||
|
} |
||||
|
const propsKey = |
||||
|
standardRwaProps?.[key] === undefined ? undefined : props[key]; |
||||
|
|
||||
|
// slot可以关闭
|
||||
|
return getFirstNonNullOrUndefined( |
||||
|
slots[key as string], |
||||
|
attrs[key], |
||||
|
propsKey, |
||||
|
state?.value?.[key as keyof S], |
||||
|
) as T[K]; |
||||
|
}); |
||||
|
|
||||
|
return value; |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import { defineBuildConfig } from 'unbuild'; |
||||
|
|
||||
|
export default defineBuildConfig({ |
||||
|
clean: true, |
||||
|
declaration: true, |
||||
|
entries: [ |
||||
|
{ |
||||
|
builder: 'mkdist', |
||||
|
input: './src', |
||||
|
loaders: ['vue'], |
||||
|
pattern: ['**/*.vue'], |
||||
|
}, |
||||
|
{ |
||||
|
builder: 'mkdist', |
||||
|
format: 'esm', |
||||
|
input: './src', |
||||
|
loaders: ['js'], |
||||
|
pattern: ['**/*.ts'], |
||||
|
}, |
||||
|
], |
||||
|
}); |
||||
@ -0,0 +1,47 @@ |
|||||
|
{ |
||||
|
"name": "@vben-core/popup-ui", |
||||
|
"version": "5.1.1", |
||||
|
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
||||
|
"directory": "packages/@vben-core/uikit/popup-ui" |
||||
|
}, |
||||
|
"license": "MIT", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"build": "pnpm unbuild", |
||||
|
"prepublishOnly": "npm run build" |
||||
|
}, |
||||
|
"files": [ |
||||
|
"dist" |
||||
|
], |
||||
|
"sideEffects": [ |
||||
|
"**/*.css" |
||||
|
], |
||||
|
"main": "./dist/index.mjs", |
||||
|
"module": "./dist/index.mjs", |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./src/index.ts", |
||||
|
"development": "./src/index.ts", |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
}, |
||||
|
"publishConfig": { |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"default": "./dist/index.mjs" |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vben-core/composables": "workspace:*", |
||||
|
"@vben-core/icons": "workspace:*", |
||||
|
"@vben-core/shadcn-ui": "workspace:*", |
||||
|
"@vben-core/shared": "workspace:*", |
||||
|
"@vueuse/core": "^11.0.1", |
||||
|
"vue": "^3.4.38" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export { default } from '@vben/tailwind-config/postcss'; |
||||
@ -0,0 +1,113 @@ |
|||||
|
import type { DrawerState } from '../drawer'; |
||||
|
|
||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { DrawerApi } from '../drawer-api'; |
||||
|
|
||||
|
// 模拟 Store 类
|
||||
|
vi.mock('@vben-core/shared', () => { |
||||
|
return { |
||||
|
isFunction: (fn: any) => typeof fn === 'function', |
||||
|
Store: class { |
||||
|
private _state: DrawerState; |
||||
|
private options: any; |
||||
|
|
||||
|
constructor(initialState: DrawerState, options: any) { |
||||
|
this._state = initialState; |
||||
|
this.options = options; |
||||
|
} |
||||
|
|
||||
|
batch(cb: () => void) { |
||||
|
cb(); |
||||
|
} |
||||
|
|
||||
|
setState(fn: (prev: DrawerState) => DrawerState) { |
||||
|
this._state = fn(this._state); |
||||
|
this.options.onUpdate(); |
||||
|
} |
||||
|
|
||||
|
get state() { |
||||
|
return this._state; |
||||
|
} |
||||
|
}, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('drawerApi', () => { |
||||
|
let drawerApi: DrawerApi; |
||||
|
let drawerState: DrawerState; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
drawerApi = new DrawerApi(); |
||||
|
drawerState = drawerApi.store.state; |
||||
|
}); |
||||
|
|
||||
|
it('should initialize with default state', () => { |
||||
|
expect(drawerState.isOpen).toBe(false); |
||||
|
expect(drawerState.cancelText).toBe('取消'); |
||||
|
expect(drawerState.confirmText).toBe('确定'); |
||||
|
}); |
||||
|
|
||||
|
it('should open the drawer', () => { |
||||
|
drawerApi.open(); |
||||
|
expect(drawerApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should close the drawer if onBeforeClose allows it', () => { |
||||
|
drawerApi.open(); |
||||
|
drawerApi.close(); |
||||
|
expect(drawerApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('should not close the drawer if onBeforeClose returns false', () => { |
||||
|
const onBeforeClose = vi.fn(() => false); |
||||
|
const drawerApiWithHook = new DrawerApi({ onBeforeClose }); |
||||
|
drawerApiWithHook.open(); |
||||
|
drawerApiWithHook.close(); |
||||
|
expect(drawerApiWithHook.store.state.isOpen).toBe(true); |
||||
|
expect(onBeforeClose).toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('should trigger onCancel and keep drawer open if onCancel is provided', () => { |
||||
|
const onCancel = vi.fn(); |
||||
|
const drawerApiWithHook = new DrawerApi({ onCancel }); |
||||
|
drawerApiWithHook.open(); |
||||
|
drawerApiWithHook.onCancel(); |
||||
|
expect(onCancel).toHaveBeenCalled(); |
||||
|
expect(drawerApiWithHook.store.state.isOpen).toBe(true); // 关闭逻辑不在 onCancel 内
|
||||
|
}); |
||||
|
|
||||
|
it('should update shared data correctly', () => { |
||||
|
const testData = { key: 'value' }; |
||||
|
drawerApi.setData(testData); |
||||
|
expect(drawerApi.getData()).toEqual(testData); |
||||
|
}); |
||||
|
|
||||
|
it('should set state correctly using an object', () => { |
||||
|
drawerApi.setState({ title: 'New Title' }); |
||||
|
expect(drawerApi.store.state.title).toBe('New Title'); |
||||
|
}); |
||||
|
|
||||
|
it('should set state correctly using a function', () => { |
||||
|
drawerApi.setState((prev) => ({ ...prev, confirmText: 'Yes' })); |
||||
|
expect(drawerApi.store.state.confirmText).toBe('Yes'); |
||||
|
}); |
||||
|
|
||||
|
it('should call onOpenChange when state changes', () => { |
||||
|
const onOpenChange = vi.fn(); |
||||
|
const drawerApiWithHook = new DrawerApi({ onOpenChange }); |
||||
|
drawerApiWithHook.open(); |
||||
|
expect(onOpenChange).toHaveBeenCalledWith(true); |
||||
|
}); |
||||
|
|
||||
|
it('should batch state updates', () => { |
||||
|
const batchSpy = vi.spyOn(drawerApi.store, 'batch'); |
||||
|
drawerApi.batchStore(() => { |
||||
|
drawerApi.setState({ title: 'Batch Title' }); |
||||
|
drawerApi.setState({ confirmText: 'Batch Confirm' }); |
||||
|
}); |
||||
|
expect(batchSpy).toHaveBeenCalled(); |
||||
|
expect(drawerApi.store.state.title).toBe('Batch Title'); |
||||
|
expect(drawerApi.store.state.confirmText).toBe('Batch Confirm'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,123 @@ |
|||||
|
import type { DrawerApiOptions, DrawerState } from './drawer'; |
||||
|
|
||||
|
import { isFunction, Store } from '@vben-core/shared'; |
||||
|
|
||||
|
export class DrawerApi { |
||||
|
private api: Pick< |
||||
|
DrawerApiOptions, |
||||
|
'onBeforeClose' | 'onCancel' | 'onConfirm' | 'onOpenChange' |
||||
|
>; |
||||
|
// private prevState!: DrawerState;
|
||||
|
private state!: DrawerState; |
||||
|
|
||||
|
// 共享数据
|
||||
|
public sharedData: Record<'payload', any> = { |
||||
|
payload: {}, |
||||
|
}; |
||||
|
|
||||
|
public store: Store<DrawerState>; |
||||
|
|
||||
|
constructor(options: DrawerApiOptions = {}) { |
||||
|
const { |
||||
|
connectedComponent: _, |
||||
|
onBeforeClose, |
||||
|
onCancel, |
||||
|
onConfirm, |
||||
|
onOpenChange, |
||||
|
...storeState |
||||
|
} = options; |
||||
|
|
||||
|
const defaultState: DrawerState = { |
||||
|
cancelText: '取消', |
||||
|
closable: true, |
||||
|
confirmLoading: false, |
||||
|
confirmText: '确定', |
||||
|
footer: true, |
||||
|
isOpen: false, |
||||
|
loading: false, |
||||
|
modal: true, |
||||
|
sharedData: {}, |
||||
|
title: '', |
||||
|
}; |
||||
|
|
||||
|
this.store = new Store<DrawerState>( |
||||
|
{ |
||||
|
...defaultState, |
||||
|
...storeState, |
||||
|
}, |
||||
|
{ |
||||
|
onUpdate: () => { |
||||
|
const state = this.store.state; |
||||
|
if (state?.isOpen === this.state?.isOpen) { |
||||
|
this.state = state; |
||||
|
} else { |
||||
|
this.state = state; |
||||
|
this.api.onOpenChange?.(!!state?.isOpen); |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
this.api = { |
||||
|
onBeforeClose, |
||||
|
onCancel, |
||||
|
onConfirm, |
||||
|
onOpenChange, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 如果需要多次更新状态,可以使用 batch 方法
|
||||
|
batchStore(cb: () => void) { |
||||
|
this.store.batch(cb); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 关闭弹窗 |
||||
|
*/ |
||||
|
close() { |
||||
|
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
|
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
|
const allowClose = this.api.onBeforeClose?.() ?? true; |
||||
|
if (allowClose) { |
||||
|
this.store.setState((prev) => ({ ...prev, isOpen: false })); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getData<T extends object = Record<string, any>>() { |
||||
|
return (this.sharedData?.payload ?? {}) as T; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 取消操作 |
||||
|
*/ |
||||
|
onCancel() { |
||||
|
this.api.onCancel?.(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 确认操作 |
||||
|
*/ |
||||
|
onConfirm() { |
||||
|
this.api.onConfirm?.(); |
||||
|
} |
||||
|
|
||||
|
open() { |
||||
|
this.store.setState((prev) => ({ ...prev, isOpen: true })); |
||||
|
} |
||||
|
|
||||
|
setData<T>(payload: T) { |
||||
|
this.sharedData.payload = payload; |
||||
|
} |
||||
|
|
||||
|
setState( |
||||
|
stateOrFn: |
||||
|
| ((prev: DrawerState) => Partial<DrawerState>) |
||||
|
| Partial<DrawerState>, |
||||
|
) { |
||||
|
if (isFunction(stateOrFn)) { |
||||
|
this.store.setState(stateOrFn); |
||||
|
} else { |
||||
|
this.store.setState((prev) => ({ ...prev, ...stateOrFn })); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,93 @@ |
|||||
|
import type { DrawerApi } from './drawer-api'; |
||||
|
|
||||
|
import type { Component, Ref } from 'vue'; |
||||
|
|
||||
|
export interface DrawerProps { |
||||
|
/** |
||||
|
* 取消按钮文字 |
||||
|
*/ |
||||
|
cancelText?: string; |
||||
|
|
||||
|
/** |
||||
|
* 是否显示右上角的关闭按钮 |
||||
|
* @default true |
||||
|
*/ |
||||
|
closable?: boolean; |
||||
|
/** |
||||
|
* 确定按钮 loading |
||||
|
* @default false |
||||
|
*/ |
||||
|
confirmLoading?: boolean; |
||||
|
/** |
||||
|
* 确定按钮文字 |
||||
|
*/ |
||||
|
confirmText?: string; |
||||
|
/** |
||||
|
* 弹窗描述 |
||||
|
*/ |
||||
|
description?: string; |
||||
|
/** |
||||
|
* 是否显示底部 |
||||
|
* @default true |
||||
|
*/ |
||||
|
footer?: boolean; |
||||
|
/** |
||||
|
* 弹窗是否显示 |
||||
|
* @default false |
||||
|
*/ |
||||
|
loading?: boolean; |
||||
|
/** |
||||
|
* 是否显示遮罩 |
||||
|
* @default true |
||||
|
*/ |
||||
|
modal?: boolean; |
||||
|
/** |
||||
|
* 弹窗标题 |
||||
|
*/ |
||||
|
title?: string; |
||||
|
/** |
||||
|
* 弹窗标题提示 |
||||
|
*/ |
||||
|
titleTooltip?: string; |
||||
|
} |
||||
|
|
||||
|
export interface DrawerState extends DrawerProps { |
||||
|
/** 弹窗打开状态 */ |
||||
|
isOpen?: boolean; |
||||
|
/** |
||||
|
* 共享数据 |
||||
|
*/ |
||||
|
sharedData?: Record<string, any>; |
||||
|
} |
||||
|
|
||||
|
export type ExtendedDrawerApi = { |
||||
|
useStore: <T = NoInfer<DrawerState>>( |
||||
|
selector?: (state: NoInfer<DrawerState>) => T, |
||||
|
) => Readonly<Ref<T>>; |
||||
|
} & DrawerApi; |
||||
|
|
||||
|
export interface DrawerApiOptions extends DrawerState { |
||||
|
/** |
||||
|
* 独立的弹窗组件 |
||||
|
*/ |
||||
|
connectedComponent?: Component; |
||||
|
/** |
||||
|
* 关闭前的回调,返回 false 可以阻止关闭 |
||||
|
* @returns |
||||
|
*/ |
||||
|
onBeforeClose?: () => void; |
||||
|
/** |
||||
|
* 点击取消按钮的回调 |
||||
|
*/ |
||||
|
onCancel?: () => void; |
||||
|
/** |
||||
|
* 点击确定按钮的回调 |
||||
|
*/ |
||||
|
onConfirm?: () => void; |
||||
|
/** |
||||
|
* 弹窗状态变化回调 |
||||
|
* @param isOpen |
||||
|
* @returns |
||||
|
*/ |
||||
|
onOpenChange?: (isOpen: boolean) => void; |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import type { DrawerProps, ExtendedDrawerApi } from './drawer'; |
||||
|
|
||||
|
import { usePriorityValue } from '@vben-core/composables'; |
||||
|
import { Info, X } from '@vben-core/icons'; |
||||
|
import { |
||||
|
Sheet, |
||||
|
SheetClose, |
||||
|
SheetContent, |
||||
|
SheetDescription, |
||||
|
SheetFooter, |
||||
|
SheetHeader, |
||||
|
SheetTitle, |
||||
|
VbenButton, |
||||
|
VbenIconButton, |
||||
|
VbenLoading, |
||||
|
VbenTooltip, |
||||
|
VisuallyHidden, |
||||
|
} from '@vben-core/shadcn-ui'; |
||||
|
import { cn } from '@vben-core/shared'; |
||||
|
|
||||
|
interface Props extends DrawerProps { |
||||
|
class?: string; |
||||
|
contentClass?: string; |
||||
|
drawerApi?: ExtendedDrawerApi; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
class: '', |
||||
|
contentClass: '', |
||||
|
drawerApi: undefined, |
||||
|
}); |
||||
|
|
||||
|
const state = props.drawerApi?.useStore?.(); |
||||
|
|
||||
|
const title = usePriorityValue('title', props, state); |
||||
|
const description = usePriorityValue('description', props, state); |
||||
|
const titleTooltip = usePriorityValue('titleTooltip', props, state); |
||||
|
const showFooter = usePriorityValue('footer', props, state); |
||||
|
const showLoading = usePriorityValue('loading', props, state); |
||||
|
const closable = usePriorityValue('closable', props, state); |
||||
|
const modal = usePriorityValue('modal', props, state); |
||||
|
const confirmLoading = usePriorityValue('confirmLoading', props, state); |
||||
|
const cancelText = usePriorityValue('cancelText', props, state); |
||||
|
const confirmText = usePriorityValue('confirmText', props, state); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Sheet |
||||
|
:modal="modal" |
||||
|
:open="state?.isOpen" |
||||
|
@update:open="() => drawerApi?.close()" |
||||
|
> |
||||
|
<SheetContent :class="cn('flex w-[520px] flex-col', props.class, {})"> |
||||
|
<SheetHeader |
||||
|
:class=" |
||||
|
cn('!flex flex-row items-center justify-between border-b px-6 py-5', { |
||||
|
'px-4 py-3': closable, |
||||
|
}) |
||||
|
" |
||||
|
> |
||||
|
<div> |
||||
|
<SheetTitle v-if="title"> |
||||
|
<slot name="title"> |
||||
|
{{ title }} |
||||
|
|
||||
|
<VbenTooltip v-if="titleTooltip" side="right"> |
||||
|
<template #trigger> |
||||
|
<Info class="inline-flex size-5 cursor-pointer pb-1" /> |
||||
|
</template> |
||||
|
{{ titleTooltip }} |
||||
|
</VbenTooltip> |
||||
|
</slot> |
||||
|
</SheetTitle> |
||||
|
<SheetDescription v-if="description" class="mt-1 text-xs"> |
||||
|
<slot name="description"> |
||||
|
{{ description }} |
||||
|
</slot> |
||||
|
</SheetDescription> |
||||
|
</div> |
||||
|
|
||||
|
<VisuallyHidden v-if="!title || !description"> |
||||
|
<SheetTitle v-if="!title" /> |
||||
|
<SheetDescription v-if="!description" /> |
||||
|
</VisuallyHidden> |
||||
|
|
||||
|
<div class="flex-center"> |
||||
|
<slot name="extra"></slot> |
||||
|
<SheetClose |
||||
|
v-if="closable" |
||||
|
as-child |
||||
|
class="data-[state=open]:bg-secondary ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" |
||||
|
> |
||||
|
<VbenIconButton> |
||||
|
<X class="size-4" /> |
||||
|
</VbenIconButton> |
||||
|
</SheetClose> |
||||
|
</div> |
||||
|
</SheetHeader> |
||||
|
|
||||
|
<div |
||||
|
:class=" |
||||
|
cn('relative flex-1 p-3', contentClass, { |
||||
|
'overflow-y-auto': !showLoading, |
||||
|
}) |
||||
|
" |
||||
|
> |
||||
|
<VbenLoading v-if="showLoading" class="size-full" spinning /> |
||||
|
|
||||
|
<slot></slot> |
||||
|
</div> |
||||
|
|
||||
|
<SheetFooter |
||||
|
v-if="showFooter" |
||||
|
class="w-full items-center border-t p-2 px-3" |
||||
|
> |
||||
|
<slot name="prepend-footer"></slot> |
||||
|
<slot name="footer"> |
||||
|
<VbenButton |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
@click="() => drawerApi?.onCancel()" |
||||
|
> |
||||
|
<slot name="cancelText"> |
||||
|
{{ cancelText }} |
||||
|
</slot> |
||||
|
</VbenButton> |
||||
|
<VbenButton |
||||
|
:loading="confirmLoading" |
||||
|
size="sm" |
||||
|
@click="() => drawerApi?.onConfirm()" |
||||
|
> |
||||
|
<slot name="confirmText"> |
||||
|
{{ confirmText }} |
||||
|
</slot> |
||||
|
</VbenButton> |
||||
|
</slot> |
||||
|
<slot name="append-footer"></slot> |
||||
|
</SheetFooter> |
||||
|
</SheetContent> |
||||
|
</Sheet> |
||||
|
</template> |
||||
@ -0,0 +1,3 @@ |
|||||
|
export type * from './drawer'; |
||||
|
export { default as VbenDrawer } from './drawer.vue'; |
||||
|
export { useVbenDrawer } from './use-drawer'; |
||||
@ -0,0 +1,105 @@ |
|||||
|
import type { |
||||
|
DrawerApiOptions, |
||||
|
DrawerProps, |
||||
|
ExtendedDrawerApi, |
||||
|
} from './drawer'; |
||||
|
|
||||
|
import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue'; |
||||
|
|
||||
|
import { useStore } from '@vben-core/shared'; |
||||
|
|
||||
|
import VbenDrawer from './drawer.vue'; |
||||
|
import { DrawerApi } from './drawer-api'; |
||||
|
|
||||
|
const USER_DRAWER_INJECT_KEY = Symbol('VBEN_DRAWER_INJECT'); |
||||
|
|
||||
|
export function useVbenDrawer< |
||||
|
TParentDrawerProps extends DrawerProps = DrawerProps, |
||||
|
>(options: DrawerApiOptions = {}) { |
||||
|
// Drawer一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
|
// 外部的Drawer通过provide/inject传递api
|
||||
|
|
||||
|
const { connectedComponent } = options; |
||||
|
if (connectedComponent) { |
||||
|
const extendedApi = reactive({}); |
||||
|
const Drawer = defineComponent( |
||||
|
(props: TParentDrawerProps, { attrs, slots }) => { |
||||
|
provide(USER_DRAWER_INJECT_KEY, { |
||||
|
extendApi(api: ExtendedDrawerApi) { |
||||
|
// 不能直接给 reactive 赋值,会丢失响应
|
||||
|
// 不能用 Object.assign,会丢失 api 的原型函数
|
||||
|
Object.setPrototypeOf(extendedApi, api); |
||||
|
}, |
||||
|
options, |
||||
|
}); |
||||
|
checkProps(extendedApi as ExtendedDrawerApi, { |
||||
|
...props, |
||||
|
...attrs, |
||||
|
...slots, |
||||
|
}); |
||||
|
return () => h(connectedComponent, { ...props, ...attrs }, slots); |
||||
|
}, |
||||
|
{ |
||||
|
inheritAttrs: false, |
||||
|
name: 'VbenParentDrawer', |
||||
|
}, |
||||
|
); |
||||
|
return [Drawer, extendedApi as ExtendedDrawerApi] as const; |
||||
|
} |
||||
|
|
||||
|
const injectData = inject<any>(USER_DRAWER_INJECT_KEY, {}); |
||||
|
|
||||
|
const mergedOptions = { |
||||
|
...injectData.options, |
||||
|
...options, |
||||
|
} as DrawerApiOptions; |
||||
|
|
||||
|
// mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
|
// options.onOpenChange?.(isOpen);
|
||||
|
// injectData.options?.onOpenChange?.(isOpen);
|
||||
|
// };
|
||||
|
const api = new DrawerApi(mergedOptions); |
||||
|
|
||||
|
const extendedApi: ExtendedDrawerApi = api as never; |
||||
|
|
||||
|
extendedApi.useStore = (selector) => { |
||||
|
return useStore(api.store, selector); |
||||
|
}; |
||||
|
|
||||
|
const Drawer = defineComponent( |
||||
|
(props: DrawerProps, { attrs, slots }) => { |
||||
|
return () => |
||||
|
h(VbenDrawer, { ...props, ...attrs, drawerApi: extendedApi }, slots); |
||||
|
}, |
||||
|
{ |
||||
|
inheritAttrs: false, |
||||
|
name: 'VbenDrawer', |
||||
|
}, |
||||
|
); |
||||
|
injectData.extendApi?.(extendedApi); |
||||
|
return [Drawer, extendedApi] as const; |
||||
|
} |
||||
|
|
||||
|
async function checkProps(api: ExtendedDrawerApi, attrs: Record<string, any>) { |
||||
|
if (!attrs || Object.keys(attrs).length === 0) { |
||||
|
return; |
||||
|
} |
||||
|
await nextTick(); |
||||
|
|
||||
|
const state = api?.store?.state; |
||||
|
|
||||
|
if (!state) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const stateKeys = new Set(Object.keys(state)); |
||||
|
|
||||
|
for (const attr of Object.keys(attrs)) { |
||||
|
if (stateKeys.has(attr)) { |
||||
|
// connectedComponent存在时,不要传入Drawer的props,会造成复杂度提升,如果你需要修改Drawer的props,请使用 useVbenDrawer 或者api
|
||||
|
console.warn( |
||||
|
`[Vben Drawer]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Drawer, please use useVbenDrawer or api.`, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
export * from './drawer'; |
||||
|
export * from './modal'; |
||||
@ -0,0 +1,112 @@ |
|||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { ModalApi } from '../modal-api'; // 假设 ModalApi 位于同一目录
|
||||
|
import type { ModalState } from '../modal'; |
||||
|
|
||||
|
vi.mock('@vben-core/shared', () => { |
||||
|
return { |
||||
|
isFunction: (fn: any) => typeof fn === 'function', |
||||
|
Store: class { |
||||
|
private _state: ModalState; |
||||
|
private options: any; |
||||
|
|
||||
|
constructor(initialState: ModalState, options: any) { |
||||
|
this._state = initialState; |
||||
|
this.options = options; |
||||
|
} |
||||
|
|
||||
|
batch(cb: () => void) { |
||||
|
cb(); |
||||
|
} |
||||
|
|
||||
|
setState(fn: (prev: ModalState) => ModalState) { |
||||
|
this._state = fn(this._state); |
||||
|
this.options.onUpdate(); |
||||
|
} |
||||
|
|
||||
|
get state() { |
||||
|
return this._state; |
||||
|
} |
||||
|
}, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('modalApi', () => { |
||||
|
let modalApi: ModalApi; |
||||
|
// 使用 modalState 而不是 state
|
||||
|
let modalState: ModalState; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
modalApi = new ModalApi(); |
||||
|
// 获取 modalApi 内的 state
|
||||
|
modalState = modalApi.store.state; |
||||
|
}); |
||||
|
|
||||
|
it('should initialize with default state', () => { |
||||
|
expect(modalState.isOpen).toBe(false); |
||||
|
expect(modalState.cancelText).toBe('取消'); |
||||
|
expect(modalState.confirmText).toBe('确定'); |
||||
|
}); |
||||
|
|
||||
|
it('should open the modal', () => { |
||||
|
modalApi.open(); |
||||
|
expect(modalApi.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should close the modal if onBeforeClose allows it', () => { |
||||
|
modalApi.close(); |
||||
|
expect(modalApi.store.state.isOpen).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('should not close the modal if onBeforeClose returns false', () => { |
||||
|
const onBeforeClose = vi.fn(() => false); |
||||
|
const modalApiWithHook = new ModalApi({ onBeforeClose }); |
||||
|
modalApiWithHook.open(); |
||||
|
modalApiWithHook.close(); |
||||
|
expect(modalApiWithHook.store.state.isOpen).toBe(true); |
||||
|
expect(onBeforeClose).toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('should trigger onCancel and close the modal if no onCancel hook is provided', () => { |
||||
|
const onCancel = vi.fn(); |
||||
|
const modalApiWithHook = new ModalApi({ onCancel }); |
||||
|
modalApiWithHook.open(); |
||||
|
modalApiWithHook.onCancel(); |
||||
|
expect(onCancel).toHaveBeenCalled(); |
||||
|
expect(modalApiWithHook.store.state.isOpen).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should update shared data correctly', () => { |
||||
|
const testData = { key: 'value' }; |
||||
|
modalApi.setData(testData); |
||||
|
expect(modalApi.getData()).toEqual(testData); |
||||
|
}); |
||||
|
|
||||
|
it('should set state correctly using an object', () => { |
||||
|
modalApi.setState({ title: 'New Title' }); |
||||
|
expect(modalApi.store.state.title).toBe('New Title'); |
||||
|
}); |
||||
|
|
||||
|
it('should set state correctly using a function', () => { |
||||
|
modalApi.setState((prev) => ({ ...prev, confirmText: 'Yes' })); |
||||
|
expect(modalApi.store.state.confirmText).toBe('Yes'); |
||||
|
}); |
||||
|
|
||||
|
it('should call onOpenChange when state changes', () => { |
||||
|
const onOpenChange = vi.fn(); |
||||
|
const modalApiWithHook = new ModalApi({ onOpenChange }); |
||||
|
modalApiWithHook.open(); |
||||
|
expect(onOpenChange).toHaveBeenCalledWith(true); |
||||
|
}); |
||||
|
|
||||
|
it('should batch state updates', () => { |
||||
|
const batchSpy = vi.spyOn(modalApi.store, 'batch'); |
||||
|
modalApi.batchStore(() => { |
||||
|
modalApi.setState({ title: 'Batch Title' }); |
||||
|
modalApi.setState({ confirmText: 'Batch Confirm' }); |
||||
|
}); |
||||
|
expect(batchSpy).toHaveBeenCalled(); |
||||
|
expect(modalApi.store.state.title).toBe('Batch Title'); |
||||
|
expect(modalApi.store.state.confirmText).toBe('Batch Confirm'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,3 @@ |
|||||
|
export type * from './modal'; |
||||
|
export { default as VbenModal } from './modal.vue'; |
||||
|
export { useVbenModal } from './use-modal'; |
||||
@ -0,0 +1,134 @@ |
|||||
|
import type { ModalApiOptions, ModalState } from './modal'; |
||||
|
|
||||
|
import { isFunction, Store } from '@vben-core/shared'; |
||||
|
|
||||
|
export class ModalApi { |
||||
|
private api: Pick< |
||||
|
ModalApiOptions, |
||||
|
'onBeforeClose' | 'onCancel' | 'onConfirm' | 'onOpenChange' |
||||
|
>; |
||||
|
// private prevState!: ModalState;
|
||||
|
private state!: ModalState; |
||||
|
|
||||
|
// 共享数据
|
||||
|
public sharedData: Record<'payload', any> = { |
||||
|
payload: {}, |
||||
|
}; |
||||
|
|
||||
|
public store: Store<ModalState>; |
||||
|
|
||||
|
constructor(options: ModalApiOptions = {}) { |
||||
|
const { |
||||
|
connectedComponent: _, |
||||
|
onBeforeClose, |
||||
|
onCancel, |
||||
|
onConfirm, |
||||
|
onOpenChange, |
||||
|
...storeState |
||||
|
} = options; |
||||
|
|
||||
|
const defaultState: ModalState = { |
||||
|
cancelText: '取消', |
||||
|
centered: false, |
||||
|
closeOnClickModal: true, |
||||
|
closeOnPressEscape: true, |
||||
|
confirmLoading: false, |
||||
|
confirmText: '确定', |
||||
|
draggable: false, |
||||
|
footer: true, |
||||
|
fullscreen: false, |
||||
|
fullscreenButton: true, |
||||
|
isOpen: false, |
||||
|
loading: false, |
||||
|
modal: true, |
||||
|
sharedData: {}, |
||||
|
title: '', |
||||
|
}; |
||||
|
|
||||
|
this.store = new Store<ModalState>( |
||||
|
{ |
||||
|
...defaultState, |
||||
|
...storeState, |
||||
|
}, |
||||
|
{ |
||||
|
onUpdate: () => { |
||||
|
const state = this.store.state; |
||||
|
|
||||
|
// 每次更新状态时,都会调用 onOpenChange 回调函数
|
||||
|
if (state?.isOpen === this.state?.isOpen) { |
||||
|
this.state = state; |
||||
|
} else { |
||||
|
this.state = state; |
||||
|
this.api.onOpenChange?.(!!state?.isOpen); |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
this.api = { |
||||
|
onBeforeClose, |
||||
|
onCancel, |
||||
|
onConfirm, |
||||
|
onOpenChange, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 如果需要多次更新状态,可以使用 batch 方法
|
||||
|
batchStore(cb: () => void) { |
||||
|
this.store.batch(cb); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 关闭弹窗 |
||||
|
*/ |
||||
|
close() { |
||||
|
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
|
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
|
const allowClose = this.api.onBeforeClose?.() ?? true; |
||||
|
if (allowClose) { |
||||
|
this.store.setState((prev) => ({ ...prev, isOpen: false })); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getData<T extends object = Record<string, any>>() { |
||||
|
return (this.sharedData?.payload ?? {}) as T; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 取消操作 |
||||
|
*/ |
||||
|
onCancel() { |
||||
|
if (this.api.onCancel) { |
||||
|
this.api.onCancel?.(); |
||||
|
} else { |
||||
|
this.close(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 确认操作 |
||||
|
*/ |
||||
|
onConfirm() { |
||||
|
this.api.onConfirm?.(); |
||||
|
} |
||||
|
|
||||
|
open() { |
||||
|
this.store.setState((prev) => ({ ...prev, isOpen: true })); |
||||
|
} |
||||
|
|
||||
|
setData<T>(payload: T) { |
||||
|
this.sharedData.payload = payload; |
||||
|
} |
||||
|
|
||||
|
setState( |
||||
|
stateOrFn: |
||||
|
| ((prev: ModalState) => Partial<ModalState>) |
||||
|
| Partial<ModalState>, |
||||
|
) { |
||||
|
if (isFunction(stateOrFn)) { |
||||
|
this.store.setState(stateOrFn); |
||||
|
} else { |
||||
|
this.store.setState((prev) => ({ ...prev, ...stateOrFn })); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,123 @@ |
|||||
|
import type { ModalApi } from './modal-api'; |
||||
|
|
||||
|
import type { Component, Ref } from 'vue'; |
||||
|
|
||||
|
export interface ModalProps { |
||||
|
/** |
||||
|
* 取消按钮文字 |
||||
|
*/ |
||||
|
cancelText?: string; |
||||
|
/** |
||||
|
* 是否居中 |
||||
|
* @default false |
||||
|
*/ |
||||
|
centered?: boolean; |
||||
|
/** |
||||
|
* 是否显示右上角的关闭按钮 |
||||
|
* @default true |
||||
|
*/ |
||||
|
closable?: boolean; |
||||
|
/** |
||||
|
* 点击弹窗遮罩是否关闭弹窗 |
||||
|
* @default true |
||||
|
*/ |
||||
|
closeOnClickModal?: boolean; |
||||
|
/** |
||||
|
* 按下 ESC 键是否关闭弹窗 |
||||
|
* @default true |
||||
|
*/ |
||||
|
closeOnPressEscape?: boolean; |
||||
|
/** |
||||
|
* 确定按钮 loading |
||||
|
* @default false |
||||
|
*/ |
||||
|
confirmLoading?: boolean; |
||||
|
/** |
||||
|
* 确定按钮文字 |
||||
|
*/ |
||||
|
confirmText?: string; |
||||
|
/** |
||||
|
* 弹窗描述 |
||||
|
*/ |
||||
|
description?: string; |
||||
|
/** |
||||
|
* 是否可拖拽 |
||||
|
* @default false |
||||
|
*/ |
||||
|
draggable?: boolean; |
||||
|
/** |
||||
|
* 是否显示底部 |
||||
|
* @default true |
||||
|
*/ |
||||
|
footer?: boolean; |
||||
|
/** |
||||
|
* 是否全屏 |
||||
|
* @default false |
||||
|
*/ |
||||
|
fullscreen?: boolean; |
||||
|
/** |
||||
|
* 是否显示全屏按钮 |
||||
|
* @default true |
||||
|
*/ |
||||
|
fullscreenButton?: boolean; |
||||
|
/** |
||||
|
* 弹窗是否显示 |
||||
|
* @default false |
||||
|
*/ |
||||
|
loading?: boolean; |
||||
|
|
||||
|
/** |
||||
|
* 是否显示遮罩 |
||||
|
* @default true |
||||
|
*/ |
||||
|
modal?: boolean; |
||||
|
/** |
||||
|
* 弹窗标题 |
||||
|
*/ |
||||
|
title?: string; |
||||
|
/** |
||||
|
* 弹窗标题提示 |
||||
|
*/ |
||||
|
titleTooltip?: string; |
||||
|
} |
||||
|
|
||||
|
export interface ModalState extends ModalProps { |
||||
|
/** 弹窗打开状态 */ |
||||
|
isOpen?: boolean; |
||||
|
/** |
||||
|
* 共享数据 |
||||
|
*/ |
||||
|
sharedData?: Record<string, any>; |
||||
|
} |
||||
|
|
||||
|
export type ExtendedModalApi = { |
||||
|
useStore: <T = NoInfer<ModalState>>( |
||||
|
selector?: (state: NoInfer<ModalState>) => T, |
||||
|
) => Readonly<Ref<T>>; |
||||
|
} & ModalApi; |
||||
|
|
||||
|
export interface ModalApiOptions extends ModalState { |
||||
|
/** |
||||
|
* 独立的弹窗组件 |
||||
|
*/ |
||||
|
connectedComponent?: Component; |
||||
|
/** |
||||
|
* 关闭前的回调,返回 false 可以阻止关闭 |
||||
|
* @returns |
||||
|
*/ |
||||
|
onBeforeClose?: () => void; |
||||
|
/** |
||||
|
* 点击取消按钮的回调 |
||||
|
*/ |
||||
|
onCancel?: () => void; |
||||
|
/** |
||||
|
* 点击确定按钮的回调 |
||||
|
*/ |
||||
|
onConfirm?: () => void; |
||||
|
/** |
||||
|
* 弹窗状态变化回调 |
||||
|
* @param isOpen |
||||
|
* @returns |
||||
|
*/ |
||||
|
onOpenChange?: (isOpen: boolean) => void; |
||||
|
} |
||||
@ -0,0 +1,231 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import type { ExtendedModalApi, ModalProps } from './modal'; |
||||
|
|
||||
|
import { computed, nextTick, ref, watch } from 'vue'; |
||||
|
|
||||
|
import { usePriorityValue } from '@vben-core/composables'; |
||||
|
import { Expand, Info, Shrink } from '@vben-core/icons'; |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogTrigger, |
||||
|
VbenButton, |
||||
|
VbenIconButton, |
||||
|
VbenLoading, |
||||
|
VbenTooltip, |
||||
|
VisuallyHidden, |
||||
|
} from '@vben-core/shadcn-ui'; |
||||
|
import { cn } from '@vben-core/shared'; |
||||
|
|
||||
|
// import { useElementSize } from '@vueuse/core'; |
||||
|
|
||||
|
import { useModalDraggable } from './use-modal-draggable'; |
||||
|
|
||||
|
interface Props extends ModalProps { |
||||
|
class?: string; |
||||
|
contentClass?: string; |
||||
|
footerClass?: string; |
||||
|
headerClass?: string; |
||||
|
modalApi?: ExtendedModalApi; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
class: '', |
||||
|
contentClass: '', |
||||
|
footerClass: '', |
||||
|
headerClass: '', |
||||
|
modalApi: undefined, |
||||
|
}); |
||||
|
|
||||
|
const contentRef = ref(); |
||||
|
const dialogRef = ref(); |
||||
|
const headerRef = ref(); |
||||
|
const footerRef = ref(); |
||||
|
|
||||
|
// const { height: headerHeight } = useElementSize(headerRef); |
||||
|
// const { height: footerHeight } = useElementSize(footerRef); |
||||
|
const state = props.modalApi?.useStore?.(); |
||||
|
|
||||
|
const title = usePriorityValue('title', props, state); |
||||
|
const fullscreen = usePriorityValue('fullscreen', props, state); |
||||
|
const description = usePriorityValue('description', props, state); |
||||
|
const titleTooltip = usePriorityValue('titleTooltip', props, state); |
||||
|
const showFooter = usePriorityValue('footer', props, state); |
||||
|
const showLoading = usePriorityValue('loading', props, state); |
||||
|
const closable = usePriorityValue('closable', props, state); |
||||
|
const modal = usePriorityValue('modal', props, state); |
||||
|
const centered = usePriorityValue('centered', props, state); |
||||
|
const confirmLoading = usePriorityValue('confirmLoading', props, state); |
||||
|
const cancelText = usePriorityValue('cancelText', props, state); |
||||
|
const confirmText = usePriorityValue('confirmText', props, state); |
||||
|
const draggable = usePriorityValue('draggable', props, state); |
||||
|
const fullscreenButton = usePriorityValue('fullscreenButton', props, state); |
||||
|
const closeOnClickModal = usePriorityValue('closeOnClickModal', props, state); |
||||
|
const closeOnPressEscape = usePriorityValue('closeOnPressEscape', props, state); |
||||
|
const shouldDraggable = computed(() => draggable.value && !fullscreen.value); |
||||
|
|
||||
|
const { dragging } = useModalDraggable(dialogRef, headerRef, shouldDraggable); |
||||
|
|
||||
|
// const loadingStyle = computed(() => { |
||||
|
// // py-5 4px*5*2 |
||||
|
// const headerPadding = 40; |
||||
|
// // p-2 4px*2*2 |
||||
|
// const footerPadding = 16; |
||||
|
|
||||
|
// return { |
||||
|
// bottom: `${footerHeight.value + footerPadding}px`, |
||||
|
// height: `calc(100% - ${footerHeight.value + headerHeight.value + headerPadding + footerPadding}px)`, |
||||
|
// top: `${headerHeight.value + headerPadding}px`, |
||||
|
// }; |
||||
|
// }); |
||||
|
|
||||
|
watch( |
||||
|
() => state?.value?.isOpen, |
||||
|
async (v) => { |
||||
|
if (v) { |
||||
|
await nextTick(); |
||||
|
if (contentRef.value) { |
||||
|
const innerContentRef = contentRef.value.getContentRef(); |
||||
|
dialogRef.value = innerContentRef.$el; |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
function handleFullscreen() { |
||||
|
props.modalApi?.setState((prev) => { |
||||
|
// if (prev.fullscreen) { |
||||
|
// resetPosition(); |
||||
|
// } |
||||
|
return { ...prev, fullscreen: !fullscreen.value }; |
||||
|
}); |
||||
|
} |
||||
|
function interactOutside(e: Event) { |
||||
|
if (!closeOnClickModal.value) { |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
} |
||||
|
function escapeKeyDown(e: KeyboardEvent) { |
||||
|
if (!closeOnPressEscape.value) { |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<Dialog |
||||
|
:modal="modal" |
||||
|
:open="state?.isOpen" |
||||
|
@update:open="() => modalApi?.close()" |
||||
|
> |
||||
|
<DialogTrigger v-if="$slots.trigger" as-child> |
||||
|
<slot name="trigger"> </slot> |
||||
|
</DialogTrigger> |
||||
|
|
||||
|
<DialogContent |
||||
|
ref="contentRef" |
||||
|
:class=" |
||||
|
cn( |
||||
|
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0', |
||||
|
props.class, |
||||
|
{ |
||||
|
'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0': |
||||
|
fullscreen, |
||||
|
'top-1/2 -translate-y-1/2': centered && !fullscreen, |
||||
|
'duration-300': !dragging, |
||||
|
}, |
||||
|
) |
||||
|
" |
||||
|
:show-close="closable" |
||||
|
close-class="top-4" |
||||
|
@escape-key-down="escapeKeyDown" |
||||
|
@interact-outside="interactOutside" |
||||
|
> |
||||
|
<DialogHeader |
||||
|
ref="headerRef" |
||||
|
:class=" |
||||
|
cn( |
||||
|
'border-b px-6 py-5', |
||||
|
{ |
||||
|
'cursor-move select-none': shouldDraggable, |
||||
|
}, |
||||
|
props.headerClass, |
||||
|
) |
||||
|
" |
||||
|
> |
||||
|
<DialogTitle v-if="title"> |
||||
|
<slot name="title"> |
||||
|
{{ title }} |
||||
|
|
||||
|
<VbenTooltip v-if="titleTooltip" side="right"> |
||||
|
<template #trigger> |
||||
|
<Info class="inline-flex size-5 cursor-pointer pb-1" /> |
||||
|
</template> |
||||
|
{{ titleTooltip }} |
||||
|
</VbenTooltip> |
||||
|
</slot> |
||||
|
</DialogTitle> |
||||
|
<DialogDescription v-if="description"> |
||||
|
<slot name="description"> |
||||
|
{{ description }} |
||||
|
</slot> |
||||
|
</DialogDescription> |
||||
|
<VisuallyHidden v-if="!title || !description"> |
||||
|
<DialogTitle v-if="!title" /> |
||||
|
<DialogDescription v-if="!description" /> |
||||
|
</VisuallyHidden> |
||||
|
</DialogHeader> |
||||
|
<div |
||||
|
:class=" |
||||
|
cn('relative min-h-40 flex-1 p-3', contentClass, { |
||||
|
'overflow-y-auto': !showLoading, |
||||
|
}) |
||||
|
" |
||||
|
> |
||||
|
<VbenLoading v-if="showLoading" class="size-full" spinning /> |
||||
|
<slot></slot> |
||||
|
</div> |
||||
|
|
||||
|
<VbenIconButton |
||||
|
v-if="fullscreenButton" |
||||
|
class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-4 size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" |
||||
|
@click="handleFullscreen" |
||||
|
> |
||||
|
<Shrink v-if="fullscreen" class="size-3.5" /> |
||||
|
<Expand v-else class="size-3.5" /> |
||||
|
</VbenIconButton> |
||||
|
|
||||
|
<DialogFooter |
||||
|
v-if="showFooter" |
||||
|
ref="footerRef" |
||||
|
:class="cn('items-center border-t p-2', props.footerClass)" |
||||
|
> |
||||
|
<slot name="prepend-footer"></slot> |
||||
|
<slot name="footer"> |
||||
|
<VbenButton |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
@click="() => modalApi?.onCancel()" |
||||
|
> |
||||
|
<slot name="cancelText"> |
||||
|
{{ cancelText }} |
||||
|
</slot> |
||||
|
</VbenButton> |
||||
|
<VbenButton |
||||
|
:loading="confirmLoading" |
||||
|
size="sm" |
||||
|
@click="() => modalApi?.onConfirm()" |
||||
|
> |
||||
|
<slot name="confirmText"> |
||||
|
{{ confirmText }} |
||||
|
</slot> |
||||
|
</VbenButton> |
||||
|
</slot> |
||||
|
<slot name="append-footer"></slot> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
</template> |
||||
@ -0,0 +1,148 @@ |
|||||
|
/** |
||||
|
* @copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-draggable/index.ts
|
||||
|
* 调整部分细节 |
||||
|
*/ |
||||
|
|
||||
|
import { onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'; |
||||
|
import type { ComputedRef, Ref } from 'vue'; |
||||
|
|
||||
|
import { unrefElement } from '@vueuse/core'; |
||||
|
|
||||
|
export function useModalDraggable( |
||||
|
targetRef: Ref<HTMLElement | undefined>, |
||||
|
dragRef: Ref<HTMLElement | undefined>, |
||||
|
draggable: ComputedRef<boolean>, |
||||
|
) { |
||||
|
let transform = { |
||||
|
offsetX: 0, |
||||
|
offsetY: 0, |
||||
|
}; |
||||
|
|
||||
|
const dragging = ref(false); |
||||
|
|
||||
|
// let isFirstDrag = true;
|
||||
|
// let initialX = 0;
|
||||
|
// let initialY = 0;
|
||||
|
const onMousedown = (e: MouseEvent) => { |
||||
|
const downX = e.clientX; |
||||
|
const downY = e.clientY; |
||||
|
|
||||
|
if (!targetRef.value) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// if (isFirstDrag) {
|
||||
|
// const { x, y } = getInitialTransform(targetRef.value);
|
||||
|
// initialX = x;
|
||||
|
// initialY = y;
|
||||
|
// }
|
||||
|
|
||||
|
const targetRect = targetRef.value.getBoundingClientRect(); |
||||
|
|
||||
|
const { offsetX, offsetY } = transform; |
||||
|
const targetLeft = targetRect.left; |
||||
|
const targetTop = targetRect.top; |
||||
|
const targetWidth = targetRect.width; |
||||
|
const targetHeight = targetRect.height; |
||||
|
const docElement = document.documentElement; |
||||
|
const clientWidth = docElement.clientWidth; |
||||
|
const clientHeight = docElement.clientHeight; |
||||
|
|
||||
|
const minLeft = -targetLeft + offsetX; |
||||
|
const minTop = -targetTop + offsetY; |
||||
|
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX; |
||||
|
const maxTop = clientHeight - targetTop - targetHeight + offsetY; |
||||
|
|
||||
|
const onMousemove = (e: MouseEvent) => { |
||||
|
let moveX = offsetX + e.clientX - downX; |
||||
|
let moveY = offsetY + e.clientY - downY; |
||||
|
// const x = isFirstDrag ? initialX : 0;
|
||||
|
// const y = isFirstDrag ? initialY : 0;
|
||||
|
moveX = Math.min(Math.max(moveX, minLeft), maxLeft); |
||||
|
// + x;
|
||||
|
moveY = Math.min(Math.max(moveY, minTop), maxTop); |
||||
|
// + y;
|
||||
|
|
||||
|
transform = { |
||||
|
offsetX: moveX, |
||||
|
offsetY: moveY, |
||||
|
}; |
||||
|
|
||||
|
if (targetRef.value) { |
||||
|
targetRef.value.style.transform = `translate(${moveX}px, ${moveY}px)`; |
||||
|
dragging.value = true; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const onMouseup = () => { |
||||
|
// isFirstDrag = false;
|
||||
|
dragging.value = false; |
||||
|
document.removeEventListener('mousemove', onMousemove); |
||||
|
document.removeEventListener('mouseup', onMouseup); |
||||
|
}; |
||||
|
|
||||
|
document.addEventListener('mousemove', onMousemove); |
||||
|
document.addEventListener('mouseup', onMouseup); |
||||
|
}; |
||||
|
|
||||
|
const onDraggable = () => { |
||||
|
const dragDom = unrefElement(dragRef); |
||||
|
if (dragDom && targetRef.value) { |
||||
|
dragDom.addEventListener('mousedown', onMousedown); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const offDraggable = () => { |
||||
|
const dragDom = unrefElement(dragRef); |
||||
|
if (dragDom && targetRef.value) { |
||||
|
dragDom.removeEventListener('mousedown', onMousedown); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const resetPosition = () => { |
||||
|
transform = { |
||||
|
offsetX: 0, |
||||
|
offsetY: 0, |
||||
|
}; |
||||
|
const target = unrefElement(targetRef); |
||||
|
if (target) { |
||||
|
target.style.transform = 'none'; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
onMounted(() => { |
||||
|
watchEffect(() => { |
||||
|
if (draggable.value) { |
||||
|
onDraggable(); |
||||
|
} else { |
||||
|
offDraggable(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
onBeforeUnmount(() => { |
||||
|
offDraggable(); |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
dragging, |
||||
|
resetPosition, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// function getInitialTransform(target: HTMLElement) {
|
||||
|
// let x = 0;
|
||||
|
// let y = 0;
|
||||
|
// const transformValue = window.getComputedStyle(target)?.transform;
|
||||
|
// if (transformValue) {
|
||||
|
// const match = transformValue.match(/matrix\(([^)]+)\)/);
|
||||
|
// if (match) {
|
||||
|
// const values = match[1]?.split(', ') ?? [];
|
||||
|
// // 获取 translateX 值
|
||||
|
// x = Number.parseFloat(`${values[4]}`);
|
||||
|
// // 获取 translateY 值
|
||||
|
// y = Number.parseFloat(`${values[5]}`);
|
||||
|
// }
|
||||
|
// }
|
||||
|
// return { x, y };
|
||||
|
// }
|
||||
@ -0,0 +1,101 @@ |
|||||
|
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal'; |
||||
|
|
||||
|
import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue'; |
||||
|
|
||||
|
import { useStore } from '@vben-core/shared'; |
||||
|
|
||||
|
import VbenModal from './modal.vue'; |
||||
|
import { ModalApi } from './modal-api'; |
||||
|
|
||||
|
const USER_MODAL_INJECT_KEY = Symbol('VBEN_MODAL_INJECT'); |
||||
|
|
||||
|
export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>( |
||||
|
options: ModalApiOptions = {}, |
||||
|
) { |
||||
|
// Modal一般会抽离出来,所以如果有传入 connectedComponent,则表示为外部调用,与内部组件进行连接
|
||||
|
// 外部的Modal通过provide/inject传递api
|
||||
|
|
||||
|
const { connectedComponent } = options; |
||||
|
if (connectedComponent) { |
||||
|
const extendedApi = reactive({}); |
||||
|
const Modal = defineComponent( |
||||
|
(props: TParentModalProps, { attrs, slots }) => { |
||||
|
provide(USER_MODAL_INJECT_KEY, { |
||||
|
extendApi(api: ExtendedModalApi) { |
||||
|
// 不能直接给 reactive 赋值,会丢失响应
|
||||
|
// 不能用 Object.assign,会丢失 api 的原型函数
|
||||
|
Object.setPrototypeOf(extendedApi, api); |
||||
|
}, |
||||
|
options, |
||||
|
}); |
||||
|
checkProps(extendedApi as ExtendedModalApi, { |
||||
|
...props, |
||||
|
...attrs, |
||||
|
...slots, |
||||
|
}); |
||||
|
return () => h(connectedComponent, { ...props, ...attrs }, slots); |
||||
|
}, |
||||
|
{ |
||||
|
inheritAttrs: false, |
||||
|
name: 'VbenParentModal', |
||||
|
}, |
||||
|
); |
||||
|
return [Modal, extendedApi as ExtendedModalApi] as const; |
||||
|
} |
||||
|
|
||||
|
const injectData = inject<any>(USER_MODAL_INJECT_KEY, {}); |
||||
|
|
||||
|
const mergedOptions = { |
||||
|
...injectData.options, |
||||
|
...options, |
||||
|
} as ModalApiOptions; |
||||
|
|
||||
|
// mergedOptions.onOpenChange = (isOpen: boolean) => {
|
||||
|
// options.onOpenChange?.(isOpen);
|
||||
|
// injectData.options?.onOpenChange?.(isOpen);
|
||||
|
// };
|
||||
|
const api = new ModalApi(mergedOptions); |
||||
|
|
||||
|
const extendedApi: ExtendedModalApi = api as never; |
||||
|
|
||||
|
extendedApi.useStore = (selector) => { |
||||
|
return useStore(api.store, selector); |
||||
|
}; |
||||
|
|
||||
|
const Modal = defineComponent( |
||||
|
(props: ModalProps, { attrs, slots }) => { |
||||
|
return () => |
||||
|
h(VbenModal, { ...props, ...attrs, modalApi: extendedApi }, slots); |
||||
|
}, |
||||
|
{ |
||||
|
inheritAttrs: false, |
||||
|
name: 'VbenModal', |
||||
|
}, |
||||
|
); |
||||
|
injectData.extendApi?.(extendedApi); |
||||
|
return [Modal, extendedApi] as const; |
||||
|
} |
||||
|
|
||||
|
async function checkProps(api: ExtendedModalApi, attrs: Record<string, any>) { |
||||
|
if (!attrs || Object.keys(attrs).length === 0) { |
||||
|
return; |
||||
|
} |
||||
|
await nextTick(); |
||||
|
|
||||
|
const state = api?.store?.state; |
||||
|
|
||||
|
if (!state) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const stateKeys = new Set(Object.keys(state)); |
||||
|
|
||||
|
for (const attr of Object.keys(attrs)) { |
||||
|
if (stateKeys.has(attr)) { |
||||
|
// connectedComponent存在时,不要传入Modal的props,会造成复杂度提升,如果你需要修改Modal的props,请使用 useModal 或者api
|
||||
|
console.warn( |
||||
|
`[Vben Modal]: When 'connectedComponent' exists, do not set props or slots '${attr}', which will increase complexity. If you need to modify the props of Modal, please use useModal or api.`, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export { default } from '@vben/tailwind-config'; |
||||
@ -0,0 +1,6 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/tsconfig", |
||||
|
"extends": "@vben/tsconfig/web.json", |
||||
|
"include": ["src"], |
||||
|
"exclude": ["node_modules"] |
||||
|
} |
||||
@ -1,62 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { |
|
||||
AlertDialogAction, |
|
||||
AlertDialogCancel, |
|
||||
AlertDialogContent, |
|
||||
AlertDialogDescription, |
|
||||
AlertDialogFooter, |
|
||||
AlertDialogHeader, |
|
||||
AlertDialog as AlertDialogRoot, |
|
||||
AlertDialogTitle, |
|
||||
} from '../ui/alert-dialog'; |
|
||||
|
|
||||
interface Props { |
|
||||
cancelText?: string; |
|
||||
content?: string; |
|
||||
submitText?: string; |
|
||||
title?: string; |
|
||||
} |
|
||||
|
|
||||
withDefaults(defineProps<Props>(), { |
|
||||
cancelText: '取消', |
|
||||
submitText: '确认', |
|
||||
}); |
|
||||
|
|
||||
const emits = defineEmits<{ |
|
||||
cancel: []; |
|
||||
submit: []; |
|
||||
}>(); |
|
||||
|
|
||||
const openModal = defineModel<boolean>('open'); |
|
||||
|
|
||||
function handleSubmit() { |
|
||||
emits('submit'); |
|
||||
openModal.value = false; |
|
||||
} |
|
||||
|
|
||||
function handleCancel() { |
|
||||
emits('cancel'); |
|
||||
openModal.value = false; |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogRoot v-model:open="openModal"> |
|
||||
<AlertDialogContent> |
|
||||
<AlertDialogHeader> |
|
||||
<AlertDialogTitle>{{ title }}</AlertDialogTitle> |
|
||||
<AlertDialogDescription> |
|
||||
{{ content }} |
|
||||
</AlertDialogDescription> |
|
||||
</AlertDialogHeader> |
|
||||
<AlertDialogFooter> |
|
||||
<AlertDialogCancel @click="handleCancel"> |
|
||||
{{ cancelText }} |
|
||||
</AlertDialogCancel> |
|
||||
<AlertDialogAction @click="handleSubmit"> |
|
||||
{{ submitText }} |
|
||||
</AlertDialogAction> |
|
||||
</AlertDialogFooter> |
|
||||
</AlertDialogContent> |
|
||||
</AlertDialogRoot> |
|
||||
</template> |
|
||||
@ -1 +0,0 @@ |
|||||
export { default as VbenAlertDialog } from './alert-dialog.vue'; |
|
||||
@ -1 +0,0 @@ |
|||||
export { default as VbenSheet } from './sheet.vue'; |
|
||||
@ -1,113 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, useSlots } from 'vue'; |
|
||||
|
|
||||
import { X } from 'lucide-vue-next'; |
|
||||
|
|
||||
import { VbenButton, VbenIconButton } from '../button'; |
|
||||
import { VbenScrollbar } from '../scrollbar'; |
|
||||
import { |
|
||||
Sheet, |
|
||||
SheetClose, |
|
||||
SheetContent, |
|
||||
SheetDescription, |
|
||||
SheetFooter, |
|
||||
SheetHeader, |
|
||||
SheetTitle, |
|
||||
SheetTrigger, |
|
||||
} from '../ui/sheet'; |
|
||||
|
|
||||
interface Props { |
|
||||
cancelText?: string; |
|
||||
description?: string; |
|
||||
showFooter?: boolean; |
|
||||
submitText?: string; |
|
||||
title?: string; |
|
||||
width?: number; |
|
||||
} |
|
||||
|
|
||||
const props = withDefaults(defineProps<Props>(), { |
|
||||
cancelText: '关闭', |
|
||||
description: '', |
|
||||
showFooter: false, |
|
||||
submitText: '确认', |
|
||||
title: '', |
|
||||
width: 400, |
|
||||
}); |
|
||||
|
|
||||
const emits = defineEmits<{ |
|
||||
cancel: []; |
|
||||
submit: []; |
|
||||
}>(); |
|
||||
|
|
||||
const openModal = defineModel<boolean>('open'); |
|
||||
|
|
||||
const slots = useSlots(); |
|
||||
|
|
||||
const contentStyle = computed(() => { |
|
||||
return { |
|
||||
width: `${props.width}px`, |
|
||||
}; |
|
||||
}); |
|
||||
|
|
||||
function handlerSubmit() { |
|
||||
emits('submit'); |
|
||||
openModal.value = false; |
|
||||
} |
|
||||
|
|
||||
// function handleCancel() { |
|
||||
// emits('cancel'); |
|
||||
// openModal.value = false; |
|
||||
// } |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<Sheet v-model:open="openModal"> |
|
||||
<SheetTrigger> |
|
||||
<slot name="trigger"></slot> |
|
||||
</SheetTrigger> |
|
||||
<SheetContent :style="contentStyle" class="!w-full pb-12 sm:rounded-l-lg"> |
|
||||
<SheetHeader |
|
||||
:class="description ? 'h-16' : 'h-12'" |
|
||||
class="border-border flex flex-row items-center justify-between border-b pl-3 pr-3" |
|
||||
> |
|
||||
<div class="flex w-full items-center justify-between"> |
|
||||
<div> |
|
||||
<SheetTitle class="text-left text-lg">{{ title }}</SheetTitle> |
|
||||
<SheetDescription class="text-muted-foreground text-xs"> |
|
||||
{{ description }} |
|
||||
</SheetDescription> |
|
||||
</div> |
|
||||
<slot v-if="slots.extra" name="extra"></slot> |
|
||||
</div> |
|
||||
<SheetClose |
|
||||
as-child |
|
||||
class="data-[state=open]:bg-secondary cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" |
|
||||
> |
|
||||
<VbenIconButton> |
|
||||
<X class="size-4" /> |
|
||||
</VbenIconButton> |
|
||||
</SheetClose> |
|
||||
</SheetHeader> |
|
||||
<div class="h-full pb-16"> |
|
||||
<VbenScrollbar class="h-full" shadow> |
|
||||
<slot></slot> |
|
||||
</VbenScrollbar> |
|
||||
</div> |
|
||||
<SheetFooter v-if="showFooter || slots.footer" as-child> |
|
||||
<div |
|
||||
class="border-border absolute bottom-0 flex h-12 w-full items-center justify-end border-t" |
|
||||
> |
|
||||
<slot v-if="slots.footer" name="footer"></slot> |
|
||||
<template v-else> |
|
||||
<SheetClose as-child> |
|
||||
<VbenButton class="mr-2" variant="outline"> |
|
||||
{{ cancelText }} |
|
||||
</VbenButton> |
|
||||
</SheetClose> |
|
||||
<VbenButton @click="handlerSubmit">{{ submitText }}</VbenButton> |
|
||||
</template> |
|
||||
</div> |
|
||||
</SheetFooter> |
|
||||
</SheetContent> |
|
||||
</Sheet> |
|
||||
</template> |
|
||||
@ -1 +1,2 @@ |
|||||
|
export { default as VbenLoading } from './loading.vue'; |
||||
export { default as VbenSpinner } from './spinner.vue'; |
export { default as VbenSpinner } from './spinner.vue'; |
||||
|
|||||
@ -0,0 +1,137 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref, watch } from 'vue'; |
||||
|
|
||||
|
import { cn } from '@vben-core/shared'; |
||||
|
|
||||
|
interface Props { |
||||
|
class?: string; |
||||
|
/** |
||||
|
* @zh_CN 最小加载时间 |
||||
|
* @en_US Minimum loading time |
||||
|
*/ |
||||
|
minLoadingTime?: number; |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN loading状态开启 |
||||
|
*/ |
||||
|
spinning?: boolean; |
||||
|
/** |
||||
|
* @zh_CN 文字 |
||||
|
*/ |
||||
|
text?: string; |
||||
|
} |
||||
|
|
||||
|
defineOptions({ |
||||
|
name: 'VbenLoading', |
||||
|
}); |
||||
|
|
||||
|
const props = withDefaults(defineProps<Props>(), { |
||||
|
minLoadingTime: 50, |
||||
|
text: '', |
||||
|
}); |
||||
|
// const startTime = ref(0); |
||||
|
const showSpinner = ref(false); |
||||
|
const renderSpinner = ref(true); |
||||
|
const timer = ref<ReturnType<typeof setTimeout>>(); |
||||
|
|
||||
|
watch( |
||||
|
() => props.spinning, |
||||
|
(show) => { |
||||
|
if (!show) { |
||||
|
showSpinner.value = false; |
||||
|
clearTimeout(timer.value); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// startTime.value = performance.now(); |
||||
|
timer.value = setTimeout(() => { |
||||
|
// const loadingTime = performance.now() - startTime.value; |
||||
|
|
||||
|
showSpinner.value = true; |
||||
|
if (showSpinner.value) { |
||||
|
renderSpinner.value = true; |
||||
|
} |
||||
|
}, props.minLoadingTime); |
||||
|
}, |
||||
|
{ |
||||
|
immediate: true, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
function onTransitionEnd() { |
||||
|
if (!showSpinner.value) { |
||||
|
renderSpinner.value = false; |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
:class=" |
||||
|
cn( |
||||
|
'bg-overlay z-100 pointer-events-none absolute left-0 top-0 flex size-full flex-col items-center justify-center backdrop-blur-sm transition-all duration-500', |
||||
|
{ |
||||
|
'invisible opacity-0': !showSpinner, |
||||
|
}, |
||||
|
props.class, |
||||
|
) |
||||
|
" |
||||
|
@transitionend="onTransitionEnd" |
||||
|
> |
||||
|
<span class="dot relative inline-block size-9 text-3xl"> |
||||
|
<i |
||||
|
v-for="index in 4" |
||||
|
:key="index" |
||||
|
class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30" |
||||
|
></i> |
||||
|
</span> |
||||
|
|
||||
|
<div v-if="text" class="mt-4 text-xs">{{ text }}</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.dot { |
||||
|
transform: rotate(45deg); |
||||
|
animation: rotate-ani 1.2s infinite linear; |
||||
|
} |
||||
|
|
||||
|
.dot i { |
||||
|
animation: spin-move-ani 1s infinite linear alternate; |
||||
|
} |
||||
|
|
||||
|
.dot i:nth-child(1) { |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
} |
||||
|
|
||||
|
.dot i:nth-child(2) { |
||||
|
top: 0; |
||||
|
right: 0; |
||||
|
animation-delay: 0.4s; |
||||
|
} |
||||
|
|
||||
|
.dot i:nth-child(3) { |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
animation-delay: 0.8s; |
||||
|
} |
||||
|
|
||||
|
.dot i:nth-child(4) { |
||||
|
bottom: 0; |
||||
|
left: 0; |
||||
|
animation-delay: 1.2s; |
||||
|
} |
||||
|
|
||||
|
@keyframes rotate-ani { |
||||
|
to { |
||||
|
transform: rotate(405deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes spin-move-ani { |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -1,19 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { |
|
||||
type AlertDialogEmits, |
|
||||
type AlertDialogProps, |
|
||||
AlertDialogRoot, |
|
||||
useForwardPropsEmits, |
|
||||
} from 'radix-vue'; |
|
||||
|
|
||||
const props = defineProps<AlertDialogProps>(); |
|
||||
const emits = defineEmits<AlertDialogEmits>(); |
|
||||
|
|
||||
const forwarded = useForwardPropsEmits(props, emits); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogRoot v-bind="forwarded"> |
|
||||
<slot></slot> |
|
||||
</AlertDialogRoot> |
|
||||
</template> |
|
||||
@ -1,28 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, type HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'radix-vue'; |
|
||||
|
|
||||
import { buttonVariants } from '../button'; |
|
||||
|
|
||||
const props = defineProps< |
|
||||
{ class?: HTMLAttributes['class'] } & AlertDialogActionProps |
|
||||
>(); |
|
||||
|
|
||||
const delegatedProps = computed(() => { |
|
||||
const { class: _, ...delegated } = props; |
|
||||
|
|
||||
return delegated; |
|
||||
}); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogAction |
|
||||
v-bind="delegatedProps" |
|
||||
:class="cn(buttonVariants(), props.class)" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</AlertDialogAction> |
|
||||
</template> |
|
||||
@ -1,30 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, type HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'radix-vue'; |
|
||||
|
|
||||
import { buttonVariants } from '../button'; |
|
||||
|
|
||||
const props = defineProps< |
|
||||
{ class?: HTMLAttributes['class'] } & AlertDialogCancelProps |
|
||||
>(); |
|
||||
|
|
||||
const delegatedProps = computed(() => { |
|
||||
const { class: _, ...delegated } = props; |
|
||||
|
|
||||
return delegated; |
|
||||
}); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogCancel |
|
||||
v-bind="delegatedProps" |
|
||||
:class=" |
|
||||
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class) |
|
||||
" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</AlertDialogCancel> |
|
||||
</template> |
|
||||
@ -1,46 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, type HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
import { |
|
||||
AlertDialogContent, |
|
||||
type AlertDialogContentEmits, |
|
||||
type AlertDialogContentProps, |
|
||||
AlertDialogOverlay, |
|
||||
AlertDialogPortal, |
|
||||
useForwardPropsEmits, |
|
||||
} from 'radix-vue'; |
|
||||
|
|
||||
const props = defineProps< |
|
||||
{ class?: HTMLAttributes['class'] } & AlertDialogContentProps |
|
||||
>(); |
|
||||
const emits = defineEmits<AlertDialogContentEmits>(); |
|
||||
|
|
||||
const delegatedProps = computed(() => { |
|
||||
const { class: _, ...delegated } = props; |
|
||||
|
|
||||
return delegated; |
|
||||
}); |
|
||||
|
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogPortal> |
|
||||
<AlertDialogOverlay |
|
||||
class="bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[1000] backdrop-blur-sm" |
|
||||
/> |
|
||||
<AlertDialogContent |
|
||||
v-bind="forwarded" |
|
||||
:class=" |
|
||||
cn( |
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] border-border fixed left-1/2 top-1/2 z-[1000] grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg', |
|
||||
props.class, |
|
||||
) |
|
||||
" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</AlertDialogContent> |
|
||||
</AlertDialogPortal> |
|
||||
</template> |
|
||||
@ -1,29 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, type HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
import { |
|
||||
AlertDialogDescription, |
|
||||
type AlertDialogDescriptionProps, |
|
||||
} from 'radix-vue'; |
|
||||
|
|
||||
const props = defineProps< |
|
||||
{ class?: HTMLAttributes['class'] } & AlertDialogDescriptionProps |
|
||||
>(); |
|
||||
|
|
||||
const delegatedProps = computed(() => { |
|
||||
const { class: _, ...delegated } = props; |
|
||||
|
|
||||
return delegated; |
|
||||
}); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogDescription |
|
||||
v-bind="delegatedProps" |
|
||||
:class="cn('text-muted-foreground text-sm', props.class)" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</AlertDialogDescription> |
|
||||
</template> |
|
||||
@ -1,22 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import type { HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
const props = defineProps<{ |
|
||||
class?: HTMLAttributes['class']; |
|
||||
}>(); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<div |
|
||||
:class=" |
|
||||
cn( |
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', |
|
||||
props.class, |
|
||||
) |
|
||||
" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</div> |
|
||||
</template> |
|
||||
@ -1,17 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import type { HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
const props = defineProps<{ |
|
||||
class?: HTMLAttributes['class']; |
|
||||
}>(); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<div |
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</div> |
|
||||
</template> |
|
||||
@ -1,26 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { computed, type HTMLAttributes } from 'vue'; |
|
||||
|
|
||||
import { cn } from '@vben-core/shared'; |
|
||||
|
|
||||
import { AlertDialogTitle, type AlertDialogTitleProps } from 'radix-vue'; |
|
||||
|
|
||||
const props = defineProps< |
|
||||
{ class?: HTMLAttributes['class'] } & AlertDialogTitleProps |
|
||||
>(); |
|
||||
|
|
||||
const delegatedProps = computed(() => { |
|
||||
const { class: _, ...delegated } = props; |
|
||||
|
|
||||
return delegated; |
|
||||
}); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogTitle |
|
||||
v-bind="delegatedProps" |
|
||||
:class="cn('text-lg font-semibold', props.class)" |
|
||||
> |
|
||||
<slot></slot> |
|
||||
</AlertDialogTitle> |
|
||||
</template> |
|
||||
@ -1,11 +0,0 @@ |
|||||
<script setup lang="ts"> |
|
||||
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'radix-vue'; |
|
||||
|
|
||||
const props = defineProps<AlertDialogTriggerProps>(); |
|
||||
</script> |
|
||||
|
|
||||
<template> |
|
||||
<AlertDialogTrigger v-bind="props"> |
|
||||
<slot></slot> |
|
||||
</AlertDialogTrigger> |
|
||||
</template> |
|
||||
@ -1,9 +0,0 @@ |
|||||
export { default as AlertDialog } from './AlertDialog.vue'; |
|
||||
export { default as AlertDialogAction } from './AlertDialogAction.vue'; |
|
||||
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'; |
|
||||
export { default as AlertDialogContent } from './AlertDialogContent.vue'; |
|
||||
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'; |
|
||||
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'; |
|
||||
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'; |
|
||||
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'; |
|
||||
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'; |
|
||||
@ -1,2 +1,3 @@ |
|||||
export * from './ellipsis-text'; |
export * from './ellipsis-text'; |
||||
export * from './page'; |
export * from './page'; |
||||
|
export * from '@vben-core/popup-ui'; |
||||
|
|||||
@ -1,6 +1,6 @@ |
|||||
import { describe, expect, it } from 'vitest'; |
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
import { findMenuByPath, findRootMenuByPath } from './find-menu-by-path'; |
import { findMenuByPath, findRootMenuByPath } from '../find-menu-by-path'; |
||||
|
|
||||
// 示例菜单数据
|
// 示例菜单数据
|
||||
const menus: any[] = [ |
const menus: any[] = [ |
||||
@ -1,6 +1,6 @@ |
|||||
import { describe, expect, it, vi } from 'vitest'; |
import { describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
import { generateMenus } from './generate-menus'; // 替换为您的实际路径
|
import { generateMenus } from '../generate-menus'; // 替换为您的实际路径
|
||||
import { |
import { |
||||
createRouter, |
createRouter, |
||||
createWebHistory, |
createWebHistory, |
||||
@ -1,10 +1,10 @@ |
|||||
import type { RouteRecordRaw } from 'vue-router'; |
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
import type { RouteModuleType } from './merge-route-modules'; |
import type { RouteModuleType } from '../merge-route-modules'; |
||||
|
|
||||
import { describe, expect, it } from 'vitest'; |
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
import { mergeRouteModules } from './merge-route-modules'; |
import { mergeRouteModules } from '../merge-route-modules'; |
||||
|
|
||||
describe('mergeRouteModules', () => { |
describe('mergeRouteModules', () => { |
||||
it('should merge route modules correctly', () => { |
it('should merge route modules correctly', () => { |
||||
@ -0,0 +1,40 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { useVbenDrawer } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Drawer, drawerApi] = useVbenDrawer({ |
||||
|
onCancel() { |
||||
|
drawerApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// drawerApi.close(); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const list = ref<number[]>([]); |
||||
|
|
||||
|
list.value = Array.from({ length: 10 }, (_v, k) => k + 1); |
||||
|
|
||||
|
function handleUpdate() { |
||||
|
list.value = Array.from({ length: 6 }, (_v, k) => k + 1); |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<Drawer title="自动计算高度"> |
||||
|
<div |
||||
|
v-for="item in list" |
||||
|
:key="item" |
||||
|
class="even:bg-heavy bg-muted flex-center h-[220px] w-full" |
||||
|
> |
||||
|
{{ item }} |
||||
|
</div> |
||||
|
|
||||
|
<template #prepend-footer> |
||||
|
<Button type="link" @click="handleUpdate">点击更新数据</Button> |
||||
|
</template> |
||||
|
</Drawer> |
||||
|
</template> |
||||
@ -0,0 +1,32 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { useVbenDrawer } from '@vben/common-ui'; |
||||
|
|
||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Drawer, drawerApi] = useVbenDrawer({ |
||||
|
onCancel() { |
||||
|
drawerApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// drawerApi.close(); |
||||
|
}, |
||||
|
onOpenChange(isOpen) { |
||||
|
if (isOpen) { |
||||
|
drawerApi.setState({ loading: true }); |
||||
|
setTimeout(() => { |
||||
|
drawerApi.setState({ loading: false }); |
||||
|
}, 2000); |
||||
|
} |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Drawer title="基础抽屉示例" title-tooltip="标题提示内容"> |
||||
|
<template #extra> extra </template> |
||||
|
base demo |
||||
|
|
||||
|
<!-- <template #prepend-footer> slot </template> --> |
||||
|
<!-- <template #append-footer> prepend slot </template> --> |
||||
|
</Drawer> |
||||
|
</template> |
||||
@ -0,0 +1,31 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { useVbenDrawer } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Drawer, drawerApi] = useVbenDrawer({ |
||||
|
onCancel() { |
||||
|
drawerApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// drawerApi.close(); |
||||
|
}, |
||||
|
title: '动态修改配置示例', |
||||
|
}); |
||||
|
|
||||
|
// const state = drawerApi.useStore(); |
||||
|
|
||||
|
function handleUpdateTitle() { |
||||
|
drawerApi.setState({ title: '内部动态标题' }); |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<Drawer> |
||||
|
<div class="flex-col-center"> |
||||
|
<Button class="mb-3" type="primary" @click="handleUpdateTitle()"> |
||||
|
内部动态修改标题 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</Drawer> |
||||
|
</template> |
||||
@ -0,0 +1,90 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { Page, useVbenDrawer } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, Card } from 'ant-design-vue'; |
||||
|
|
||||
|
import AutoHeightDemo from './auto-height-demo.vue'; |
||||
|
import BaseDemo from './base-demo.vue'; |
||||
|
import DynamicDemo from './dynamic-demo.vue'; |
||||
|
import SharedDataDemo from './shared-data-demo.vue'; |
||||
|
|
||||
|
const [BaseDrawer, baseDrawerApi] = useVbenDrawer({ |
||||
|
// 链接抽离的组件 |
||||
|
connectedComponent: BaseDemo, |
||||
|
}); |
||||
|
|
||||
|
const [AutoHeightDrawer, autoHeightDrawerApi] = useVbenDrawer({ |
||||
|
// 链接抽离的组件 |
||||
|
connectedComponent: AutoHeightDemo, |
||||
|
}); |
||||
|
|
||||
|
const [DynamicDrawer, dynamicDrawerApi] = useVbenDrawer({ |
||||
|
connectedComponent: DynamicDemo, |
||||
|
}); |
||||
|
|
||||
|
const [SharedDataDrawer, sharedDrawerApi] = useVbenDrawer({ |
||||
|
connectedComponent: SharedDataDemo, |
||||
|
}); |
||||
|
|
||||
|
function openBaseDrawer() { |
||||
|
baseDrawerApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openAutoHeightDrawer() { |
||||
|
autoHeightDrawerApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openDynamicDrawer() { |
||||
|
dynamicDrawerApi.open(); |
||||
|
} |
||||
|
|
||||
|
function handleUpdateTitle() { |
||||
|
dynamicDrawerApi.setState({ title: '外部动态标题' }); |
||||
|
dynamicDrawerApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openSharedDrawer() { |
||||
|
sharedDrawerApi.setData({ |
||||
|
content: '外部传递的数据 content', |
||||
|
payload: '外部传递的数据 payload', |
||||
|
}); |
||||
|
sharedDrawerApi.open(); |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Page |
||||
|
description="抽屉组件通常用于在当前页面上显示一个覆盖层,用以展示重要信息或提供用户交互界面。" |
||||
|
title="抽屉组件示例" |
||||
|
> |
||||
|
<BaseDrawer /> |
||||
|
<AutoHeightDrawer /> |
||||
|
<DynamicDrawer /> |
||||
|
<SharedDataDrawer /> |
||||
|
|
||||
|
<Card class="mb-4" title="基本使用"> |
||||
|
<p class="mb-3">一个基础的抽屉示例</p> |
||||
|
<Button type="primary" @click="openBaseDrawer">打开抽屉</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="内容高度自适应滚动"> |
||||
|
<p class="mb-3">可根据内容自动计算滚动高度</p> |
||||
|
<Button type="primary" @click="openAutoHeightDrawer">打开抽屉</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="动态配置示例"> |
||||
|
<p class="mb-3">通过 setState 动态调整抽屉数据</p> |
||||
|
<Button type="primary" @click="openDynamicDrawer">打开抽屉</Button> |
||||
|
<Button class="ml-2" type="primary" @click="handleUpdateTitle"> |
||||
|
从外部修改标题并打开 |
||||
|
</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="内外数据共享示例"> |
||||
|
<p class="mb-3">通过共享 sharedData 来进行数据交互</p> |
||||
|
<Button type="primary" @click="openSharedDrawer"> |
||||
|
打开抽屉并传递数据 |
||||
|
</Button> |
||||
|
</Card> |
||||
|
</Page> |
||||
|
</template> |
||||
@ -0,0 +1,29 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { useVbenDrawer } from '@vben/common-ui'; |
||||
|
|
||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
const data = ref(); |
||||
|
|
||||
|
const [Drawer, drawerApi] = useVbenDrawer({ |
||||
|
onCancel() { |
||||
|
drawerApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// drawerApi.close(); |
||||
|
}, |
||||
|
onOpenChange(isOpen: boolean) { |
||||
|
if (isOpen) { |
||||
|
data.value = drawerApi.getData<Record<string, any>>(); |
||||
|
} |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Drawer title="数据共享示例"> |
||||
|
<div class="flex-col-center">外部传递数据: {{ data }}</div> |
||||
|
</Drawer> |
||||
|
</template> |
||||
@ -0,0 +1,40 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
onCancel() { |
||||
|
modalApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// modalApi.close(); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const list = ref<number[]>([]); |
||||
|
|
||||
|
list.value = Array.from({ length: 10 }, (_v, k) => k + 1); |
||||
|
|
||||
|
function handleUpdate() { |
||||
|
list.value = Array.from({ length: 6 }, (_v, k) => k + 1); |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<Modal title="自动计算高度"> |
||||
|
<div |
||||
|
v-for="item in list" |
||||
|
:key="item" |
||||
|
class="even:bg-heavy bg-muted flex-center h-[220px] w-full" |
||||
|
> |
||||
|
{{ item }} |
||||
|
</div> |
||||
|
|
||||
|
<template #prepend-footer> |
||||
|
<Button type="link" @click="handleUpdate">点击更新数据</Button> |
||||
|
</template> |
||||
|
</Modal> |
||||
|
</template> |
||||
@ -0,0 +1,28 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
onCancel() { |
||||
|
modalApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// modalApi.close(); |
||||
|
}, |
||||
|
onOpenChange(isOpen) { |
||||
|
if (isOpen) { |
||||
|
modalApi.setState({ loading: true }); |
||||
|
setTimeout(() => { |
||||
|
modalApi.setState({ loading: false }); |
||||
|
}, 2000); |
||||
|
} |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Modal class="w-[600px]" title="基础弹窗示例" title-tooltip="标题提示内容"> |
||||
|
base demo |
||||
|
</Modal> |
||||
|
</template> |
||||
@ -0,0 +1,19 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
draggable: true, |
||||
|
onCancel() { |
||||
|
modalApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// modalApi.close(); |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Modal title="可拖拽示例"> 鼠标移动到 header 上,可拖拽弹窗 </Modal> |
||||
|
</template> |
||||
@ -0,0 +1,41 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, message } from 'ant-design-vue'; |
||||
|
|
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
draggable: true, |
||||
|
onCancel() { |
||||
|
modalApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// modalApi.close(); |
||||
|
}, |
||||
|
title: '动态修改配置示例', |
||||
|
}); |
||||
|
|
||||
|
const state = modalApi.useStore(); |
||||
|
|
||||
|
function handleUpdateTitle() { |
||||
|
modalApi.setState({ title: '内部动态标题' }); |
||||
|
} |
||||
|
|
||||
|
function handleToggleFullscreen() { |
||||
|
modalApi.setState((prev) => { |
||||
|
return { ...prev, fullscreen: !prev.fullscreen }; |
||||
|
}); |
||||
|
} |
||||
|
</script> |
||||
|
<template> |
||||
|
<Modal> |
||||
|
<div class="flex-col-center"> |
||||
|
<Button class="mb-3" type="primary" @click="handleUpdateTitle()"> |
||||
|
内部动态修改标题 |
||||
|
</Button> |
||||
|
<Button class="mb-3" type="primary" @click="handleToggleFullscreen()"> |
||||
|
{{ state.fullscreen ? '退出全屏' : '打开全屏' }} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</template> |
||||
@ -0,0 +1,104 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { Page, useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { Button, Card } from 'ant-design-vue'; |
||||
|
|
||||
|
import AutoHeightDemo from './auto-height-demo.vue'; |
||||
|
import BaseDemo from './base-demo.vue'; |
||||
|
import DragDemo from './drag-demo.vue'; |
||||
|
import DynamicDemo from './dynamic-demo.vue'; |
||||
|
import SharedDataDemo from './shared-data-demo.vue'; |
||||
|
|
||||
|
const [BaseModal, baseModalApi] = useVbenModal({ |
||||
|
// 链接抽离的组件 |
||||
|
connectedComponent: BaseDemo, |
||||
|
}); |
||||
|
|
||||
|
const [AutoHeightModal, autoHeightModalApi] = useVbenModal({ |
||||
|
connectedComponent: AutoHeightDemo, |
||||
|
}); |
||||
|
|
||||
|
const [DragModal, dragModalApi] = useVbenModal({ |
||||
|
connectedComponent: DragDemo, |
||||
|
}); |
||||
|
|
||||
|
const [DynamicModal, dynamicModalApi] = useVbenModal({ |
||||
|
connectedComponent: DynamicDemo, |
||||
|
}); |
||||
|
|
||||
|
const [SharedDataModal, sharedModalApi] = useVbenModal({ |
||||
|
connectedComponent: SharedDataDemo, |
||||
|
}); |
||||
|
|
||||
|
function openBaseModal() { |
||||
|
baseModalApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openAutoHeightModal() { |
||||
|
autoHeightModalApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openDargModal() { |
||||
|
dragModalApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openDynamicModal() { |
||||
|
dynamicModalApi.open(); |
||||
|
} |
||||
|
|
||||
|
function openSharedModal() { |
||||
|
sharedModalApi.setData({ |
||||
|
content: '外部传递的数据 content', |
||||
|
payload: '外部传递的数据 payload', |
||||
|
}); |
||||
|
sharedModalApi.open(); |
||||
|
} |
||||
|
|
||||
|
function handleUpdateTitle() { |
||||
|
dynamicModalApi.setState({ title: '外部动态标题' }); |
||||
|
dynamicModalApi.open(); |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Page |
||||
|
description="弹窗组件常用于在不离开当前页面的情况下,显示额外的信息、表单或操作提示。" |
||||
|
title="弹窗组件示例" |
||||
|
> |
||||
|
<BaseModal /> |
||||
|
<AutoHeightModal /> |
||||
|
<DragModal /> |
||||
|
<DynamicModal /> |
||||
|
<SharedDataModal /> |
||||
|
|
||||
|
<Card class="mb-4" title="基本使用"> |
||||
|
<p class="mb-3">一个基础的弹窗示例</p> |
||||
|
<Button type="primary" @click="openBaseModal">打开弹窗</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="内容高度自适应"> |
||||
|
<p class="mb-3">可根据内容并自动调整高度</p> |
||||
|
<Button type="primary" @click="openAutoHeightModal">打开弹窗</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="可拖拽示例"> |
||||
|
<p class="mb-3">配置 draggable 可开启拖拽功能</p> |
||||
|
<Button type="primary" @click="openDargModal">打开弹窗</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="动态配置示例"> |
||||
|
<p class="mb-3">通过 setState 动态调整弹窗数据</p> |
||||
|
<Button type="primary" @click="openDynamicModal">打开弹窗</Button> |
||||
|
<Button class="ml-2" type="primary" @click="handleUpdateTitle"> |
||||
|
从外部修改标题并打开 |
||||
|
</Button> |
||||
|
</Card> |
||||
|
|
||||
|
<Card class="mb-4" title="内外数据共享示例"> |
||||
|
<p class="mb-3">通过共享 sharedData 来进行数据交互</p> |
||||
|
<Button type="primary" @click="openSharedModal"> |
||||
|
打开弹窗并传递数据 |
||||
|
</Button> |
||||
|
</Card> |
||||
|
</Page> |
||||
|
</template> |
||||
@ -0,0 +1,29 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
|
||||
|
import { message } from 'ant-design-vue'; |
||||
|
|
||||
|
const data = ref(); |
||||
|
|
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
onCancel() { |
||||
|
modalApi.close(); |
||||
|
}, |
||||
|
onConfirm() { |
||||
|
message.info('onConfirm'); |
||||
|
// modalApi.close(); |
||||
|
}, |
||||
|
onOpenChange(isOpen: boolean) { |
||||
|
if (isOpen) { |
||||
|
data.value = modalApi.getData<Record<string, any>>(); |
||||
|
} |
||||
|
}, |
||||
|
}); |
||||
|
</script> |
||||
|
<template> |
||||
|
<Modal title="数据共享示例"> |
||||
|
<div class="flex-col-center">外部传递数据: {{ data }}</div> |
||||
|
</Modal> |
||||
|
</template> |
||||
Loading…
Reference in new issue