Browse Source

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
Vben 8 months ago
parent
commit
20a3868594
96 changed files with 2701 additions and 744 deletions
  1. 2 1
      .vscode/settings.json
  2. 1 1
      apps/backend-mock/utils/jwt-utils.ts
  3. 5 1
      cspell.json
  4. 1 1
      internal/node-utils/src/__tests__/hash.test.ts
  5. 1 1
      internal/node-utils/src/__tests__/path.test.ts
  6. 1 8
      internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html
  7. 3 0
      packages/@core/base/icons/src/lucide.ts
  8. 1 1
      packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts
  9. 1 1
      packages/@core/base/shared/src/color/__tests__/convert.test.ts
  10. 1 1
      packages/@core/base/shared/src/utils/__tests__/diff.test.ts
  11. 1 1
      packages/@core/base/shared/src/utils/__tests__/dom.test.ts
  12. 55 2
      packages/@core/base/shared/src/utils/__tests__/inference.test.ts
  13. 40 1
      packages/@core/base/shared/src/utils/__tests__/letter.test.ts
  14. 1 1
      packages/@core/base/shared/src/utils/__tests__/tree.test.ts
  15. 1 1
      packages/@core/base/shared/src/utils/__tests__/unique.test.ts
  16. 1 1
      packages/@core/base/shared/src/utils/__tests__/update-css-variables.test.ts
  17. 1 1
      packages/@core/base/shared/src/utils/__tests__/window.test.ts
  18. 36 1
      packages/@core/base/shared/src/utils/inference.ts
  19. 16 1
      packages/@core/base/shared/src/utils/letter.ts
  20. 1 2
      packages/@core/composables/src/__tests__/use-sortable.test.ts
  21. 1 0
      packages/@core/composables/src/index.ts
  22. 47 0
      packages/@core/composables/src/use-priority-value.ts
  23. 0 9
      packages/@core/preferences/src/use-preferences.ts
  24. 21 0
      packages/@core/ui-kit/popup-ui/build.config.ts
  25. 47 0
      packages/@core/ui-kit/popup-ui/package.json
  26. 1 0
      packages/@core/ui-kit/popup-ui/postcss.config.mjs
  27. 113 0
      packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts
  28. 123 0
      packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
  29. 93 0
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts
  30. 141 0
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
  31. 3 0
      packages/@core/ui-kit/popup-ui/src/drawer/index.ts
  32. 105 0
      packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
  33. 2 0
      packages/@core/ui-kit/popup-ui/src/index.ts
  34. 112 0
      packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts
  35. 3 0
      packages/@core/ui-kit/popup-ui/src/modal/index.ts
  36. 134 0
      packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
  37. 123 0
      packages/@core/ui-kit/popup-ui/src/modal/modal.ts
  38. 231 0
      packages/@core/ui-kit/popup-ui/src/modal/modal.vue
  39. 148 0
      packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
  40. 101 0
      packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
  41. 1 0
      packages/@core/ui-kit/popup-ui/tailwind.config.mjs
  42. 6 0
      packages/@core/ui-kit/popup-ui/tsconfig.json
  43. 0 62
      packages/@core/ui-kit/shadcn-ui/src/components/alert-dialog/alert-dialog.vue
  44. 0 1
      packages/@core/ui-kit/shadcn-ui/src/components/alert-dialog/index.ts
  45. 0 3
      packages/@core/ui-kit/shadcn-ui/src/components/index.ts
  46. 0 1
      packages/@core/ui-kit/shadcn-ui/src/components/sheet/index.ts
  47. 0 113
      packages/@core/ui-kit/shadcn-ui/src/components/sheet/sheet.vue
  48. 1 0
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/index.ts
  49. 137 0
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/loading.vue
  50. 25 7
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue
  51. 0 19
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialog.vue
  52. 0 28
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogAction.vue
  53. 0 30
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogCancel.vue
  54. 0 46
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogContent.vue
  55. 0 29
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogDescription.vue
  56. 0 22
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogFooter.vue
  57. 0 17
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogHeader.vue
  58. 0 26
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogTitle.vue
  59. 0 11
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogTrigger.vue
  60. 0 9
      packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/index.ts
  61. 17 4
      packages/@core/ui-kit/shadcn-ui/src/components/ui/dialog/DialogContent.vue
  62. 3 3
      packages/@core/ui-kit/shadcn-ui/src/components/ui/sheet/sheet.ts
  63. 1 0
      packages/effects/common-ui/package.json
  64. 36 34
      packages/effects/common-ui/src/components/ellipsis-text/ellipsis-text.vue
  65. 1 0
      packages/effects/common-ui/src/components/index.ts
  66. 32 31
      packages/effects/common-ui/src/ui/authentication/login-expired-modal.vue
  67. 1 0
      packages/effects/layouts/package.json
  68. 57 64
      packages/effects/layouts/src/widgets/global-search/global-search.vue
  69. 3 3
      packages/effects/layouts/src/widgets/global-search/search-panel.vue
  70. 45 55
      packages/effects/layouts/src/widgets/lock-screen/lock-screen-modal.vue
  71. 3 3
      packages/effects/layouts/src/widgets/preferences/blocks/shortcut-keys/global.vue
  72. 9 25
      packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
  73. 23 7
      packages/effects/layouts/src/widgets/preferences/preferences.vue
  74. 30 45
      packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue
  75. 1 1
      packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts
  76. 1 1
      packages/utils/src/helpers/__tests__/generate-menus.test.ts
  77. 1 1
      packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts
  78. 2 2
      packages/utils/src/helpers/__tests__/merge-route-modules.test.ts
  79. 6 0
      playground/src/locales/langs/en-US.json
  80. 6 0
      playground/src/locales/langs/zh-CN.json
  81. 18 2
      playground/src/router/routes/modules/examples.ts
  82. 40 0
      playground/src/views/examples/drawer/auto-height-demo.vue
  83. 32 0
      playground/src/views/examples/drawer/base-demo.vue
  84. 31 0
      playground/src/views/examples/drawer/dynamic-demo.vue
  85. 90 0
      playground/src/views/examples/drawer/index.vue
  86. 29 0
      playground/src/views/examples/drawer/shared-data-demo.vue
  87. 1 1
      playground/src/views/examples/ellipsis/index.vue
  88. 40 0
      playground/src/views/examples/modal/auto-height-demo.vue
  89. 28 0
      playground/src/views/examples/modal/base-demo.vue
  90. 19 0
      playground/src/views/examples/modal/drag-demo.vue
  91. 41 0
      playground/src/views/examples/modal/dynamic-demo.vue
  92. 104 0
      playground/src/views/examples/modal/index.vue
  93. 29 0
      playground/src/views/examples/modal/shared-data-demo.vue
  94. 27 1
      pnpm-lock.yaml
  95. 1 0
      scripts/vsh/src/check-circular/index.ts
  96. 4 0
      vben-admin.code-workspace

+ 2 - 1
.vscode/settings.json

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

+ 1 - 1
apps/backend-mock/utils/jwt-utils.ts

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

+ 5 - 1
cspell.json

@@ -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__/**"
   ]
 }

+ 1 - 1
internal/node-utils/src/hash.test.ts → internal/node-utils/src/__tests__/hash.test.ts

@@ -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', () => {

+ 1 - 1
internal/node-utils/src/path.test.ts → internal/node-utils/src/__tests__/path.test.ts

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

+ 1 - 8
internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html

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

+ 3 - 0
packages/@core/base/icons/src/lucide.ts

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

+ 1 - 1
packages/@core/base/shared/src/cache/storage-manager.test.ts → packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts

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

+ 1 - 1
packages/@core/base/shared/src/color/convert.test.ts → packages/@core/base/shared/src/color/__tests__/convert.test.ts

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

+ 1 - 1
packages/@core/base/shared/src/utils/diff.test.ts → packages/@core/base/shared/src/utils/__tests__/diff.test.ts

@@ -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', () => {

+ 1 - 1
packages/@core/base/shared/src/utils/dom.test.ts → packages/@core/base/shared/src/utils/__tests__/dom.test.ts

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

+ 55 - 2
packages/@core/base/shared/src/utils/inference.test.ts → packages/@core/base/shared/src/utils/__tests__/inference.test.ts

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

+ 40 - 1
packages/@core/base/shared/src/utils/letter.test.ts → packages/@core/base/shared/src/utils/__tests__/letter.test.ts

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

+ 1 - 1
packages/@core/base/shared/src/utils/tree.test.ts → packages/@core/base/shared/src/utils/__tests__/tree.test.ts

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

+ 1 - 1
packages/@core/base/shared/src/utils/unique.test.ts → packages/@core/base/shared/src/utils/__tests__/unique.test.ts

@@ -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', () => {

+ 1 - 1
packages/@core/base/shared/src/utils/update-css-variables.test.ts → packages/@core/base/shared/src/utils/__tests__/update-css-variables.test.ts

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

+ 1 - 1
packages/@core/base/shared/src/utils/window.test.ts → packages/@core/base/shared/src/utils/__tests__/window.test.ts

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

+ 36 - 1
packages/@core/base/shared/src/utils/inference.ts

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

+ 16 - 1
packages/@core/base/shared/src/utils/letter.ts

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

+ 1 - 2
packages/@core/composables/src/use-sortable.test.ts → packages/@core/composables/src/__tests__/use-sortable.test.ts

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

+ 1 - 0
packages/@core/composables/src/index.ts

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

+ 47 - 0
packages/@core/composables/src/use-priority-value.ts

@@ -0,0 +1,47 @@
+import type { Ref } from 'vue';
+import { computed, getCurrentInstance, useAttrs, useSlots } from 'vue';
+
+import {
+  getFirstNonNullOrUndefined,
+  kebabToCamelCase,
+} from '@vben-core/shared';
+
+/**
+ * 依次从插槽、attrs、props、state 中获取值
+ * @param key
+ * @param props
+ * @param state
+ */
+export function usePriorityValue<
+  T extends Record<string, any>,
+  S extends Record<string, any>,
+  K extends keyof T = keyof T,
+>(key: K, props: T, state: Readonly<Ref<NoInfer<S>>> | undefined) {
+  const instance = getCurrentInstance();
+  const slots = useSlots();
+  const attrs = useAttrs() as T;
+
+  const value = computed((): T[K] => {
+    // props不管有没有传,都会有默认值,会影响这里的顺序,
+    // 通过判断原始props是否有值来判断是否传入
+    const rawProps = (instance?.vnode?.props || {}) as T;
+
+    const standardRwaProps = {} as T;
+
+    for (const [key, value] of Object.entries(rawProps)) {
+      standardRwaProps[kebabToCamelCase(key) as K] = value;
+    }
+    const propsKey =
+      standardRwaProps?.[key] === undefined ? undefined : props[key];
+
+    // slot可以关闭
+    return getFirstNonNullOrUndefined(
+      slots[key as string],
+      attrs[key],
+      propsKey,
+      state?.value?.[key as keyof S],
+    ) as T[K];
+  });
+
+  return value;
+}

+ 0 - 9
packages/@core/preferences/src/use-preferences.ts

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

+ 21 - 0
packages/@core/ui-kit/popup-ui/build.config.ts

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

+ 47 - 0
packages/@core/ui-kit/popup-ui/package.json

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

+ 1 - 0
packages/@core/ui-kit/popup-ui/postcss.config.mjs

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

+ 113 - 0
packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts

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

+ 123 - 0
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts

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

+ 93 - 0
packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts

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

+ 141 - 0
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue

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

+ 3 - 0
packages/@core/ui-kit/popup-ui/src/drawer/index.ts

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

+ 105 - 0
packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts

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

+ 2 - 0
packages/@core/ui-kit/popup-ui/src/index.ts

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

+ 112 - 0
packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts

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

+ 3 - 0
packages/@core/ui-kit/popup-ui/src/modal/index.ts

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

+ 134 - 0
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts

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

+ 123 - 0
packages/@core/ui-kit/popup-ui/src/modal/modal.ts

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

+ 231 - 0
packages/@core/ui-kit/popup-ui/src/modal/modal.vue

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

+ 148 - 0
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts

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

+ 101 - 0
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts

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

+ 1 - 0
packages/@core/ui-kit/popup-ui/tailwind.config.mjs

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

+ 6 - 0
packages/@core/ui-kit/popup-ui/tsconfig.json

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

+ 0 - 62
packages/@core/ui-kit/shadcn-ui/src/components/alert-dialog/alert-dialog.vue

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

+ 0 - 1
packages/@core/ui-kit/shadcn-ui/src/components/alert-dialog/index.ts

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

+ 0 - 3
packages/@core/ui-kit/shadcn-ui/src/components/index.ts

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

+ 0 - 1
packages/@core/ui-kit/shadcn-ui/src/components/sheet/index.ts

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

+ 0 - 113
packages/@core/ui-kit/shadcn-ui/src/components/sheet/sheet.vue

@@ -1,113 +0,0 @@
-<script setup lang="ts">
-import { computed, useSlots } from 'vue';
-
-import { X } from 'lucide-vue-next';
-
-import { VbenButton, VbenIconButton } from '../button';
-import { VbenScrollbar } from '../scrollbar';
-import {
-  Sheet,
-  SheetClose,
-  SheetContent,
-  SheetDescription,
-  SheetFooter,
-  SheetHeader,
-  SheetTitle,
-  SheetTrigger,
-} from '../ui/sheet';
-
-interface Props {
-  cancelText?: string;
-  description?: string;
-  showFooter?: boolean;
-  submitText?: string;
-  title?: string;
-  width?: number;
-}
-
-const props = withDefaults(defineProps<Props>(), {
-  cancelText: '关闭',
-  description: '',
-  showFooter: false,
-  submitText: '确认',
-  title: '',
-  width: 400,
-});
-
-const emits = defineEmits<{
-  cancel: [];
-  submit: [];
-}>();
-
-const openModal = defineModel<boolean>('open');
-
-const slots = useSlots();
-
-const contentStyle = computed(() => {
-  return {
-    width: `${props.width}px`,
-  };
-});
-
-function handlerSubmit() {
-  emits('submit');
-  openModal.value = false;
-}
-
-// function handleCancel() {
-//   emits('cancel');
-//   openModal.value = false;
-// }
-</script>
-
-<template>
-  <Sheet v-model:open="openModal">
-    <SheetTrigger>
-      <slot name="trigger"></slot>
-    </SheetTrigger>
-    <SheetContent :style="contentStyle" class="!w-full pb-12 sm:rounded-l-lg">
-      <SheetHeader
-        :class="description ? 'h-16' : 'h-12'"
-        class="border-border flex flex-row items-center justify-between border-b pl-3 pr-3"
-      >
-        <div class="flex w-full items-center justify-between">
-          <div>
-            <SheetTitle class="text-left text-lg">{{ title }}</SheetTitle>
-            <SheetDescription class="text-muted-foreground text-xs">
-              {{ description }}
-            </SheetDescription>
-          </div>
-          <slot v-if="slots.extra" name="extra"></slot>
-        </div>
-        <SheetClose
-          as-child
-          class="data-[state=open]:bg-secondary cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
-        >
-          <VbenIconButton>
-            <X class="size-4" />
-          </VbenIconButton>
-        </SheetClose>
-      </SheetHeader>
-      <div class="h-full pb-16">
-        <VbenScrollbar class="h-full" shadow>
-          <slot></slot>
-        </VbenScrollbar>
-      </div>
-      <SheetFooter v-if="showFooter || slots.footer" as-child>
-        <div
-          class="border-border absolute bottom-0 flex h-12 w-full items-center justify-end border-t"
-        >
-          <slot v-if="slots.footer" name="footer"></slot>
-          <template v-else>
-            <SheetClose as-child>
-              <VbenButton class="mr-2" variant="outline">
-                {{ cancelText }}
-              </VbenButton>
-            </SheetClose>
-            <VbenButton @click="handlerSubmit">{{ submitText }}</VbenButton>
-          </template>
-        </div>
-      </SheetFooter>
-    </SheetContent>
-  </Sheet>
-</template>

+ 1 - 0
packages/@core/ui-kit/shadcn-ui/src/components/spinner/index.ts

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

+ 137 - 0
packages/@core/ui-kit/shadcn-ui/src/components/spinner/loading.vue

@@ -0,0 +1,137 @@
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+
+import { cn } from '@vben-core/shared';
+
+interface Props {
+  class?: string;
+  /**
+   * @zh_CN 最小加载时间
+   * @en_US Minimum loading time
+   */
+  minLoadingTime?: number;
+
+  /**
+   * @zh_CN loading状态开启
+   */
+  spinning?: boolean;
+  /**
+   * @zh_CN 文字
+   */
+  text?: string;
+}
+
+defineOptions({
+  name: 'VbenLoading',
+});
+
+const props = withDefaults(defineProps<Props>(), {
+  minLoadingTime: 50,
+  text: '',
+});
+// const startTime = ref(0);
+const showSpinner = ref(false);
+const renderSpinner = ref(true);
+const timer = ref<ReturnType<typeof setTimeout>>();
+
+watch(
+  () => props.spinning,
+  (show) => {
+    if (!show) {
+      showSpinner.value = false;
+      clearTimeout(timer.value);
+      return;
+    }
+
+    // startTime.value = performance.now();
+    timer.value = setTimeout(() => {
+      // const loadingTime = performance.now() - startTime.value;
+
+      showSpinner.value = true;
+      if (showSpinner.value) {
+        renderSpinner.value = true;
+      }
+    }, props.minLoadingTime);
+  },
+  {
+    immediate: true,
+  },
+);
+
+function onTransitionEnd() {
+  if (!showSpinner.value) {
+    renderSpinner.value = false;
+  }
+}
+</script>
+
+<template>
+  <div
+    :class="
+      cn(
+        'bg-overlay z-100 pointer-events-none absolute left-0 top-0 flex size-full flex-col items-center justify-center backdrop-blur-sm transition-all duration-500',
+        {
+          'invisible opacity-0': !showSpinner,
+        },
+        props.class,
+      )
+    "
+    @transitionend="onTransitionEnd"
+  >
+    <span class="dot relative inline-block size-9 text-3xl">
+      <i
+        v-for="index in 4"
+        :key="index"
+        class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
+      ></i>
+    </span>
+
+    <div v-if="text" class="mt-4 text-xs">{{ text }}</div>
+  </div>
+</template>
+
+<style scoped>
+.dot {
+  transform: rotate(45deg);
+  animation: rotate-ani 1.2s infinite linear;
+}
+
+.dot i {
+  animation: spin-move-ani 1s infinite linear alternate;
+}
+
+.dot i:nth-child(1) {
+  top: 0;
+  left: 0;
+}
+
+.dot i:nth-child(2) {
+  top: 0;
+  right: 0;
+  animation-delay: 0.4s;
+}
+
+.dot i:nth-child(3) {
+  right: 0;
+  bottom: 0;
+  animation-delay: 0.8s;
+}
+
+.dot i:nth-child(4) {
+  bottom: 0;
+  left: 0;
+  animation-delay: 1.2s;
+}
+
+@keyframes rotate-ani {
+  to {
+    transform: rotate(405deg);
+  }
+}
+
+@keyframes spin-move-ani {
+  to {
+    opacity: 1;
+  }
+}
+</style>

+ 25 - 7
packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue

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

+ 0 - 19
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialog.vue

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

+ 0 - 28
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogAction.vue

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

+ 0 - 30
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogCancel.vue

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

+ 0 - 46
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogContent.vue

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

+ 0 - 29
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogDescription.vue

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

+ 0 - 22
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogFooter.vue

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

+ 0 - 17
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogHeader.vue

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

+ 0 - 26
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogTitle.vue

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

+ 0 - 11
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/AlertDialogTrigger.vue

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

+ 0 - 9
packages/@core/ui-kit/shadcn-ui/src/components/ui/alert-dialog/index.ts

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

+ 17 - 4
packages/@core/ui-kit/shadcn-ui/src/components/ui/dialog/DialogContent.vue

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

+ 3 - 3
packages/@core/ui-kit/shadcn-ui/src/components/ui/sheet/sheet.ts

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

+ 1 - 0
packages/effects/common-ui/package.json

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

+ 36 - 34
packages/effects/common-ui/src/components/ellipsis-text/ellipsis-text.vue

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

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

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

+ 32 - 31
packages/effects/common-ui/src/ui/authentication/login-expired-modal.vue

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

+ 1 - 0
packages/effects/layouts/package.json

@@ -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:*",

+ 57 - 64
packages/effects/layouts/src/widgets/global-search/global-search.vue

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

+ 3 - 3
packages/effects/layouts/src/widgets/global-search/search-panel.vue

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

+ 45 - 55
packages/effects/layouts/src/widgets/lock-screen/lock-screen-modal.vue

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

+ 3 - 3
packages/effects/layouts/src/widgets/preferences/blocks/shortcut-keys/global.vue

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

+ 9 - 25
packages/effects/layouts/src/widgets/preferences/preferences-sheet.vue → packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue

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

+ 23 - 7
packages/effects/layouts/src/widgets/preferences/preferences.vue

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

+ 30 - 45
packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue

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

+ 1 - 1
packages/utils/src/helpers/find-menu-by-path.test.ts → packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts

@@ -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[] = [

+ 1 - 1
packages/utils/src/helpers/generate-menus.test.ts → packages/utils/src/helpers/__tests__/generate-menus.test.ts

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

+ 1 - 1
packages/utils/src/helpers/generate-routes-frontend.test.ts → packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts

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

+ 2 - 2
packages/utils/src/helpers/merge-route-modules.test.ts → packages/utils/src/helpers/__tests__/merge-route-modules.test.ts

@@ -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', () => {

+ 6 - 0
playground/src/locales/langs/en-US.json

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

+ 6 - 0
playground/src/locales/langs/zh-CN.json

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

+ 18 - 2
playground/src/router/routes/modules/examples.ts

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

+ 40 - 0
playground/src/views/examples/drawer/auto-height-demo.vue

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

+ 32 - 0
playground/src/views/examples/drawer/base-demo.vue

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

+ 31 - 0
playground/src/views/examples/drawer/dynamic-demo.vue

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

+ 90 - 0
playground/src/views/examples/drawer/index.vue

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

+ 29 - 0
playground/src/views/examples/drawer/shared-data-demo.vue

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

+ 1 - 1
playground/src/views/examples/ellipsis/index.vue

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

+ 40 - 0
playground/src/views/examples/modal/auto-height-demo.vue

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

+ 28 - 0
playground/src/views/examples/modal/base-demo.vue

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

+ 19 - 0
playground/src/views/examples/modal/drag-demo.vue

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

+ 41 - 0
playground/src/views/examples/modal/dynamic-demo.vue

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

+ 104 - 0
playground/src/views/examples/modal/index.vue

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

+ 29 - 0
playground/src/views/examples/modal/shared-data-demo.vue

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

+ 27 - 1
pnpm-lock.yaml

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

+ 1 - 0
scripts/vsh/src/check-circular/index.ts

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

+ 4 - 0
vben-admin.code-workspace

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