feat: add modal and drawer components and examples (#4229)

* feat: add modal component

* feat: add drawer component

* feat: apply new modal and drawer components to the layout

* chore: typo

* feat: add some unit tests
This commit is contained in:
Vben
2024-08-25 23:40:52 +08:00
committed by GitHub
parent edb55b1fc0
commit 20a3868594
96 changed files with 2700 additions and 743 deletions

View File

@@ -168,9 +168,10 @@
"i18n-ally.localesPaths": [
"packages/locales/src/langs",
"playground/src/langs",
"playground/src/locales/langs",
"apps/*/src/locales/langs"
],
"i18n-ally.pathMatcher": "{locale}.json",
"i18n-ally.enabledParsers": ["json", "ts", "js", "yaml"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN",

View File

@@ -14,7 +14,7 @@ export interface UserPayload extends UserInfo {
}
export function generateAccessToken(user: UserInfo) {
return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '1d' });
return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '7d' });
}
export function generateRefreshToken(user: UserInfo) {

View File

@@ -37,6 +37,7 @@
"astro",
"ui-kit",
"styl",
"vnode",
"nocheck",
"prefixs",
"vitepress",
@@ -53,6 +54,9 @@
"**/*-dist/**",
"**/icons/**",
"pnpm-lock.yaml",
"**/*.log"
"**/*.log",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**"
]
}

View File

@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { generatorContentHash } from './hash';
import { generatorContentHash } from '../hash';
describe('generatorContentHash', () => {
it('should generate an MD5 hash for the content', () => {

View File

@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest';
import { toPosixPath } from './path';
import { toPosixPath } from '../path';
describe('toPosixPath', () => {
// 测试 Windows 风格路径到 POSIX 风格路径的转换

View File

@@ -34,13 +34,6 @@
transition: all 0.6s ease-out;
}
.loading .dots {
display: flex;
align-items: center;
justify-content: center;
padding: 98px;
}
.loading .title {
margin-top: 36px;
font-size: 30px;
@@ -109,6 +102,6 @@
}
</style>
<div class="loading" id="__app-loading__">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
<span class="dot"><i></i><i></i><i></i><i></i></span>
<div class="title"><%= VITE_APP_TITLE %></div>
</div>

View File

@@ -20,12 +20,14 @@ export {
CornerDownLeft,
Disc as IconDefault,
Ellipsis,
Expand,
ExternalLink,
Eye,
EyeOff,
FoldHorizontal,
Fullscreen,
Github,
Info,
InspectionPanel,
Languages,
LoaderCircle,
@@ -46,6 +48,7 @@ export {
Search,
SearchX,
Settings,
Shrink,
Sun,
SunMoon,
SwatchBook,

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StorageManager } from './storage-manager';
import { StorageManager } from '../storage-manager';
describe('storageManager', () => {
let storageManager: StorageManager;

View File

@@ -5,7 +5,7 @@ import {
convertToHslCssVar,
convertToRgb,
isValidColor,
} from './convert';
} from '../convert';
describe('color conversion functions', () => {
it('should correctly convert color to HSL format', () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { diff } from './diff';
import { diff } from '../diff';
describe('diff function', () => {
it('should return an empty object when comparing identical objects', () => {

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getElementVisibleRect } from './dom'; // 假设函数位于 utils.ts 中
import { getElementVisibleRect } from '../dom'; // 假设函数位于 utils.ts 中
describe('getElementVisibleRect', () => {
// 设置浏览器视口尺寸的 mock

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest';
import {
getFirstNonNullOrUndefined,
isEmpty,
isHttpUrl,
isObject,
isUndefined,
isWindow,
} from './inference';
} from '../inference';
describe('isHttpUrl', () => {
it("should return true when given 'http://example.com'", () => {
@@ -103,7 +104,6 @@ describe('isObject', () => {
it('should return false for non-objects', () => {
expect(isObject(null)).toBe(false);
expect(isObject()).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject('string')).toBe(false);
expect(isObject(true)).toBe(false);
@@ -112,3 +112,56 @@ describe('isObject', () => {
expect(isObject(/regex/)).toBe(true);
});
});
describe('getFirstNonNullOrUndefined', () => {
describe('getFirstNonNullOrUndefined', () => {
it('should return the first non-null and non-undefined value for a number array', () => {
expect(getFirstNonNullOrUndefined<number>(undefined, null, 0, 42)).toBe(
0,
);
expect(getFirstNonNullOrUndefined<number>(null, undefined, 42, 123)).toBe(
42,
);
});
it('should return the first non-null and non-undefined value for a string array', () => {
expect(
getFirstNonNullOrUndefined<string>(undefined, null, '', 'hello'),
).toBe('');
expect(
getFirstNonNullOrUndefined<string>(null, undefined, 'test', 'world'),
).toBe('test');
});
it('should return undefined if all values are null or undefined', () => {
expect(getFirstNonNullOrUndefined(undefined, null)).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should work with a single value', () => {
expect(getFirstNonNullOrUndefined(42)).toBe(42);
expect(getFirstNonNullOrUndefined()).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should handle mixed types correctly', () => {
expect(
getFirstNonNullOrUndefined<number | object | string>(
undefined,
null,
'test',
123,
{ key: 'value' },
),
).toBe('test');
expect(
getFirstNonNullOrUndefined<number | object | string>(
null,
undefined,
[1, 2, 3],
'string',
),
).toEqual([1, 2, 3]);
});
});
});

View File

@@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
} from './letter';
} from '../letter';
// 编写测试用例
describe('capitalizeFirstLetter', () => {
@@ -76,3 +77,41 @@ describe('toCamelCase', () => {
expect(toCamelCase('Child', 'Parent')).toBe('ParentChild');
});
});
describe('kebabToCamelCase', () => {
it('should convert kebab-case to camelCase correctly', () => {
expect(kebabToCamelCase('my-component-name')).toBe('myComponentName');
});
it('should handle multiple consecutive hyphens', () => {
expect(kebabToCamelCase('my--component--name')).toBe('myComponentName');
});
it('should trim leading and trailing hyphens', () => {
expect(kebabToCamelCase('-my-component-name-')).toBe('myComponentName');
});
it('should preserve the case of the first word', () => {
expect(kebabToCamelCase('My-component-name')).toBe('MyComponentName');
});
it('should convert a single word correctly', () => {
expect(kebabToCamelCase('component')).toBe('component');
});
it('should return an empty string if input is empty', () => {
expect(kebabToCamelCase('')).toBe('');
});
it('should handle strings with no hyphens', () => {
expect(kebabToCamelCase('mycomponentname')).toBe('mycomponentname');
});
it('should handle strings with only hyphens', () => {
expect(kebabToCamelCase('---')).toBe('');
});
it('should handle mixed case inputs', () => {
expect(kebabToCamelCase('my-Component-Name')).toBe('myComponentName');
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { filterTree, mapTree, traverseTreeValues } from './tree';
import { filterTree, mapTree, traverseTreeValues } from '../tree';
describe('traverseTreeValues', () => {
interface Node {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { uniqueByField } from './unique';
import { uniqueByField } from '../unique';
describe('uniqueByField', () => {
it('should return an array with unique items based on id field', () => {

View File

@@ -1,6 +1,6 @@
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', () => {
// 模拟初始的内联样式表内容

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { openWindow } from './window'; // 假设你的函数在 'openWindow' 文件中
import { openWindow } from '../window'; // 假设你的函数在 'openWindow' 文件中
describe('openWindow', () => {
// 保存原始的 window.open 函数

View File

@@ -24,7 +24,7 @@ function isUndefined(value?: unknown): value is undefined {
* @param {T} value 要检查的值。
* @returns {boolean} 如果值为空返回true否则返回false。
*/
function isEmpty<T = unknown>(value: T): value is T {
function isEmpty<T = unknown>(value?: T): value is T {
if (value === null || value === undefined) {
return true;
}
@@ -105,7 +105,42 @@ function isNumber(value: any): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
* Returns the first value in the provided list that is neither `null` nor `undefined`.
*
* This function iterates over the input values and returns the first one that is
* not strictly equal to `null` or `undefined`. If all values are either `null` or
* `undefined`, it returns `undefined`.
*
* @template T - The type of the input values.
* @param {...(T | null | undefined)[]} values - A list of values to evaluate.
* @returns {T | undefined} - The first value that is not `null` or `undefined`, or `undefined` if none are found.
*
* @example
* // Returns 42 because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(undefined, null, 42, 'hello'); // 42
*
* @example
* // Returns 'hello' because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(null, undefined, 'hello', 123); // 'hello'
*
* @example
* // Returns undefined because all values are either null or undefined.
* getFirstNonNullOrUndefined(undefined, null); // undefined
*/
function getFirstNonNullOrUndefined<T>(
...values: (null | T | undefined)[]
): T | undefined {
for (const value of values) {
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
}
export {
getFirstNonNullOrUndefined,
isEmpty,
isFunction,
isHttpUrl,

View File

@@ -29,4 +29,19 @@ function toCamelCase(key: string, parentKey: string): string {
return parentKey + key.charAt(0).toUpperCase() + key.slice(1);
}
export { capitalizeFirstLetter, toCamelCase, toLowerCaseFirstLetter };
function kebabToCamelCase(str: string): string {
return str
.split('-')
.filter(Boolean)
.map((word, index) =>
index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1),
)
.join('');
}
export {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
};

View File

@@ -2,7 +2,7 @@ import type { SortableOptions } from 'sortablejs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSortable } from './use-sortable';
import { useSortable } from '../use-sortable';
describe('useSortable', () => {
beforeEach(() => {
@@ -30,7 +30,6 @@ describe('useSortable', () => {
// Import sortablejs to access the mocked create function
const Sortable = await import(
// @ts-expect-error - This is a dynamic import
'sortablejs/modular/sortable.complete.esm.js'
);

View File

@@ -1,5 +1,6 @@
export * from './use-content-style';
export * from './use-namespace';
export * from './use-priority-value';
export * from './use-sortable';
export {
useEmitAsProps,

View File

@@ -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;
}

View File

@@ -149,14 +149,6 @@ function usePreferences() {
return enable && globalLockScreen;
});
/**
* @zh_CN 是否启用全局偏好设置快捷键
*/
const globalPreferencesShortcutKey = computed(() => {
const { enable, globalPreferences } = shortcutKeysPreferences.value;
return enable && globalPreferences;
});
return {
authPanelCenter,
authPanelLeft,
@@ -165,7 +157,6 @@ function usePreferences() {
diffPreference,
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalPreferencesShortcutKey,
globalSearchShortcutKey,
isDark,
isFullContent,

View File

@@ -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'],
},
],
});

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -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');
});
});

View File

@@ -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 }));
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
export type * from './drawer';
export { default as VbenDrawer } from './drawer.vue';
export { useVbenDrawer } from './use-drawer';

View File

@@ -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.`,
);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './drawer';
export * from './modal';

View File

@@ -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');
});
});

View File

@@ -0,0 +1,3 @@
export type * from './modal';
export { default as VbenModal } from './modal.vue';
export { useVbenModal } from './use-modal';

View File

@@ -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 }));
}
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 };
// }

View File

@@ -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.`,
);
}
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -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>

View File

@@ -1 +0,0 @@
export { default as VbenAlertDialog } from './alert-dialog.vue';

View File

@@ -1,4 +1,3 @@
export * from './alert-dialog';
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
@@ -20,11 +19,9 @@ export * from './popover';
export * from './render-content';
export * from './scrollbar';
export * from './segmented';
export * from './sheet';
export * from './spinner';
export * from './swap';
export * from './tooltip';
export * from './ui/alert-dialog';
export * from './ui/avatar';
export * from './ui/badge';
export * from './ui/breadcrumb';

View File

@@ -1 +0,0 @@
export { default as VbenSheet } from './sheet.vue';

View File

@@ -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>

View File

@@ -1 +1,2 @@
export { default as VbenLoading } from './loading.vue';
export { default as VbenSpinner } from './spinner.vue';

View File

@@ -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>

View File

@@ -1,7 +1,10 @@
<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
@@ -14,7 +17,7 @@ interface Props {
}
defineOptions({
name: 'Spinner',
name: 'VbenSpinner',
});
const props = withDefaults(defineProps<Props>(), {
@@ -58,19 +61,34 @@ function onTransitionEnd() {
<template>
<div
:class="{
'invisible opacity-0': !showSpinner,
}"
class="flex-center bg-overlay z-100 absolute left-0 top-0 size-full backdrop-blur-sm transition-all duration-500"
:class="
cn(
'flex-center bg-overlay z-100 absolute left-0 top-0 size-full backdrop-blur-sm transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
props.class,
)
"
@transitionend="onTransitionEnd"
>
<div
class="loader before:bg-primary/50 after:bg-primary relative h-12 w-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:animate-[loader-shadow-ani_0.5s_linear_infinite] before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:animate-[loader-jump-ani_0.5s_linear_infinite] after:rounded after:content-['']"
class="loader before:bg-primary/50 after:bg-primary relative size-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:content-['']"
></div>
</div>
</template>
<style>
<style scoped>
.loader {
&::before {
animation: loader-shadow-ani 0.5s linear infinite;
}
&::after {
animation: loader-jump-ani 0.5s linear infinite;
}
}
@keyframes loader-jump-ani {
15% {
border-bottom-right-radius: 3px;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared';
@@ -17,7 +17,8 @@ import {
const props = withDefaults(
defineProps<
{
class?: HTMLAttributes['class'];
class?: any;
closeClass?: any;
showClose?: boolean;
} & DialogContentProps
>(),
@@ -32,6 +33,12 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref<InstanceType<typeof DialogContent> | null>(null);
defineExpose({
getContentRef: () => contentRef.value,
});
</script>
<template>
@@ -41,10 +48,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
@click="() => emits('close')"
/>
<DialogContent
ref="contentRef"
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 -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg outline-none duration-300 sm:rounded-lg',
'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-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed z-[1000] w-full p-6 shadow-lg outline-none sm:rounded-xl',
props.class,
)
"
@@ -53,7 +61,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<DialogClose
v-if="showClose"
class="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
:class="
cn(
'data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none',
props.closeClass,
)
"
@click="() => emits('close')"
>
<Cross2Icon class="h-4 w-4" />

View File

@@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border',
'fixed z-[1000] bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border',
{
defaultVariants: {
side: 'right',
@@ -10,9 +10,9 @@ export const sheetVariants = cva(
side: {
bottom:
'inset-x-0 bottom-0 border-t border-border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left ',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
},
},

View File

@@ -20,6 +20,7 @@
}
},
"dependencies": {
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",

View File

@@ -95,41 +95,43 @@ function handleExpand() {
}
</script>
<template>
<VbenTooltip
:content-style="{
...tooltipOverlayStyle,
maxWidth: `${defaultTooltipMaxWidth}px`,
fontSize: `${tooltipFontSize}px`,
color: tooltipColor,
backgroundColor: tooltipBackgroundColor,
}"
:disabled="!props.tooltip || isExpand"
:side="placement"
>
<slot name="tooltip">
<slot></slot>
</slot>
<template #trigger>
<div
ref="ellipsis"
:class="{
'!cursor-pointer': expand,
['inline-block truncate']: line === 1,
[$style.ellipsisMultiLine]: line > 1,
}"
:style="{
'-webkit-line-clamp': isExpand ? '' : line,
'max-width': textMaxWidth,
}"
class="cursor-text overflow-hidden"
@click="handleExpand"
v-bind="$attrs"
>
<div>
<VbenTooltip
:content-style="{
...tooltipOverlayStyle,
maxWidth: `${defaultTooltipMaxWidth}px`,
fontSize: `${tooltipFontSize}px`,
color: tooltipColor,
backgroundColor: tooltipBackgroundColor,
}"
:disabled="!props.tooltip || isExpand"
:side="placement"
>
<slot name="tooltip">
<slot></slot>
</div>
</template>
</VbenTooltip>
</slot>
<template #trigger>
<div
ref="ellipsis"
:class="{
'!cursor-pointer': expand,
['inline-block truncate']: line === 1,
[$style.ellipsisMultiLine]: line > 1,
}"
:style="{
'-webkit-line-clamp': isExpand ? '' : line,
'max-width': textMaxWidth,
}"
class="cursor-text overflow-hidden"
@click="handleExpand"
v-bind="$attrs"
>
<slot></slot>
</div>
</template>
</VbenTooltip>
</div>
</template>
<style module>

View File

@@ -1,2 +1,3 @@
export * from './ellipsis-text';
export * from './page';
export * from '@vben-core/popup-ui';

View File

@@ -1,15 +1,11 @@
<script setup lang="ts">
import type { AuthenticationProps, LoginAndRegisterParams } from './types';
import { watch } from 'vue';
import { useForwardPropsEmits } from '@vben/hooks';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
VbenAvatar,
VisuallyHidden,
} from '@vben-core/shadcn-ui';
import { useVbenModal } from '@vben-core/popup-ui';
import { VbenAvatar } from '@vben-core/shadcn-ui';
import AuthenticationLogin from './login.vue';
@@ -32,32 +28,37 @@ const emit = defineEmits<{
const open = defineModel<boolean>('open');
const forwarded = useForwardPropsEmits(props, emit);
const [Modal, modalApi] = useVbenModal();
watch(
() => open.value,
(val) => {
modalApi.setState({ isOpen: val });
},
);
</script>
<template>
<div>
<Dialog v-model:open="open">
<DialogContent
:show-close="false"
class="top-1/2 h-full w-full translate-y-[-50%] border-none p-4 py-12 text-center shadow-xl sm:w-[600px] sm:rounded-2xl md:h-[unset] md:px-14 md:pt-12"
@escape-key-down="(e) => e.preventDefault()"
@interact-outside="(e) => e.preventDefault()"
>
<DialogTitle>
<VbenAvatar :src="avatar" class="mx-auto size-20" />
</DialogTitle>
<VisuallyHidden>
<DialogDescription />
</VisuallyHidden>
<AuthenticationLogin
v-bind="forwarded"
:show-forget-password="false"
:show-register="false"
:show-remember-me="false"
:sub-title="$t('authentication.loginAgainSubTitle')"
:title="$t('authentication.loginAgainTitle')"
/>
</DialogContent>
</Dialog>
<Modal
:closable="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:footer="false"
:fullscreen-button="false"
class="border-none px-10 py-6 text-center shadow-xl sm:w-[600px] sm:rounded-2xl md:h-[unset]"
header-class="hidden"
>
<VbenAvatar :src="avatar" class="mx-auto mb-6 size-20" />
<AuthenticationLogin
v-bind="forwarded"
:show-forget-password="false"
:show-register="false"
:show-remember-me="false"
:sub-title="$t('authentication.loginAgainSubTitle')"
:title="$t('authentication.loginAgainTitle')"
/>
</Modal>
</div>
</template>

View File

@@ -22,6 +22,7 @@
"dependencies": {
"@vben-core/layout-ui": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/tabs-ui": "workspace:*",
"@vben/constants": "workspace:*",

View File

@@ -12,17 +12,9 @@ import {
} from '@vben/icons';
import { $t } from '@vben/locales';
import { isWindowsOs } from '@vben/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@vben-core/shadcn-ui';
import { useVbenModal } from '@vben-core/popup-ui';
import { useMagicKeys, useToggle, whenever } from '@vueuse/core';
import { useMagicKeys, whenever } from '@vueuse/core';
import SearchPanel from './search-panel.vue';
@@ -38,12 +30,18 @@ const props = withDefaults(
},
);
const [open, toggleOpen] = useToggle();
const [Modal, modalApi] = useVbenModal({
onCancel() {
modalApi.close();
},
});
const open = modalApi.useStore((state) => state.isOpen);
const keyword = ref('');
const searchInputRef = ref<HTMLInputElement>();
function handleClose() {
open.value = false;
modalApi.close();
keyword.value = '';
}
@@ -51,7 +49,7 @@ const keys = useMagicKeys();
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
whenever(cmd!, () => {
if (props.enableShortcutKey) {
open.value = true;
modalApi.open();
}
});
@@ -75,6 +73,10 @@ const toggleKeydownListener = () => {
}
};
const toggleOpen = () => {
open.value ? modalApi.close() : modalApi.open();
};
watch(() => props.enableShortcutKey, toggleKeydownListener);
onMounted(() => {
@@ -88,67 +90,58 @@ onMounted(() => {
<template>
<div>
<Dialog :open="open">
<DialogTrigger as-child>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<Search
class="text-muted-foreground group-hover:text-foreground size-3 group-hover:opacity-100"
<Modal :fullscreen-button="false" class="w-[600px]" header-class="py-2">
<template #title>
<div class="flex items-center">
<Search class="text-muted-foreground mr-2 size-4" />
<input
ref="searchInputRef"
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm font-normal outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</DialogTrigger>
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[10%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogHeader>
<DialogTitle
class="border-border flex h-12 items-center gap-3 border-b px-5 font-normal"
>
<Search class="text-muted-foreground size-4" />
<input
ref="searchInputRef"
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<DialogFooter
class="text-muted-foreground border-border hidden flex-row rounded-b-2xl border-t px-4 py-2 text-xs sm:flex sm:justify-start sm:gap-x-4"
>
<div class="flex items-center">
</template>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<template #footer>
<div class="flex w-full justify-start text-xs">
<div class="mr-2 flex items-center">
<CornerDownLeft class="mr-1 size-3" />
{{ $t('widgets.search.select') }}
</div>
<div class="flex items-center">
<ArrowUp class="mr-2 size-3" />
<ArrowDown class="mr-2 size-3" />
<div class="mr-2 flex items-center">
<ArrowUp class="mr-1 size-3" />
<ArrowDown class="mr-1 size-3" />
{{ $t('widgets.search.navigate') }}
</div>
<div class="flex items-center">
<MdiKeyboardEsc class="mr-1 size-3" />
{{ $t('widgets.search.close') }}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
</Modal>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<Search
class="text-muted-foreground group-hover:text-foreground size-3 group-hover:opacity-100"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</div>
</template>

View File

@@ -217,14 +217,14 @@ onMounted(() => {
<template>
<VbenScrollbar>
<div class="!flex h-full justify-center px-4 sm:max-h-[450px]">
<div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
<!-- 无搜索结果 -->
<div
v-if="keyword && searchResults.length === 0"
class="text-muted-foreground text-center"
>
<SearchX class="mx-auto size-12" />
<p class="my-10 text-xs">
<SearchX class="mx-auto mt-4 size-12" />
<p class="mb-10 mt-6 text-xs">
{{ $t('widgets.search.noResults') }}
<span class="text-foreground text-sm font-medium">
"{{ keyword }}"

View File

@@ -1,12 +1,8 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { useVbenModal } from '@vben-core/popup-ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
VbenAvatar,
VbenButton,
VbenInputPassword,
@@ -28,28 +24,33 @@ interface RegisterEmits {
defineOptions({
name: 'LockScreenModal',
});
withDefaults(defineProps<Props>(), {
avatar: '',
text: '',
});
const emit = defineEmits<{
submit: RegisterEmits['submit'];
}>();
const [Modal] = useVbenModal({
onConfirm() {
handleSubmit();
},
});
const formState = reactive({
lockScreenPassword: '',
submitted: false,
});
const openModal = defineModel<boolean>('open');
const passwordStatus = computed(() => {
return formState.submitted && !formState.lockScreenPassword
? 'error'
: 'default';
});
function handleClose() {
openModal.value = false;
}
function handleSubmit() {
formState.submitted = true;
if (passwordStatus.value !== 'default') {
@@ -62,51 +63,40 @@ function handleSubmit() {
</script>
<template>
<div>
<Dialog :open="openModal">
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[20%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogDescription />
<DialogHeader>
<DialogTitle
class="border-border flex h-8 items-center px-5 font-normal"
>
{{ $t('widgets.lockScreen.title') }}
</DialogTitle>
</DialogHeader>
<div
class="mb-10 flex w-full flex-col items-center"
@keypress.enter.prevent="handleSubmit"
>
<div class="w-2/3">
<div class="ml-2 flex w-full flex-col items-center">
<VbenAvatar
:src="avatar"
class="size-24"
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
/>
<div class="text-foreground my-6 flex items-center font-medium">
{{ text }}
</div>
</div>
<VbenInputPassword
v-model="formState.lockScreenPassword"
:error-tip="$t('widgets.lockScreen.placeholder')"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
<VbenButton class="w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.screenButton') }}
</VbenButton>
<Modal
:footer="false"
:fullscreen-button="false"
:title="$t('widgets.lockScreen.title')"
>
<div
class="mb-10 flex w-full flex-col items-center px-10"
@keypress.enter.prevent="handleSubmit"
>
<div class="w-full">
<div class="ml-2 flex w-full flex-col items-center">
<VbenAvatar
:src="avatar"
class="size-20"
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
/>
<div class="text-foreground my-6 flex items-center font-medium">
{{ text }}
</div>
</div>
</DialogContent>
</Dialog>
</div>
<VbenInputPassword
v-model="formState.lockScreenPassword"
:error-tip="$t('widgets.lockScreen.placeholder')"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
<VbenButton class="w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.screenButton') }}
</VbenButton>
</div>
</div>
</Modal>
</template>

View File

@@ -15,7 +15,7 @@ const shortcutKeysGlobalSearch = defineModel<boolean>(
'shortcutKeysGlobalSearch',
);
const shortcutKeysLogout = defineModel<boolean>('shortcutKeysLogout');
const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
// const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
const shortcutKeysLockScreen = defineModel<boolean>('shortcutKeysLockScreen');
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
@@ -39,10 +39,10 @@ const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
{{ $t('preferences.shortcutKeys.logout') }}
<template #shortcut> {{ altView }} Q </template>
</SwitchItem>
<SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
<!-- <SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
{{ $t('preferences.shortcutKeys.preferences') }}
<template #shortcut> {{ altView }} , </template>
</SwitchItem>
</SwitchItem> -->
<SwitchItem v-model="shortcutKeysLockScreen" :disabled="!shortcutKeysEnable">
{{ $t('widgets.lockScreen.title') }}
<template #shortcut> {{ altView }} L </template>

View File

@@ -14,7 +14,7 @@ import type { SegmentedItem } from '@vben-core/shadcn-ui';
import { computed, ref } from 'vue';
import { Copy, RotateCw, Settings } from '@vben/icons';
import { Copy, RotateCw } from '@vben/icons';
import { $t, loadLocaleMessages } from '@vben/locales';
import {
clearPreferencesCache,
@@ -22,12 +22,12 @@ import {
resetPreferences,
usePreferences,
} from '@vben/preferences';
import { useVbenDrawer } from '@vben-core/popup-ui';
import {
useToast,
VbenButton,
VbenIconButton,
VbenSegmented,
VbenSheet,
} from '@vben-core/shadcn-ui';
import { useClipboard } from '@vueuse/core';
@@ -52,7 +52,6 @@ import {
Theme,
Widget,
} from './blocks';
import { useOpenPreferences } from './use-open-preferences';
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const { toast } = useToast();
@@ -134,9 +133,7 @@ const shortcutKeysGlobalSearch = defineModel<boolean>(
const shortcutKeysGlobalLogout = defineModel<boolean>(
'shortcutKeysGlobalLogout',
);
const shortcutKeysGlobalPreferences = defineModel<boolean>(
'shortcutKeysGlobalPreferences',
);
const shortcutKeysGlobalLockScreen = defineModel<boolean>(
'shortcutKeysGlobalLockScreen',
);
@@ -161,6 +158,8 @@ const {
} = usePreferences();
const { copy } = useClipboard();
const [Drawer] = useVbenDrawer();
const activeTab = ref('appearance');
const tabs = computed((): SegmentedItem[] => {
@@ -193,8 +192,6 @@ const showBreadcrumbConfig = computed(() => {
);
});
const { openPreferences } = useOpenPreferences();
async function handleCopy() {
await copy(JSON.stringify(diffPreference.value, null, 2));
@@ -225,21 +222,11 @@ async function handleReset() {
<template>
<div>
<VbenSheet
v-model:open="openPreferences"
<Drawer
:description="$t('preferences.subtitle')"
:title="$t('preferences.title')"
class="sm:max-w-sm"
>
<template #trigger>
<slot name="trigger">
<VbenButton
:title="$t('preferences.title')"
class="bg-primary flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</template>
<template #extra>
<div class="flex items-center">
<VbenIconButton
@@ -256,7 +243,7 @@ async function handleReset() {
</div>
</template>
<div class="p-4 pt-4">
<div class="p-1">
<VbenSegmented v-model="activeTab" :tabs="tabs">
<template #general>
<Block :title="$t('preferences.general')">
@@ -402,9 +389,6 @@ async function handleReset() {
v-model:shortcut-keys-global-search="shortcutKeysGlobalSearch"
v-model:shortcut-keys-lock-screen="shortcutKeysGlobalLockScreen"
v-model:shortcut-keys-logout="shortcutKeysGlobalLogout"
v-model:shortcut-keys-preferences="
shortcutKeysGlobalPreferences
"
/>
</Block>
</template>
@@ -433,6 +417,6 @@ async function handleReset() {
{{ $t('preferences.clearAndLogout') }}
</VbenButton>
</template>
</VbenSheet>
</Drawer>
</div>
</template>

View File

@@ -1,11 +1,18 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { loadLocaleMessages } from '@vben/locales';
import { Settings } from '@vben/icons';
import { $t, loadLocaleMessages } from '@vben/locales';
import { preferences, updatePreferences } from '@vben/preferences';
import { capitalizeFirstLetter } from '@vben/utils';
import { useVbenDrawer } from '@vben-core/popup-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
import PreferencesSheet from './preferences-sheet.vue';
import PreferencesDrawer from './preferences-drawer.vue';
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: PreferencesDrawer,
});
/**
* preferences 转成 vue props
@@ -47,9 +54,18 @@ const listen = computed(() => {
});
</script>
<template>
<PreferencesSheet v-bind="attrs" v-on="listen">
<template #trigger>
<slot></slot>
</template>
</PreferencesSheet>
<div>
<Drawer v-bind="attrs" v-on="listen" />
<div @click="() => drawerApi.open()">
<slot>
<VbenButton
:title="$t('preferences.title')"
class="bg-primary flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</div>
</div>
</template>

View File

@@ -4,11 +4,12 @@ import type { AnyFunction } from '@vben/types';
import type { Component } from 'vue';
import { computed, ref } from 'vue';
import { LockKeyhole, LogOut, SwatchBook } from '@vben/icons';
import { LockKeyhole, LogOut } from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useLockStore } from '@vben/stores';
import { isWindowsOs } from '@vben/utils';
import { useVbenModal } from '@vben-core/popup-ui';
import {
Badge,
DropdownMenu,
@@ -18,7 +19,6 @@ import {
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
VbenAlertDialog,
VbenAvatar,
VbenIcon,
} from '@vben-core/shadcn-ui';
@@ -26,7 +26,6 @@ import {
import { useMagicKeys, whenever } from '@vueuse/core';
import { LockScreenModal } from '../lock-screen';
import { useOpenPreferences } from '../preferences';
interface Props {
/**
@@ -72,16 +71,18 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ logout: [] }>();
const openPopover = ref(false);
const openDialog = ref(false);
const openLock = ref(false);
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalPreferencesShortcutKey,
} = usePreferences();
const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
usePreferences();
const lockStore = useLockStore();
const { handleOpenPreference } = useOpenPreferences();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
});
const [LogoutModal, logoutModalApi] = useVbenModal({
onConfirm() {
handleSubmitLogout();
},
});
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
@@ -97,12 +98,8 @@ const enableShortcutKey = computed(() => {
return props.enableShortcutKey && preferences.shortcutKeys.enable;
});
const enablePreferencesShortcutKey = computed(() => {
return props.enableShortcutKey && globalPreferencesShortcutKey.value;
});
function handleOpenLock() {
openLock.value = true;
lockModalApi.open();
}
function handleSubmitLock({
@@ -110,18 +107,19 @@ function handleSubmitLock({
}: {
lockScreenPassword: string;
}) {
openLock.value = false;
lockModalApi.close();
lockStore.lockScreen(lockScreenPassword);
}
function handleLogout() {
// emit
openDialog.value = true;
logoutModalApi.open();
openPopover.value = false;
}
function handleSubmitLogout() {
emit('logout');
openDialog.value = false;
logoutModalApi.close();
}
if (enableShortcutKey.value) {
@@ -132,12 +130,6 @@ if (enableShortcutKey.value) {
}
});
whenever(keys['Alt+Comma']!, () => {
if (enablePreferencesShortcutKey.value) {
handleOpenPreference();
}
});
whenever(keys['Alt+KeyL']!, () => {
if (enableLockScreenShortcutKey.value) {
handleOpenLock();
@@ -147,21 +139,25 @@ if (enableShortcutKey.value) {
</script>
<template>
<LockScreenModal
<LockModal
v-if="preferences.widget.lockScreen"
v-model:open="openLock"
:avatar="avatar"
:text="text"
@submit="handleSubmitLock"
/>
<VbenAlertDialog
v-model:open="openDialog"
<LogoutModal
:cancel-text="$t('common.cancel')"
:content="$t('widgets.logoutTip')"
:submit-text="$t('common.confirm')"
:confirm-text="$t('common.confirm')"
:fullscreen-button="false"
:title="$t('common.prompt')"
@submit="handleSubmitLogout"
/>
centered
content-class="px-8 min-h-10"
footer-class="border-none mb-4 mr-4"
header-class="border-none"
>
{{ $t('widgets.logoutTip') }}
</LogoutModal>
<DropdownMenu>
<DropdownMenuTrigger>
@@ -205,17 +201,6 @@ if (enableShortcutKey.value) {
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="preferences.app.enablePreferences"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenPreference"
>
<SwatchBook class="mr-2 size-4" />
{{ $t('preferences.title') }}
<DropdownMenuShortcut v-if="enablePreferencesShortcutKey">
{{ altView }} ,
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
v-if="preferences.widget.lockScreen"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"

View File

@@ -1,6 +1,6 @@
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[] = [

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { generateMenus } from './generate-menus'; // 替换为您的实际路径
import { generateMenus } from '../generate-menus'; // 替换为您的实际路径
import {
createRouter,
createWebHistory,

View File

@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest';
import {
generateRoutesByFrontend,
hasAuthority,
} from './generate-routes-frontend';
} from '../generate-routes-frontend';
// Mock 路由数据
const mockRoutes = [

View File

@@ -1,10 +1,10 @@
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 { mergeRouteModules } from './merge-route-modules';
import { mergeRouteModules } from '../merge-route-modules';
describe('mergeRouteModules', () => {
it('should merge route modules correctly', () => {

View File

@@ -63,6 +63,12 @@
},
"examples": {
"title": "Examples",
"modal": {
"title": "Modal"
},
"drawer": {
"title": "Drawer"
},
"ellipsis": {
"title": "EllipsisText"
}

View File

@@ -63,6 +63,12 @@
},
"examples": {
"title": "示例",
"modal": {
"title": "弹窗"
},
"drawer": {
"title": "抽屉"
},
"ellipsis": {
"title": "文本省略"
}

View File

@@ -16,13 +16,29 @@ const routes: RouteRecordRaw[] = [
path: '/examples',
children: [
{
name: 'EllipsisDemo',
path: 'ellipsis',
name: 'EllipsisExample',
path: '/examples/ellipsis',
component: () => import('#/views/examples/ellipsis/index.vue'),
meta: {
title: $t('page.examples.ellipsis.title'),
},
},
{
name: 'ModalExample',
path: '/examples/modal',
component: () => import('#/views/examples/modal/index.vue'),
meta: {
title: $t('page.examples.modal.title'),
},
},
{
name: 'DrawerExample',
path: '/examples/drawer',
component: () => import('#/views/examples/drawer/index.vue'),
meta: {
title: $t('page.examples.drawer.title'),
},
},
],
},
];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -13,7 +13,7 @@ const text = ref(longText);
<template>
<Page
description="用于多行文本省略,支持点击展开和自定义内容。"
title="文本省略示例"
title="文本省略组件示例"
>
<Card class="mb-4" title="基本使用">
<EllipsisText :max-width="500">{{ text }}</EllipsisText>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

28
pnpm-lock.yaml generated
View File

@@ -804,6 +804,27 @@ importers:
specifier: 3.4.38
version: 3.4.38(typescript@5.5.4)
packages/@core/ui-kit/popup-ui:
dependencies:
'@vben-core/composables':
specifier: workspace:*
version: link:../../composables
'@vben-core/icons':
specifier: workspace:*
version: link:../../base/icons
'@vben-core/shadcn-ui':
specifier: workspace:*
version: link:../shadcn-ui
'@vben-core/shared':
specifier: workspace:*
version: link:../../base/shared
'@vueuse/core':
specifier: ^11.0.1
version: 11.0.1(vue@3.4.38(typescript@5.5.4))
vue:
specifier: 3.4.38
version: 3.4.38(typescript@5.5.4)
packages/@core/ui-kit/shadcn-ui:
dependencies:
'@radix-icons/vue':
@@ -896,6 +917,9 @@ importers:
packages/effects/common-ui:
dependencies:
'@vben-core/popup-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/popup-ui
'@vben-core/shadcn-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/shadcn-ui
@@ -966,6 +990,9 @@ importers:
'@vben-core/menu-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/menu-ui
'@vben-core/popup-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/popup-ui
'@vben-core/shadcn-ui':
specifier: workspace:*
version: link:../../@core/ui-kit/shadcn-ui
@@ -3411,7 +3438,6 @@ packages:
'@ls-lint/ls-lint@2.2.3':
resolution: {integrity: sha512-ekM12jNm/7O2I/hsRv9HvYkRdfrHpiV1epVuI2NP+eTIcEgdIdKkKCs9KgQydu/8R5YXTov9aHdOgplmCHLupw==}
cpu: [x64, arm64, s390x]
os: [darwin, linux, win32]
hasBin: true

View File

@@ -15,6 +15,7 @@ const IGNORE_DIR = [
'internal',
'packages/effects/request/src/',
'packages/@core/ui-kit/menu-ui/src/',
'packages/@core/ui-kit/popup-ui/src/',
].join(',');
const IGNORE = [`**/{${IGNORE_DIR}}/**`];

View File

@@ -84,6 +84,10 @@
"name": "@vben-core/menu-ui",
"path": "packages/@core/ui-kit/menu-ui",
},
{
"name": "@vben-core/popup-ui",
"path": "packages/@core/ui-kit/popup-ui",
},
{
"name": "@vben-core/shadcn-ui",
"path": "packages/@core/ui-kit/shadcn-ui",