Browse Source

feat: add VbenForm component (#4352)

* feat: add form component

* fix: build error

* feat: add form adapter

* feat: add some component

* feat: add some component

* feat: add example

* feat: suppoer custom action button

* chore: update

* feat: add example

* feat: add formModel,formDrawer demo

* fix: build error

* fix: typo

* fix: ci error

---------

Co-authored-by: jinmao <jinmao88@qq.com>
Co-authored-by: likui628 <90845831+likui628@users.noreply.github.com>
Vben 8 months ago
parent
commit
524b9badf2
100 changed files with 3362 additions and 404 deletions
  1. 114 0
      apps/web-antd/src/adapter/form.ts
  2. 1 0
      apps/web-antd/src/adapter/index.ts
  3. 5 8
      apps/web-antd/src/layouts/basic.vue
  4. 39 5
      apps/web-antd/src/views/_core/authentication/code-login.vue
  5. 23 4
      apps/web-antd/src/views/_core/authentication/forget-password.vue
  6. 76 3
      apps/web-antd/src/views/_core/authentication/login.vue
  7. 81 5
      apps/web-antd/src/views/_core/authentication/register.vue
  8. 89 0
      apps/web-ele/src/adapter/form.ts
  9. 1 0
      apps/web-ele/src/adapter/index.ts
  10. 5 8
      apps/web-ele/src/layouts/basic.vue
  11. 39 5
      apps/web-ele/src/views/_core/authentication/code-login.vue
  12. 23 4
      apps/web-ele/src/views/_core/authentication/forget-password.vue
  13. 76 3
      apps/web-ele/src/views/_core/authentication/login.vue
  14. 81 5
      apps/web-ele/src/views/_core/authentication/register.vue
  15. 98 0
      apps/web-naive/src/adapter/form.ts
  16. 1 0
      apps/web-naive/src/adapter/index.ts
  17. 5 8
      apps/web-naive/src/layouts/basic.vue
  18. 39 5
      apps/web-naive/src/views/_core/authentication/code-login.vue
  19. 23 4
      apps/web-naive/src/views/_core/authentication/forget-password.vue
  20. 76 3
      apps/web-naive/src/views/_core/authentication/login.vue
  21. 81 5
      apps/web-naive/src/views/_core/authentication/register.vue
  22. 4 0
      docs/.vitepress/config/zh.mts
  23. 11 0
      docs/src/components/common-ui/vben-form.md
  24. 0 10
      docs/src/en/guide/in-depth/login.md
  25. 0 9
      docs/src/guide/in-depth/login.md
  26. 2 0
      internal/lint-configs/eslint-config/src/configs/unicorn.ts
  27. 5 1
      internal/vite-config/src/config/application.ts
  28. 9 1
      internal/vite-config/src/plugins/archiver.ts
  29. 4 1
      packages/@core/base/design/src/css/global.css
  30. 1 1
      packages/@core/base/icons/src/lucide.ts
  31. 0 1
      packages/@core/base/shared/build.config.ts
  32. 19 10
      packages/@core/base/shared/package.json
  33. 0 5
      packages/@core/base/shared/src/index.ts
  34. 60 0
      packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts
  35. 80 0
      packages/@core/base/shared/src/utils/__tests__/util.test.ts
  36. 2 0
      packages/@core/base/shared/src/utils/index.ts
  37. 50 0
      packages/@core/base/shared/src/utils/state-handler.ts
  38. 19 0
      packages/@core/base/shared/src/utils/util.ts
  39. 3 1
      packages/@core/composables/src/use-content-style.ts
  40. 1 1
      packages/@core/composables/src/use-namespace.ts
  41. 49 3
      packages/@core/composables/src/use-priority-value.ts
  42. 8 0
      packages/@core/composables/src/use-simple-locale/messages.ts
  43. 2 1
      packages/@core/preferences/src/preferences.ts
  44. 2 4
      packages/@core/preferences/src/update-css-variables.ts
  45. 1 1
      packages/@core/preferences/src/use-preferences.ts
  46. 21 0
      packages/@core/ui-kit/form-ui/build.config.ts
  47. 50 0
      packages/@core/ui-kit/form-ui/package.json
  48. 1 0
      packages/@core/ui-kit/form-ui/postcss.config.mjs
  49. 103 0
      packages/@core/ui-kit/form-ui/src/components/form-actions.vue
  50. 65 0
      packages/@core/ui-kit/form-ui/src/config.ts
  51. 175 0
      packages/@core/ui-kit/form-ui/src/form-api.ts
  52. 24 0
      packages/@core/ui-kit/form-ui/src/form-render/context.ts
  53. 116 0
      packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
  54. 97 0
      packages/@core/ui-kit/form-ui/src/form-render/expandable.ts
  55. 283 0
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  56. 20 0
      packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
  57. 140 0
      packages/@core/ui-kit/form-ui/src/form-render/form.vue
  58. 60 0
      packages/@core/ui-kit/form-ui/src/form-render/helper.ts
  59. 3 0
      packages/@core/ui-kit/form-ui/src/form-render/index.ts
  60. 11 0
      packages/@core/ui-kit/form-ui/src/index.ts
  61. 327 0
      packages/@core/ui-kit/form-ui/src/types.ts
  62. 59 0
      packages/@core/ui-kit/form-ui/src/use-form-context.ts
  63. 49 0
      packages/@core/ui-kit/form-ui/src/use-vben-form.ts
  64. 72 0
      packages/@core/ui-kit/form-ui/src/vben-form.vue
  65. 57 0
      packages/@core/ui-kit/form-ui/src/vben-use-form.vue
  66. 1 0
      packages/@core/ui-kit/form-ui/tailwind.config.mjs
  67. 6 0
      packages/@core/ui-kit/form-ui/tsconfig.json
  68. 3 3
      packages/@core/ui-kit/menu-ui/src/components/menu.vue
  69. 1 1
      packages/@core/ui-kit/popup-ui/package.json
  70. 1 1
      packages/@core/ui-kit/popup-ui/src/drawer/__tests__/drawer-api.test.ts
  71. 4 2
      packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts
  72. 22 23
      packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
  73. 1 1
      packages/@core/ui-kit/popup-ui/src/drawer/use-drawer.ts
  74. 1 1
      packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-api.test.ts
  75. 5 1
      packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
  76. 27 28
      packages/@core/ui-kit/popup-ui/src/modal/modal.vue
  77. 19 3
      packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts
  78. 1 1
      packages/@core/ui-kit/shadcn-ui/components.json
  79. 10 11
      packages/@core/ui-kit/shadcn-ui/package.json
  80. 24 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/button.ts
  81. 6 10
      packages/@core/ui-kit/shadcn-ui/src/components/button/button.vue
  82. 4 5
      packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue
  83. 1 0
      packages/@core/ui-kit/shadcn-ui/src/components/button/index.ts
  84. 9 7
      packages/@core/ui-kit/shadcn-ui/src/components/checkbox/checkbox.vue
  85. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/count-to-animator/count-to-animator.vue
  86. 36 0
      packages/@core/ui-kit/shadcn-ui/src/components/expandable-arrow/expandable-arrow.vue
  87. 1 0
      packages/@core/ui-kit/shadcn-ui/src/components/expandable-arrow/index.ts
  88. 2 2
      packages/@core/ui-kit/shadcn-ui/src/components/full-screen/full-screen.vue
  89. 6 1
      packages/@core/ui-kit/shadcn-ui/src/components/icon/icon.vue
  90. 8 1
      packages/@core/ui-kit/shadcn-ui/src/components/index.ts
  91. 25 20
      packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue
  92. 0 2
      packages/@core/ui-kit/shadcn-ui/src/components/input/index.ts
  93. 0 53
      packages/@core/ui-kit/shadcn-ui/src/components/input/input.vue
  94. 0 25
      packages/@core/ui-kit/shadcn-ui/src/components/input/types.ts
  95. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/link/link.vue
  96. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/menu-badge/menu-badge.vue
  97. 52 33
      packages/@core/ui-kit/shadcn-ui/src/components/pin-input/input.vue
  98. 6 18
      packages/@core/ui-kit/shadcn-ui/src/components/pin-input/types.ts
  99. 35 22
      packages/@core/ui-kit/shadcn-ui/src/components/render-content/render-content.vue
  100. 1 1
      packages/@core/ui-kit/shadcn-ui/src/components/scrollbar/scrollbar.vue

+ 114 - 0
apps/web-antd/src/adapter/form.ts

@@ -0,0 +1,114 @@
+import type {
+  BaseFormComponentType,
+  VbenFormSchema as FormSchema,
+  VbenFormProps,
+} from '@vben/common-ui';
+
+import { h } from 'vue';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import {
+  AutoComplete,
+  Button,
+  Checkbox,
+  CheckboxGroup,
+  DatePicker,
+  Divider,
+  Input,
+  InputNumber,
+  InputPassword,
+  Mentions,
+  Radio,
+  RadioGroup,
+  RangePicker,
+  Rate,
+  Select,
+  Space,
+  Switch,
+  TimePicker,
+  TreeSelect,
+  Upload,
+} from 'ant-design-vue';
+
+// 业务表单组件适配
+
+export type FormComponentType =
+  | 'AutoComplete'
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'Divider'
+  | 'Input'
+  | 'InputNumber'
+  | 'InputPassword'
+  | 'Mentions'
+  | 'Radio'
+  | 'RadioGroup'
+  | 'RangePicker'
+  | 'Rate'
+  | 'Select'
+  | 'Space'
+  | 'Switch'
+  | 'TimePicker'
+  | 'TreeSelect'
+  | 'Upload'
+  | BaseFormComponentType;
+
+// 初始化表单组件,并注册到form组件内部
+setupVbenForm<FormComponentType>({
+  components: {
+    AutoComplete,
+    Checkbox,
+    CheckboxGroup,
+    DatePicker,
+    // 自定义默认的重置按钮
+    DefaultResetActionButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'default' }, slots);
+    },
+    // 自定义默认的提交按钮
+    DefaultSubmitActionButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'primary' }, slots);
+    },
+    Divider,
+    Input,
+    InputNumber,
+    InputPassword,
+    Mentions,
+    Radio,
+    RadioGroup,
+    RangePicker,
+    Rate,
+    Select,
+    Space,
+    Switch,
+    TimePicker,
+    TreeSelect,
+    Upload,
+  },
+  config: {
+    baseModelPropName: 'value',
+    modelPropNameMap: {
+      Checkbox: 'checked',
+      Radio: 'checked',
+      Switch: 'checked',
+      Upload: 'fileList',
+    },
+  },
+  defineRules: {
+    required: (value, _params, ctx) => {
+      if ((!value && value !== 0) || value.length === 0) {
+        return $t('formRules.required', [ctx.label]);
+      }
+      return true;
+    },
+  },
+});
+
+const useVbenForm = useForm<FormComponentType>;
+
+export { useVbenForm, z };
+
+export type VbenFormSchema = FormSchema<FormComponentType>;
+export type { VbenFormProps };

+ 1 - 0
apps/web-antd/src/adapter/index.ts

@@ -0,0 +1 @@
+export * from './form';

+ 5 - 8
apps/web-antd/src/layouts/basic.vue

@@ -13,11 +13,12 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
+import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
 import { useAuthStore } from '#/store';
+import LoginForm from '#/views/_core/authentication/login.vue';
 
 const notifications = ref<NotificationItem[]>([
   {
@@ -87,8 +88,6 @@ const menus = computed(() => [
   },
 ]);
 
-const { loginLoading } = storeToRefs(authStore);
-
 const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
@@ -130,11 +129,9 @@ function handleMakeAll() {
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"
         :avatar
-        :loading="loginLoading"
-        password-placeholder="123456"
-        username-placeholder="vben"
-        @submit="authStore.authLogin"
-      />
+      >
+        <LoginForm />
+      </AuthenticationLoginExpiredModal>
     </template>
     <template #lock-screen>
       <LockScreen :avatar @to-login="handleLogout" />

+ 39 - 5
apps/web-antd/src/views/_core/authentication/code-login.vue

@@ -1,15 +1,49 @@
 <script lang="ts" setup>
-import type { LoginCodeParams } from '@vben/common-ui';
+import type { LoginCodeParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 
-import { AuthenticationCodeLogin } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationCodeLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'CodeLogin' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.mobile'),
+      },
+      fieldName: 'phoneNumber',
+      label: $t('authentication.mobile'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.mobileTip') })
+        .refine((v) => /^\d{11}$/.test(v), {
+          message: $t('authentication.mobileErrortip'),
+        }),
+    },
+    {
+      component: 'VbenPinInput',
+      componentProps: {
+        createText: (countdown: number) => {
+          const text =
+            countdown > 0
+              ? $t('authentication.sendText', [countdown])
+              : $t('authentication.sendCode');
+          return text;
+        },
+        placeholder: $t('authentication.code'),
+      },
+      fieldName: 'code',
+      label: $t('authentication.code'),
+      rules: z.string().min(1, { message: $t('authentication.codeTip') }),
+    },
+  ];
+});
 /**
  * 异步处理登录操作
  * Asynchronously handle the login process
@@ -23,8 +57,8 @@ async function handleLogin(values: LoginCodeParams) {
 
 <template>
   <AuthenticationCodeLogin
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleLogin"
   />
 </template>

+ 23 - 4
apps/web-antd/src/views/_core/authentication/forget-password.vue

@@ -1,13 +1,32 @@
 <script lang="ts" setup>
-import { ref } from 'vue';
+import type { VbenFormSchema } from '@vben/common-ui';
 
-import { AuthenticationForgetPassword } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { computed, ref } from 'vue';
+
+import { AuthenticationForgetPassword, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'ForgetPassword' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: 'example@example.com',
+      },
+      fieldName: 'email',
+      label: $t('authentication.email'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.emailTip') })
+        .email($t('authentication.emailValidErrorTip')),
+    },
+  ];
+});
+
 function handleSubmit(value: string) {
   // eslint-disable-next-line no-console
   console.log('reset email:', value);
@@ -16,8 +35,8 @@ function handleSubmit(value: string) {
 
 <template>
   <AuthenticationForgetPassword
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 76 - 3
apps/web-antd/src/views/_core/authentication/login.vue

@@ -1,18 +1,91 @@
 <script lang="ts" setup>
-import { AuthenticationLogin } from '@vben/common-ui';
+import type { VbenFormSchema } from '@vben/common-ui';
+import type { BasicOption } from '@vben/types';
+
+import { computed } from 'vue';
+
+import { AuthenticationLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 import { useAuthStore } from '#/store';
 
 defineOptions({ name: 'Login' });
 
 const authStore = useAuthStore();
+
+const MOCK_USER_OPTIONS: BasicOption[] = [
+  {
+    label: '超级管理员',
+    value: 'vben',
+  },
+  {
+    label: '管理员',
+    value: 'admin',
+  },
+  {
+    label: '用户',
+    value: 'jack',
+  },
+];
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenSelect',
+      componentProps: {
+        options: MOCK_USER_OPTIONS,
+        placeholder: $t('authentication.selectAccount'),
+      },
+      fieldName: 'selectAccount',
+      label: $t('authentication.selectAccount'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.selectAccount') })
+        .optional()
+        .default('vben'),
+    },
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      dependencies: {
+        trigger(values, form) {
+          if (values.selectAccount) {
+            const findUser = MOCK_USER_OPTIONS.find(
+              (item) => item.value === values.selectAccount,
+            );
+            if (findUser) {
+              form.setValues({
+                password: '123456',
+                username: findUser.value,
+              });
+            }
+          }
+        },
+        triggerFields: ['selectAccount'],
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+  ];
+});
 </script>
 
 <template>
   <AuthenticationLogin
+    :form-schema="formSchema"
     :loading="authStore.loginLoading"
-    password-placeholder="123456"
-    username-placeholder="vben"
     @submit="authStore.authLogin"
   />
 </template>

+ 81 - 5
apps/web-antd/src/views/_core/authentication/register.vue

@@ -1,15 +1,91 @@
 <script lang="ts" setup>
-import type { LoginAndRegisterParams } from '@vben/common-ui';
+import type { LoginAndRegisterParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, h, ref } from 'vue';
 
-import { AuthenticationRegister } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationRegister, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'Register' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        passwordStrength: true,
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      renderComponentContent() {
+        return {
+          strengthText: () => $t('authentication.passwordStrength'),
+        };
+      },
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.confirmPassword'),
+      },
+      dependencies: {
+        rules(values) {
+          const { password } = values;
+          return z
+            .string()
+            .min(1, { message: $t('authentication.passwordTip') })
+            .refine((value) => value === password, {
+              message: $t('authentication.confirmPasswordTip'),
+            });
+        },
+        triggerFields: ['password'],
+      },
+      fieldName: 'confirmPassword',
+      label: $t('authentication.confirmPassword'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenCheckbox',
+      fieldName: 'agreePolicy',
+      renderComponentContent: () => ({
+        default: () =>
+          h('span', [
+            $t('authentication.agree'),
+            h(
+              'a',
+              {
+                class:
+                  'cursor-pointer text-primary ml-1 hover:text-primary-hover',
+                href: '',
+              },
+              [
+                $t('authentication.privacyPolicy'),
+                '&',
+                $t('authentication.terms'),
+              ],
+            ),
+          ]),
+      }),
+      rules: z.boolean().refine((value) => !!value, {
+        message: $t('authentication.agreeTip'),
+      }),
+    },
+  ];
+});
+
 function handleSubmit(value: LoginAndRegisterParams) {
   // eslint-disable-next-line no-console
   console.log('register submit:', value);
@@ -18,8 +94,8 @@ function handleSubmit(value: LoginAndRegisterParams) {
 
 <template>
   <AuthenticationRegister
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 89 - 0
apps/web-ele/src/adapter/form.ts

@@ -0,0 +1,89 @@
+import type {
+  BaseFormComponentType,
+  VbenFormSchema as FormSchema,
+  VbenFormProps,
+} from '@vben/common-ui';
+
+import { h } from 'vue';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import {
+  ElButton,
+  ElCheckbox,
+  ElCheckboxGroup,
+  ElDivider,
+  ElInput,
+  ElInputNumber,
+  ElRadioGroup,
+  ElSelect,
+  ElSpace,
+  ElSwitch,
+  ElTimePicker,
+  ElTreeSelect,
+  ElUpload,
+} from 'element-plus';
+// 业务表单组件适配
+
+export type FormComponentType =
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'Divider'
+  | 'Input'
+  | 'InputNumber'
+  | 'RadioGroup'
+  | 'Select'
+  | 'Space'
+  | 'Switch'
+  | 'TimePicker'
+  | 'TreeSelect'
+  | 'Upload'
+  | BaseFormComponentType;
+
+// 初始化表单组件,并注册到form组件内部
+setupVbenForm<FormComponentType>({
+  components: {
+    Checkbox: ElCheckbox,
+    CheckboxGroup: ElCheckboxGroup,
+    // 自定义默认的重置按钮
+    DefaultResetActionButton: (props, { attrs, slots }) => {
+      return h(ElButton, { ...props, attrs, type: 'info' }, slots);
+    },
+    // 自定义默认的提交按钮
+    DefaultSubmitActionButton: (props, { attrs, slots }) => {
+      return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
+    },
+    Divider: ElDivider,
+    Input: ElInput,
+    InputNumber: ElInputNumber,
+    RadioGroup: ElRadioGroup,
+    Select: ElSelect,
+    Space: ElSpace,
+    Switch: ElSwitch,
+    TimePicker: ElTimePicker,
+    TreeSelect: ElTreeSelect,
+    Upload: ElUpload,
+  },
+  config: {
+    modelPropNameMap: {
+      Upload: 'fileList',
+    },
+  },
+  defineRules: {
+    required: (value, _params, ctx) => {
+      if ((!value && value !== 0) || value.length === 0) {
+        return $t('formRules.required', [ctx.label]);
+      }
+      return true;
+    },
+  },
+});
+
+const useVbenForm = useForm<FormComponentType>;
+
+export { useVbenForm, z };
+
+export type VbenFormSchema = FormSchema<FormComponentType>;
+export type { VbenFormProps };

+ 1 - 0
apps/web-ele/src/adapter/index.ts

@@ -0,0 +1 @@
+export * from './form';

+ 5 - 8
apps/web-ele/src/layouts/basic.vue

@@ -13,11 +13,12 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
+import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
 import { useAuthStore } from '#/store';
+import LoginForm from '#/views/_core/authentication/login.vue';
 
 const notifications = ref<NotificationItem[]>([
   {
@@ -87,8 +88,6 @@ const menus = computed(() => [
   },
 ]);
 
-const { loginLoading } = storeToRefs(authStore);
-
 const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
@@ -130,11 +129,9 @@ function handleMakeAll() {
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"
         :avatar
-        :loading="loginLoading"
-        password-placeholder="123456"
-        username-placeholder="vben"
-        @submit="authStore.authLogin"
-      />
+      >
+        <LoginForm />
+      </AuthenticationLoginExpiredModal>
     </template>
     <template #lock-screen>
       <LockScreen :avatar @to-login="handleLogout" />

+ 39 - 5
apps/web-ele/src/views/_core/authentication/code-login.vue

@@ -1,15 +1,49 @@
 <script lang="ts" setup>
-import type { LoginCodeParams } from '@vben/common-ui';
+import type { LoginCodeParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 
-import { AuthenticationCodeLogin } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationCodeLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'CodeLogin' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.mobile'),
+      },
+      fieldName: 'phoneNumber',
+      label: $t('authentication.mobile'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.mobileTip') })
+        .refine((v) => /^\d{11}$/.test(v), {
+          message: $t('authentication.mobileErrortip'),
+        }),
+    },
+    {
+      component: 'VbenPinInput',
+      componentProps: {
+        createText: (countdown: number) => {
+          const text =
+            countdown > 0
+              ? $t('authentication.sendText', [countdown])
+              : $t('authentication.sendCode');
+          return text;
+        },
+        placeholder: $t('authentication.code'),
+      },
+      fieldName: 'code',
+      label: $t('authentication.code'),
+      rules: z.string().min(1, { message: $t('authentication.codeTip') }),
+    },
+  ];
+});
 /**
  * 异步处理登录操作
  * Asynchronously handle the login process
@@ -23,8 +57,8 @@ async function handleLogin(values: LoginCodeParams) {
 
 <template>
   <AuthenticationCodeLogin
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleLogin"
   />
 </template>

+ 23 - 4
apps/web-ele/src/views/_core/authentication/forget-password.vue

@@ -1,13 +1,32 @@
 <script lang="ts" setup>
-import { ref } from 'vue';
+import type { VbenFormSchema } from '@vben/common-ui';
 
-import { AuthenticationForgetPassword } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { computed, ref } from 'vue';
+
+import { AuthenticationForgetPassword, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'ForgetPassword' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: 'example@example.com',
+      },
+      fieldName: 'email',
+      label: $t('authentication.email'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.emailTip') })
+        .email($t('authentication.emailValidErrorTip')),
+    },
+  ];
+});
+
 function handleSubmit(value: string) {
   // eslint-disable-next-line no-console
   console.log('reset email:', value);
@@ -16,8 +35,8 @@ function handleSubmit(value: string) {
 
 <template>
   <AuthenticationForgetPassword
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 76 - 3
apps/web-ele/src/views/_core/authentication/login.vue

@@ -1,18 +1,91 @@
 <script lang="ts" setup>
-import { AuthenticationLogin } from '@vben/common-ui';
+import type { VbenFormSchema } from '@vben/common-ui';
+import type { BasicOption } from '@vben/types';
+
+import { computed } from 'vue';
+
+import { AuthenticationLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 import { useAuthStore } from '#/store';
 
 defineOptions({ name: 'Login' });
 
 const authStore = useAuthStore();
+
+const MOCK_USER_OPTIONS: BasicOption[] = [
+  {
+    label: '超级管理员',
+    value: 'vben',
+  },
+  {
+    label: '管理员',
+    value: 'admin',
+  },
+  {
+    label: '用户',
+    value: 'jack',
+  },
+];
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenSelect',
+      componentProps: {
+        options: MOCK_USER_OPTIONS,
+        placeholder: $t('authentication.selectAccount'),
+      },
+      fieldName: 'selectAccount',
+      label: $t('authentication.selectAccount'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.selectAccount') })
+        .optional()
+        .default('vben'),
+    },
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      dependencies: {
+        trigger(values, form) {
+          if (values.selectAccount) {
+            const findUser = MOCK_USER_OPTIONS.find(
+              (item) => item.value === values.selectAccount,
+            );
+            if (findUser) {
+              form.setValues({
+                password: '123456',
+                username: findUser.value,
+              });
+            }
+          }
+        },
+        triggerFields: ['selectAccount'],
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+  ];
+});
 </script>
 
 <template>
   <AuthenticationLogin
+    :form-schema="formSchema"
     :loading="authStore.loginLoading"
-    password-placeholder="123456"
-    username-placeholder="vben"
     @submit="authStore.authLogin"
   />
 </template>

+ 81 - 5
apps/web-ele/src/views/_core/authentication/register.vue

@@ -1,15 +1,91 @@
 <script lang="ts" setup>
-import type { LoginAndRegisterParams } from '@vben/common-ui';
+import type { LoginAndRegisterParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, h, ref } from 'vue';
 
-import { AuthenticationRegister } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationRegister, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'Register' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        passwordStrength: true,
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      renderComponentContent() {
+        return {
+          strengthText: () => $t('authentication.passwordStrength'),
+        };
+      },
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.confirmPassword'),
+      },
+      dependencies: {
+        rules(values) {
+          const { password } = values;
+          return z
+            .string()
+            .min(1, { message: $t('authentication.passwordTip') })
+            .refine((value) => value === password, {
+              message: $t('authentication.confirmPasswordTip'),
+            });
+        },
+        triggerFields: ['password'],
+      },
+      fieldName: 'confirmPassword',
+      label: $t('authentication.confirmPassword'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenCheckbox',
+      fieldName: 'agreePolicy',
+      renderComponentContent: () => ({
+        default: () =>
+          h('span', [
+            $t('authentication.agree'),
+            h(
+              'a',
+              {
+                class:
+                  'cursor-pointer text-primary ml-1 hover:text-primary-hover',
+                href: '',
+              },
+              [
+                $t('authentication.privacyPolicy'),
+                '&',
+                $t('authentication.terms'),
+              ],
+            ),
+          ]),
+      }),
+      rules: z.boolean().refine((value) => !!value, {
+        message: $t('authentication.agreeTip'),
+      }),
+    },
+  ];
+});
+
 function handleSubmit(value: LoginAndRegisterParams) {
   // eslint-disable-next-line no-console
   console.log('register submit:', value);
@@ -18,8 +94,8 @@ function handleSubmit(value: LoginAndRegisterParams) {
 
 <template>
   <AuthenticationRegister
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 98 - 0
apps/web-naive/src/adapter/form.ts

@@ -0,0 +1,98 @@
+import type {
+  BaseFormComponentType,
+  VbenFormSchema as FormSchema,
+  VbenFormProps,
+} from '@vben/common-ui';
+
+import { h } from 'vue';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import {
+  NButton,
+  NCheckbox,
+  NCheckboxGroup,
+  NDatePicker,
+  NDivider,
+  NInput,
+  NInputNumber,
+  NRadioGroup,
+  NSelect,
+  NSpace,
+  NSwitch,
+  NTimePicker,
+  NTreeSelect,
+  NUpload,
+} from 'naive-ui';
+// 业务表单组件适配
+
+export type FormComponentType =
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'Divider'
+  | 'Input'
+  | 'InputNumber'
+  | 'RadioGroup'
+  | 'Select'
+  | 'Space'
+  | 'Switch'
+  | 'TimePicker'
+  | 'TreeSelect'
+  | 'Upload'
+  | BaseFormComponentType;
+
+// 初始化表单组件,并注册到form组件内部
+setupVbenForm<FormComponentType>({
+  components: {
+    Checkbox: NCheckbox,
+    CheckboxGroup: NCheckboxGroup,
+    DatePicker: NDatePicker,
+    // 自定义默认的重置按钮
+    DefaultResetActionButton: (props, { attrs, slots }) => {
+      return h(NButton, { ...props, attrs, text: false, type: 'info' }, slots);
+    },
+    // 自定义默认的提交按钮
+    DefaultSubmitActionButton: (props, { attrs, slots }) => {
+      return h(
+        NButton,
+        { ...props, attrs, text: false, type: 'primary' },
+        slots,
+      );
+    },
+    Divider: NDivider,
+    Input: NInput,
+    InputNumber: NInputNumber,
+    RadioGroup: NRadioGroup,
+    Select: NSelect,
+    Space: NSpace,
+    Switch: NSwitch,
+    TimePicker: NTimePicker,
+    TreeSelect: NTreeSelect,
+    Upload: NUpload,
+  },
+  config: {
+    baseModelPropName: 'value',
+    modelPropNameMap: {
+      Checkbox: 'checked',
+      Radio: 'checked',
+      Upload: 'fileList',
+    },
+  },
+  defineRules: {
+    required: (value, _params, ctx) => {
+      if ((!value && value !== 0) || value.length === 0) {
+        return $t('formRules.required', [ctx.label]);
+      }
+      return true;
+    },
+  },
+});
+
+const useVbenForm = useForm<FormComponentType>;
+
+export { useVbenForm, z };
+
+export type VbenFormSchema = FormSchema<FormComponentType>;
+export type { VbenFormProps };

+ 1 - 0
apps/web-naive/src/adapter/index.ts

@@ -0,0 +1 @@
+export * from './form';

+ 5 - 8
apps/web-naive/src/layouts/basic.vue

@@ -13,11 +13,12 @@ import {
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
-import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores';
+import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
 import { $t } from '#/locales';
 import { useAuthStore } from '#/store';
+import LoginForm from '#/views/_core/authentication/login.vue';
 
 const notifications = ref<NotificationItem[]>([
   {
@@ -87,8 +88,6 @@ const menus = computed(() => [
   },
 ]);
 
-const { loginLoading } = storeToRefs(authStore);
-
 const avatar = computed(() => {
   return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
 });
@@ -130,11 +129,9 @@ function handleMakeAll() {
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"
         :avatar
-        :loading="loginLoading"
-        password-placeholder="123456"
-        username-placeholder="vben"
-        @submit="authStore.authLogin"
-      />
+      >
+        <LoginForm />
+      </AuthenticationLoginExpiredModal>
     </template>
     <template #lock-screen>
       <LockScreen :avatar @to-login="handleLogout" />

+ 39 - 5
apps/web-naive/src/views/_core/authentication/code-login.vue

@@ -1,15 +1,49 @@
 <script lang="ts" setup>
-import type { LoginCodeParams } from '@vben/common-ui';
+import type { LoginCodeParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 
-import { AuthenticationCodeLogin } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationCodeLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'CodeLogin' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.mobile'),
+      },
+      fieldName: 'phoneNumber',
+      label: $t('authentication.mobile'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.mobileTip') })
+        .refine((v) => /^\d{11}$/.test(v), {
+          message: $t('authentication.mobileErrortip'),
+        }),
+    },
+    {
+      component: 'VbenPinInput',
+      componentProps: {
+        createText: (countdown: number) => {
+          const text =
+            countdown > 0
+              ? $t('authentication.sendText', [countdown])
+              : $t('authentication.sendCode');
+          return text;
+        },
+        placeholder: $t('authentication.code'),
+      },
+      fieldName: 'code',
+      label: $t('authentication.code'),
+      rules: z.string().min(1, { message: $t('authentication.codeTip') }),
+    },
+  ];
+});
 /**
  * 异步处理登录操作
  * Asynchronously handle the login process
@@ -23,8 +57,8 @@ async function handleLogin(values: LoginCodeParams) {
 
 <template>
   <AuthenticationCodeLogin
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleLogin"
   />
 </template>

+ 23 - 4
apps/web-naive/src/views/_core/authentication/forget-password.vue

@@ -1,13 +1,32 @@
 <script lang="ts" setup>
-import { ref } from 'vue';
+import type { VbenFormSchema } from '@vben/common-ui';
 
-import { AuthenticationForgetPassword } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { computed, ref } from 'vue';
+
+import { AuthenticationForgetPassword, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'ForgetPassword' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: 'example@example.com',
+      },
+      fieldName: 'email',
+      label: $t('authentication.email'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.emailTip') })
+        .email($t('authentication.emailValidErrorTip')),
+    },
+  ];
+});
+
 function handleSubmit(value: string) {
   // eslint-disable-next-line no-console
   console.log('reset email:', value);
@@ -16,8 +35,8 @@ function handleSubmit(value: string) {
 
 <template>
   <AuthenticationForgetPassword
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 76 - 3
apps/web-naive/src/views/_core/authentication/login.vue

@@ -1,18 +1,91 @@
 <script lang="ts" setup>
-import { AuthenticationLogin } from '@vben/common-ui';
+import type { VbenFormSchema } from '@vben/common-ui';
+import type { BasicOption } from '@vben/types';
+
+import { computed } from 'vue';
+
+import { AuthenticationLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 import { useAuthStore } from '#/store';
 
 defineOptions({ name: 'Login' });
 
 const authStore = useAuthStore();
+
+const MOCK_USER_OPTIONS: BasicOption[] = [
+  {
+    label: '超级管理员',
+    value: 'vben',
+  },
+  {
+    label: '管理员',
+    value: 'admin',
+  },
+  {
+    label: '用户',
+    value: 'jack',
+  },
+];
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenSelect',
+      componentProps: {
+        options: MOCK_USER_OPTIONS,
+        placeholder: $t('authentication.selectAccount'),
+      },
+      fieldName: 'selectAccount',
+      label: $t('authentication.selectAccount'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.selectAccount') })
+        .optional()
+        .default('vben'),
+    },
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      dependencies: {
+        trigger(values, form) {
+          if (values.selectAccount) {
+            const findUser = MOCK_USER_OPTIONS.find(
+              (item) => item.value === values.selectAccount,
+            );
+            if (findUser) {
+              form.setValues({
+                password: '123456',
+                username: findUser.value,
+              });
+            }
+          }
+        },
+        triggerFields: ['selectAccount'],
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+  ];
+});
 </script>
 
 <template>
   <AuthenticationLogin
+    :form-schema="formSchema"
     :loading="authStore.loginLoading"
-    password-placeholder="123456"
-    username-placeholder="vben"
     @submit="authStore.authLogin"
   />
 </template>

+ 81 - 5
apps/web-naive/src/views/_core/authentication/register.vue

@@ -1,15 +1,91 @@
 <script lang="ts" setup>
-import type { LoginAndRegisterParams } from '@vben/common-ui';
+import type { LoginAndRegisterParams, VbenFormSchema } from '@vben/common-ui';
 
-import { ref } from 'vue';
+import { computed, h, ref } from 'vue';
 
-import { AuthenticationRegister } from '@vben/common-ui';
-import { LOGIN_PATH } from '@vben/constants';
+import { AuthenticationRegister, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
 
 defineOptions({ name: 'Register' });
 
 const loading = ref(false);
 
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        passwordStrength: true,
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      renderComponentContent() {
+        return {
+          strengthText: () => $t('authentication.passwordStrength'),
+        };
+      },
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.confirmPassword'),
+      },
+      dependencies: {
+        rules(values) {
+          const { password } = values;
+          return z
+            .string()
+            .min(1, { message: $t('authentication.passwordTip') })
+            .refine((value) => value === password, {
+              message: $t('authentication.confirmPasswordTip'),
+            });
+        },
+        triggerFields: ['password'],
+      },
+      fieldName: 'confirmPassword',
+      label: $t('authentication.confirmPassword'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenCheckbox',
+      fieldName: 'agreePolicy',
+      renderComponentContent: () => ({
+        default: () =>
+          h('span', [
+            $t('authentication.agree'),
+            h(
+              'a',
+              {
+                class:
+                  'cursor-pointer text-primary ml-1 hover:text-primary-hover',
+                href: '',
+              },
+              [
+                $t('authentication.privacyPolicy'),
+                '&',
+                $t('authentication.terms'),
+              ],
+            ),
+          ]),
+      }),
+      rules: z.boolean().refine((value) => !!value, {
+        message: $t('authentication.agreeTip'),
+      }),
+    },
+  ];
+});
+
 function handleSubmit(value: LoginAndRegisterParams) {
   // eslint-disable-next-line no-console
   console.log('register submit:', value);
@@ -18,8 +94,8 @@ function handleSubmit(value: LoginAndRegisterParams) {
 
 <template>
   <AuthenticationRegister
+    :form-schema="formSchema"
     :loading="loading"
-    :login-path="LOGIN_PATH"
     @submit="handleSubmit"
   />
 </template>

+ 4 - 0
docs/.vitepress/config/zh.mts

@@ -160,6 +160,10 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
           link: 'common-ui/vben-drawer',
           text: 'Vben Drawer 抽屉',
         },
+        {
+          link: 'common-ui/vben-form',
+          text: 'Vben Form 表单',
+        },
       ],
     },
   ];

+ 11 - 0
docs/src/components/common-ui/vben-form.md

@@ -0,0 +1,11 @@
+---
+outline: deep
+---
+
+# Vben Form 表单
+
+框架提供的表单组件,可适配 `Element Plus`、`Ant Design Vue`、`Naive`UI 框架。
+
+# 使用
+
+TODO

+ 0 - 10
docs/src/en/guide/in-depth/login.md

@@ -61,11 +61,6 @@ If you want to adjust the content of the login form, you can configure the `Auth
    */
   loading?: boolean;
 
-  /**
-   * @en Password placeholder
-   */
-  passwordPlaceholder?: string;
-
   /**
    * @en QR code login path
    */
@@ -114,11 +109,6 @@ If you want to adjust the content of the login form, you can configure the `Auth
    * @en Login box title
    */
   title?: string;
-
-  /**
-   * @en Username placeholder
-   */
-  usernamePlaceholder?: string;
 }
 ```
 

+ 0 - 9
docs/src/guide/in-depth/login.md

@@ -54,11 +54,6 @@
    */
   loading?: boolean;
 
-  /**
-   * @zh_CN 密码占位符
-   */
-  passwordPlaceholder?: string;
-
   /**
    * @zh_CN 二维码登录路径
    */
@@ -108,10 +103,6 @@
    */
   title?: string;
 
-  /**
-   * @zh_CN 用户名占位符
-   */
-  usernamePlaceholder?: string;
 }
 ```
 

+ 2 - 0
internal/lint-configs/eslint-config/src/configs/unicorn.ts

@@ -15,12 +15,14 @@ export async function unicorn(): Promise<Linter.Config[]> {
       rules: {
         ...pluginUnicorn.configs.recommended.rules,
 
+        'unicorn/better-regex': 'off',
         'unicorn/consistent-destructuring': 'off',
         'unicorn/consistent-function-scoping': 'off',
         'unicorn/filename-case': 'off',
         'unicorn/import-style': 'off',
         'unicorn/no-array-for-each': 'off',
         'unicorn/no-null': 'off',
+        'unicorn/no-useless-undefined': 'off',
         'unicorn/prefer-at': 'off',
         'unicorn/prefer-dom-node-text-content': 'off',
         'unicorn/prefer-export-from': ['error', { ignoreUsedVariables: true }],

+ 5 - 1
internal/vite-config/src/config/application.ts

@@ -81,7 +81,11 @@ function defineApplicationConfig(userConfigPromise?: DefineApplicationOptions) {
         port,
         warmup: {
           // 预热文件
-          clientFiles: ['./index.html', './src/{views,layouts,router,store}/*'],
+          clientFiles: [
+            './index.html',
+            './bootstrap.ts',
+            './src/{views,layouts,router,store,api}/*',
+          ],
         },
       },
     };

+ 9 - 1
internal/vite-config/src/plugins/archiver.ts

@@ -3,6 +3,7 @@ import type { PluginOption } from 'vite';
 import type { ArchiverPluginOptions } from '../typing';
 
 import fs from 'node:fs';
+import fsp from 'node:fs/promises';
 import { join } from 'node:path';
 
 import archiver from 'archiver';
@@ -18,7 +19,14 @@ export const viteArchiverPlugin = (
 
         setTimeout(async () => {
           const folderToZip = 'dist';
-          const zipOutputPath = join(process.cwd(), outputDir, `${name}.zip`);
+
+          const zipOutputDir = join(process.cwd(), outputDir);
+          const zipOutputPath = join(zipOutputDir, `${name}.zip`);
+          try {
+            await fsp.mkdir(zipOutputDir, { recursive: true });
+          } catch {
+            // ignore
+          }
 
           try {
             await zipFolder(folderToZip, zipOutputPath);

+ 4 - 1
packages/@core/base/design/src/css/global.css

@@ -23,12 +23,15 @@
     scroll-behavior: smooth;
     text-rendering: optimizelegibility;
     -webkit-tap-highlight-color: transparent;
+
+    /* -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale; */
   }
 
   #app,
   body,
   html {
-    @apply !pointer-events-auto size-full overscroll-none;
+    @apply size-full overscroll-none;
   }
 
   body {

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

@@ -18,7 +18,6 @@ export {
   CircleHelp,
   Copy,
   CornerDownLeft,
-  Disc as IconDefault,
   Ellipsis,
   Expand,
   ExternalLink,
@@ -35,6 +34,7 @@ export {
   LogOut,
   MailCheck,
   Maximize,
+  Menu as IconDefault,
   Menu,
   Minimize,
   Minimize2,

+ 0 - 1
packages/@core/base/shared/build.config.ts

@@ -4,7 +4,6 @@ export default defineBuildConfig({
   clean: true,
   declaration: true,
   entries: [
-    'src/index',
     'src/store',
     'src/constants/index',
     'src/utils/index',

+ 19 - 10
packages/@core/base/shared/package.json

@@ -17,14 +17,7 @@
     "dist"
   ],
   "sideEffects": false,
-  "main": "./dist/index.mjs",
-  "module": "./dist/index.mjs",
   "exports": {
-    ".": {
-      "types": "./src/index.ts",
-      "development": "./src/index.ts",
-      "default": "./dist/index.mjs"
-    },
     "./constants": {
       "types": "./src/constants/index.ts",
       "development": "./src/constants/index.ts",
@@ -53,9 +46,25 @@
   },
   "publishConfig": {
     "exports": {
-      ".": {
-        "types": "./dist/index.d.ts",
-        "default": "./dist/index.mjs"
+      "./constants": {
+        "types": "./dist/constants/index.d.ts",
+        "default": "./dist/constants/index.mjs"
+      },
+      "./utils": {
+        "types": "./dist/utils/index.d.ts",
+        "default": "./dist/utils/index.mjs"
+      },
+      "./color": {
+        "types": "./dist/color/index.d.ts",
+        "default": "./dist/color/index.mjs"
+      },
+      "./cache": {
+        "types": "./dist/cache/index.d.ts",
+        "default": "./dist/cache/index.mjs"
+      },
+      "./store": {
+        "types": "./dist/store/index.d.ts",
+        "default": "./dist/store.mjs"
       }
     }
   },

+ 0 - 5
packages/@core/base/shared/src/index.ts

@@ -1,5 +0,0 @@
-export * from './cache';
-export * from './color';
-export * from './constants';
-export * from './store';
-export * from './utils';

+ 60 - 0
packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+
+import { StateHandler } from '../state-handler';
+
+describe('stateHandler', () => {
+  it('should resolve when condition is set to true', async () => {
+    const handler = new StateHandler();
+
+    // 模拟异步设置 condition 为 true
+    setTimeout(() => {
+      handler.setConditionTrue(); // 明确触发 condition 为 true
+    }, 10);
+
+    // 等待条件被设置为 true
+    await handler.waitForCondition();
+    expect(handler.isConditionTrue()).toBe(true);
+  });
+
+  it('should resolve immediately if condition is already true', async () => {
+    const handler = new StateHandler();
+    handler.setConditionTrue(); // 提前设置为 true
+
+    // 立即 resolve,因为 condition 已经是 true
+    await handler.waitForCondition();
+    expect(handler.isConditionTrue()).toBe(true);
+  });
+
+  it('should reject when condition is set to false after waiting', async () => {
+    const handler = new StateHandler();
+
+    // 模拟异步设置 condition 为 false
+    setTimeout(() => {
+      handler.setConditionFalse(); // 明确触发 condition 为 false
+    }, 10);
+
+    // 等待过程中,期望 Promise 被 reject
+    await expect(handler.waitForCondition()).rejects.toThrow();
+    expect(handler.isConditionTrue()).toBe(false);
+  });
+
+  it('should reset condition to false', () => {
+    const handler = new StateHandler();
+    handler.setConditionTrue(); // 设置为 true
+    handler.reset(); // 重置为 false
+
+    expect(handler.isConditionTrue()).toBe(false);
+  });
+
+  it('should resolve when condition is set to true after reset', async () => {
+    const handler = new StateHandler();
+    handler.reset(); // 确保初始为 false
+
+    setTimeout(() => {
+      handler.setConditionTrue(); // 重置后设置为 true
+    }, 10);
+
+    await handler.waitForCondition();
+    expect(handler.isConditionTrue()).toBe(true);
+  });
+});

+ 80 - 0
packages/@core/base/shared/src/utils/__tests__/util.test.ts

@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+
+import { bindMethods } from '../util';
+
+class TestClass {
+  public value: string;
+
+  constructor(value: string) {
+    this.value = value;
+    bindMethods(this); // 调用通用方法
+  }
+
+  getValue() {
+    return this.value;
+  }
+
+  setValue(newValue: string) {
+    this.value = newValue;
+  }
+}
+
+describe('bindMethods', () => {
+  it('should bind methods to the instance correctly', () => {
+    const instance = new TestClass('initial');
+
+    // 解构方法
+    const { getValue } = instance;
+
+    // 检查 getValue 是否能正确调用,并且 this 绑定了 instance
+    expect(getValue()).toBe('initial');
+  });
+
+  it('should bind multiple methods', () => {
+    const instance = new TestClass('initial');
+
+    const { getValue, setValue } = instance;
+
+    // 检查 getValue 和 setValue 方法是否正确绑定了 this
+    setValue('newValue');
+    expect(getValue()).toBe('newValue');
+  });
+
+  it('should not bind non-function properties', () => {
+    const instance = new TestClass('initial');
+
+    // 检查普通属性是否保持原样
+    expect(instance.value).toBe('initial');
+  });
+
+  it('should not bind constructor method', () => {
+    const instance = new TestClass('test');
+
+    // 检查 constructor 是否没有被绑定
+    expect(instance.constructor.name).toBe('TestClass');
+  });
+
+  it('should not bind getter/setter properties', () => {
+    class TestWithGetterSetter {
+      private _value: string = 'test';
+
+      constructor() {
+        bindMethods(this);
+      }
+
+      get value() {
+        return this._value;
+      }
+
+      set value(newValue: string) {
+        this._value = newValue;
+      }
+    }
+
+    const instance = new TestWithGetterSetter();
+    const { value } = instance;
+
+    // Getter 和 setter 不应被绑定
+    expect(value).toBe('test');
+  });
+});

+ 2 - 0
packages/@core/base/shared/src/utils/index.ts

@@ -5,9 +5,11 @@ export * from './inference';
 export * from './letter';
 export * from './merge';
 export * from './nprogress';
+export * from './state-handler';
 export * from './to';
 export * from './tree';
 export * from './unique';
 export * from './update-css-variables';
+export * from './util';
 export * from './window';
 export { default as cloneDeep } from 'lodash.clonedeep';

+ 50 - 0
packages/@core/base/shared/src/utils/state-handler.ts

@@ -0,0 +1,50 @@
+export class StateHandler {
+  private condition: boolean = false;
+  private rejectCondition: (() => void) | null = null;
+  private resolveCondition: (() => void) | null = null;
+
+  // 清理 resolve/reject 函数
+  private clearPromises() {
+    this.resolveCondition = null;
+    this.rejectCondition = null;
+  }
+
+  isConditionTrue(): boolean {
+    return this.condition;
+  }
+
+  reset() {
+    this.condition = false;
+    this.clearPromises();
+  }
+
+  // 触发状态为 false 时,reject
+  setConditionFalse() {
+    this.condition = false;
+    if (this.rejectCondition) {
+      this.rejectCondition();
+      this.clearPromises();
+    }
+  }
+
+  // 触发状态为 true 时,resolve
+  setConditionTrue() {
+    this.condition = true;
+    if (this.resolveCondition) {
+      this.resolveCondition();
+      this.clearPromises();
+    }
+  }
+
+  // 返回一个 Promise,等待 condition 变为 true
+  waitForCondition(): Promise<void> {
+    return new Promise((resolve, reject) => {
+      if (this.condition) {
+        resolve(); // 如果 condition 已经为 true,立即 resolve
+      } else {
+        this.resolveCondition = resolve;
+        this.rejectCondition = reject;
+      }
+    });
+  }
+}

+ 19 - 0
packages/@core/base/shared/src/utils/util.ts

@@ -0,0 +1,19 @@
+export function bindMethods<T extends object>(instance: T): void {
+  const prototype = Object.getPrototypeOf(instance);
+  const propertyNames = Object.getOwnPropertyNames(prototype);
+
+  propertyNames.forEach((propertyName) => {
+    const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
+    const propertyValue = instance[propertyName as keyof T];
+
+    if (
+      typeof propertyValue === 'function' &&
+      propertyName !== 'constructor' &&
+      descriptor &&
+      !descriptor.get &&
+      !descriptor.set
+    ) {
+      instance[propertyName as keyof T] = propertyValue.bind(instance);
+    }
+  });
+}

+ 3 - 1
packages/@core/composables/src/use-content-style.ts

@@ -4,9 +4,11 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
 import {
   CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
   CSS_VARIABLE_LAYOUT_CONTENT_WIDTH,
+} from '@vben-core/shared/constants';
+import {
   getElementVisibleRect,
   type VisibleDomRect,
-} from '@vben-core/shared';
+} from '@vben-core/shared/utils';
 
 import { useCssVar, useDebounceFn } from '@vueuse/core';
 

+ 1 - 1
packages/@core/composables/src/use-namespace.ts

@@ -1,4 +1,4 @@
-import { DEFAULT_NAMESPACE } from '@vben-core/shared';
+import { DEFAULT_NAMESPACE } from '@vben-core/shared/constants';
 
 /**
  * @see copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-namespace/index.ts

+ 49 - 3
packages/@core/composables/src/use-priority-value.ts

@@ -1,10 +1,10 @@
-import type { Ref } from 'vue';
-import { computed, getCurrentInstance, useAttrs, useSlots } from 'vue';
+import type { ComputedRef, Ref } from 'vue';
+import { computed, getCurrentInstance, unref, useAttrs, useSlots } from 'vue';
 
 import {
   getFirstNonNullOrUndefined,
   kebabToCamelCase,
-} from '@vben-core/shared';
+} from '@vben-core/shared/utils';
 
 /**
  * 依次从插槽、attrs、props、state 中获取值
@@ -45,3 +45,49 @@ export function usePriorityValue<
 
   return value;
 }
+
+/**
+ * 批量获取state中的值(每个值都是ref)
+ * @param props
+ * @param state
+ */
+export function usePriorityValues<
+  T extends Record<string, any>,
+  S extends Ref<Record<string, any>> = Readonly<Ref<NoInfer<T>, NoInfer<T>>>,
+>(props: T, state: S | undefined) {
+  const result: { [K in keyof T]: ComputedRef<T[K]> } = {} as never;
+
+  (Object.keys(props) as (keyof T)[]).forEach((key) => {
+    result[key] = usePriorityValue(key as keyof typeof props, props, state);
+  });
+
+  return result;
+}
+
+/**
+ * 批量获取state中的值(集中在一个computed,用于透传)
+ * @param props
+ * @param state
+ */
+export function useForwardPriorityValues<
+  T extends Record<string, any>,
+  S extends Ref<Record<string, any>> = Readonly<Ref<NoInfer<T>, NoInfer<T>>>,
+>(props: T, state: S | undefined) {
+  const computedResult: { [K in keyof T]: ComputedRef<T[K]> } = {} as never;
+
+  (Object.keys(props) as (keyof T)[]).forEach((key) => {
+    computedResult[key] = usePriorityValue(
+      key as keyof typeof props,
+      props,
+      state,
+    );
+  });
+
+  return computed(() => {
+    const unwrapResult: Record<string, any> = {};
+    Object.keys(props).forEach((key) => {
+      unwrapResult[key] = unref(computedResult[key]);
+    });
+    return unwrapResult as { [K in keyof T]: T[K] };
+  });
+}

+ 8 - 0
packages/@core/composables/src/use-simple-locale/messages.ts

@@ -3,11 +3,19 @@ export type Locale = 'en-US' | 'zh-CN';
 export const messages: Record<Locale, Record<string, string>> = {
   'en-US': {
     cancel: 'Cancel',
+    collapse: 'Collapse',
     confirm: 'Confirm',
+    expand: 'Expand',
+    reset: 'Reset',
+    submit: 'Submit',
   },
   'zh-CN': {
     cancel: '取消',
+    collapse: '收起',
     confirm: '确认',
+    expand: '展开',
+    reset: '重置',
+    submit: '提交',
   },
 };
 

+ 2 - 1
packages/@core/preferences/src/preferences.ts

@@ -4,7 +4,8 @@ import type { InitialOptions, Preferences } from './types';
 
 import { markRaw, reactive, readonly, watch } from 'vue';
 
-import { isMacOs, merge, StorageManager } from '@vben-core/shared';
+import { StorageManager } from '@vben-core/shared/cache';
+import { isMacOs, merge } from '@vben-core/shared/utils';
 
 import {
   breakpointsTailwind,

+ 2 - 4
packages/@core/preferences/src/update-css-variables.ts

@@ -1,9 +1,7 @@
 import type { Preferences } from './types';
 
-import {
-  updateCSSVariables as executeUpdateCSSVariables,
-  generatorColorVariables,
-} from '@vben-core/shared';
+import { generatorColorVariables } from '@vben-core/shared/color';
+import { updateCSSVariables as executeUpdateCSSVariables } from '@vben-core/shared/utils';
 
 import { BUILT_IN_THEME_PRESETS } from './constants';
 

+ 1 - 1
packages/@core/preferences/src/use-preferences.ts

@@ -1,6 +1,6 @@
 import { computed } from 'vue';
 
-import { diff } from '@vben-core/shared';
+import { diff } from '@vben-core/shared/utils';
 
 import { preferencesManager } from './preferences';
 import { isDarkTheme } from './update-css-variables';

+ 21 - 0
packages/@core/ui-kit/form-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'],
+    },
+  ],
+});

+ 50 - 0
packages/@core/ui-kit/form-ui/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "@vben-core/form-ui",
+  "version": "5.2.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/form-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/shadcn-ui": "workspace:*",
+    "@vben-core/shared": "workspace:*",
+    "@vee-validate/zod": "^4.13.2",
+    "@vueuse/core": "^11.0.3",
+    "vee-validate": "^4.13.2",
+    "vue": "^3.5.3",
+    "zod": "^3.23.8",
+    "zod-defaults": "^0.1.3"
+  }
+}

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

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

+ 103 - 0
packages/@core/ui-kit/form-ui/src/components/form-actions.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { computed, toRaw, unref } from 'vue';
+
+import { useSimpleLocale } from '@vben-core/composables';
+import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
+import { cn, isFunction } from '@vben-core/shared/utils';
+
+import { COMPONENT_MAP } from '../config';
+import { injectFormProps } from '../use-form-context';
+
+const { $t } = useSimpleLocale();
+
+const [rootProps, form] = injectFormProps();
+
+const collapsed = defineModel({ default: false });
+
+const resetButtonOptions = computed(() => {
+  return {
+    show: true,
+    text: `${$t.value('reset')}`,
+    ...unref(rootProps).resetButtonOptions,
+  };
+});
+
+const submitButtonOptions = computed(() => {
+  return {
+    show: true,
+    text: `${$t.value('submit')}`,
+    ...unref(rootProps).submitButtonOptions,
+  };
+});
+
+const isQueryForm = computed(() => {
+  return !!unref(rootProps).showCollapseButton;
+});
+
+const queryFormStyle = computed(() => {
+  if (isQueryForm.value) {
+    return {
+      'grid-column': `-2 / -1`,
+      marginLeft: 'auto',
+    };
+  }
+
+  return {};
+});
+
+async function handleSubmit(e: Event) {
+  e?.preventDefault();
+  e?.stopPropagation();
+  const { valid } = await form.validate();
+  if (!valid) {
+    return;
+  }
+  await unref(rootProps).handleSubmit?.(toRaw(form.values));
+}
+
+async function handleReset(e: Event) {
+  e?.preventDefault();
+  e?.stopPropagation();
+  const props = unref(rootProps);
+  if (isFunction(props.handleReset)) {
+    await props.handleReset?.(form.values);
+  } else {
+    form.resetForm();
+  }
+}
+</script>
+<template>
+  <div
+    :class="cn('col-span-full w-full text-right', rootProps.actionWrapperClass)"
+    :style="queryFormStyle"
+  >
+    <component
+      :is="COMPONENT_MAP.DefaultResetActionButton"
+      v-if="resetButtonOptions.show"
+      class="mr-3"
+      type="button"
+      @click="handleReset"
+      v-bind="resetButtonOptions"
+    >
+      {{ resetButtonOptions.text }}
+    </component>
+
+    <component
+      :is="COMPONENT_MAP.DefaultSubmitActionButton"
+      v-if="submitButtonOptions.show"
+      type="button"
+      @click="handleSubmit"
+      v-bind="submitButtonOptions"
+    >
+      {{ submitButtonOptions.text }}
+    </component>
+
+    <VbenExpandableArrow
+      v-if="rootProps.showCollapseButton"
+      v-model:model-value="collapsed"
+      class="ml-2"
+    >
+      <span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
+    </VbenExpandableArrow>
+  </div>
+</template>

+ 65 - 0
packages/@core/ui-kit/form-ui/src/config.ts

@@ -0,0 +1,65 @@
+import type { BaseFormComponentType, VbenFormAdapterOptions } from './types';
+
+import type { Component } from 'vue';
+import { h } from 'vue';
+
+import {
+  VbenButton,
+  VbenCheckbox,
+  Input as VbenInput,
+  VbenInputPassword,
+  VbenPinInput,
+  VbenSelect,
+} from '@vben-core/shadcn-ui';
+
+import { defineRule } from 'vee-validate';
+
+const DEFAULT_MODEL_PROP_NAME = 'modelValue';
+
+export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
+  DefaultResetActionButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
+  DefaultSubmitActionButton: h(VbenButton, { size: 'sm', variant: 'default' }),
+  VbenCheckbox,
+  VbenInput,
+  VbenInputPassword,
+  VbenPinInput,
+  VbenSelect,
+};
+
+export const COMPONENT_BIND_EVENT_MAP: Partial<
+  Record<BaseFormComponentType, string>
+> = {
+  VbenCheckbox: 'checked',
+};
+
+export function setupVbenForm<
+  T extends BaseFormComponentType = BaseFormComponentType,
+>(options: VbenFormAdapterOptions<T>) {
+  const { components, config, defineRules } = options;
+
+  if (defineRules) {
+    for (const key of Object.keys(defineRules)) {
+      defineRule(key, defineRules[key as never]);
+    }
+  }
+
+  const baseModelPropName =
+    config?.baseModelPropName ?? DEFAULT_MODEL_PROP_NAME;
+  const modelPropNameMap = config?.modelPropNameMap as
+    | Record<BaseFormComponentType, string>
+    | undefined;
+
+  for (const component of Object.keys(components)) {
+    const key = component as BaseFormComponentType;
+    COMPONENT_MAP[key] = components[component as never];
+
+    if (baseModelPropName !== DEFAULT_MODEL_PROP_NAME) {
+      COMPONENT_BIND_EVENT_MAP[key] = baseModelPropName;
+    }
+
+    // 覆盖特殊组件的modelPropName
+    if (modelPropNameMap && modelPropNameMap[key]) {
+      COMPONENT_BIND_EVENT_MAP[key] = modelPropNameMap[key];
+    }
+  }
+}

+ 175 - 0
packages/@core/ui-kit/form-ui/src/form-api.ts

@@ -0,0 +1,175 @@
+import type {
+  FormState,
+  GenericObject,
+  ResetFormOpts,
+  ValidationOptions,
+} from 'vee-validate';
+
+import type { FormActions, VbenFormProps } from './types';
+
+import { toRaw } from 'vue';
+
+import { Store } from '@vben-core/shared/store';
+import { bindMethods, isFunction, StateHandler } from '@vben-core/shared/utils';
+
+function getDefaultState(): VbenFormProps {
+  return {
+    actionWrapperClass: '',
+    collapsed: false,
+    collapsedRows: 1,
+    commonConfig: {},
+    handleReset: undefined,
+    handleSubmit: undefined,
+    layout: 'horizontal',
+    resetButtonOptions: {},
+    schema: [],
+    showCollapseButton: false,
+    showDefaultActions: true,
+    submitButtonOptions: {},
+    wrapperClass: 'grid-cols-1',
+  };
+}
+
+export class FormApi {
+  // private prevState!: ModalState;
+  private state: null | VbenFormProps = null;
+  // private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
+  public form = {} as FormActions;
+
+  isMounted = false;
+
+  stateHandler: StateHandler;
+
+  public store: Store<VbenFormProps>;
+
+  constructor(options: VbenFormProps = {}) {
+    const { ...storeState } = options;
+
+    const defaultState = getDefaultState();
+
+    this.store = new Store<VbenFormProps>(
+      {
+        ...defaultState,
+        ...storeState,
+      },
+      {
+        onUpdate: () => {
+          this.state = this.store.state;
+        },
+      },
+    );
+
+    this.state = this.store.state;
+    this.stateHandler = new StateHandler();
+    bindMethods(this);
+  }
+
+  private async getForm() {
+    if (!this.isMounted) {
+      // 等待form挂载
+      await this.stateHandler.waitForCondition();
+    }
+    if (!this.form?.meta) {
+      throw new Error('<VbenForm /> is not mounted');
+    }
+    return this.form;
+  }
+
+  // 如果需要多次更新状态,可以使用 batch 方法
+  batchStore(cb: () => void) {
+    this.store.batch(cb);
+  }
+
+  async getValues() {
+    const form = await this.getForm();
+    return form.values;
+  }
+
+  mount(formActions: FormActions) {
+    if (!this.isMounted) {
+      Object.assign(this.form, formActions);
+      this.stateHandler.setConditionTrue();
+      this.isMounted = true;
+    }
+  }
+
+  /**
+   * 根据字段名移除表单项
+   * @param fields
+   */
+  async removeSchemaByFields(fields: string[]) {
+    const fieldSet = new Set(fields);
+    const schema = this.state?.schema ?? [];
+
+    const filterSchema = schema.filter((item) => fieldSet.has(item.fieldName));
+
+    this.setState({
+      schema: filterSchema,
+    });
+  }
+
+  /**
+   * 重置表单
+   */
+  async resetForm(
+    state?: Partial<FormState<GenericObject>> | undefined,
+    opts?: Partial<ResetFormOpts>,
+  ) {
+    const form = await this.getForm();
+    return form.resetForm(state, opts);
+  }
+
+  async resetValidate() {
+    const form = await this.getForm();
+    const fields = Object.keys(form.errors.value);
+    fields.forEach((field) => {
+      form.setFieldError(field, undefined);
+    });
+  }
+
+  async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
+    const form = await this.getForm();
+    form.setFieldValue(field, value, shouldValidate);
+  }
+
+  setState(
+    stateOrFn:
+      | ((prev: VbenFormProps) => Partial<VbenFormProps>)
+      | Partial<VbenFormProps>,
+  ) {
+    if (isFunction(stateOrFn)) {
+      this.store.setState(stateOrFn as (prev: VbenFormProps) => VbenFormProps);
+    } else {
+      this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
+    }
+  }
+
+  async setValues(
+    fields: Record<string, any>,
+    shouldValidate: boolean = false,
+  ) {
+    const form = await this.getForm();
+    form.setValues(fields, shouldValidate);
+  }
+
+  async submitForm(e?: Event) {
+    e?.preventDefault();
+    e?.stopPropagation();
+    const form = await this.getForm();
+    await form.submitForm();
+    const rawValues = toRaw(form.values || {});
+    await this.state?.handleSubmit?.(rawValues);
+    return rawValues;
+  }
+
+  unmounted() {
+    this.state = null;
+    this.isMounted = false;
+    this.stateHandler.reset();
+  }
+
+  async validate(opts?: Partial<ValidationOptions>) {
+    const form = await this.getForm();
+    return await form.validate(opts);
+  }
+}

+ 24 - 0
packages/@core/ui-kit/form-ui/src/form-render/context.ts

@@ -0,0 +1,24 @@
+import type { FormRenderProps } from '../types';
+
+import { computed } from 'vue';
+
+import { createContext } from '@vben-core/shadcn-ui';
+
+export const [injectRenderFormProps, provideFormRenderProps] =
+  createContext<FormRenderProps>('FormRenderProps');
+
+export const useFormContext = () => {
+  const formRenderProps = injectRenderFormProps();
+
+  const isVertical = computed(() => formRenderProps.layout === 'vertical');
+
+  const componentMap = computed(() => formRenderProps.componentMap);
+  const componentBindEventMap = computed(
+    () => formRenderProps.componentBindEventMap,
+  );
+  return {
+    componentBindEventMap,
+    componentMap,
+    isVertical,
+  };
+};

+ 116 - 0
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts

@@ -0,0 +1,116 @@
+import type {
+  FormItemDependencies,
+  FormSchemaRuleType,
+  MaybeComponentProps,
+} from '../types';
+
+import { computed, ref, watch } from 'vue';
+
+import { isFunction } from '@vben-core/shared/utils';
+
+import { useFormValues } from 'vee-validate';
+
+import { injectRenderFormProps } from './context';
+
+export default function useDependencies(
+  getDependencies: () => FormItemDependencies | undefined,
+) {
+  const values = useFormValues();
+
+  const formRenderProps = injectRenderFormProps();
+
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const formApi = formRenderProps.form!;
+
+  if (!values) {
+    throw new Error('useDependencies should be used within <VbenForm>');
+  }
+
+  const isIf = ref(true);
+  const isDisabled = ref(false);
+  const isShow = ref(true);
+  const isRequired = ref(false);
+  const dynamicComponentProps = ref<MaybeComponentProps>({});
+  const dynamicRules = ref<FormSchemaRuleType>();
+
+  const triggerFieldValues = computed(() => {
+    // 该字段可能会被多个字段触发
+    const triggerFields = getDependencies()?.triggerFields ?? [];
+    return triggerFields.map((dep) => {
+      return values.value[dep];
+    });
+  });
+
+  const resetConditionState = () => {
+    isDisabled.value = false;
+    isIf.value = true;
+    isShow.value = true;
+    isRequired.value = false;
+    dynamicRules.value = undefined;
+    dynamicComponentProps.value = {};
+  };
+
+  watch(
+    [triggerFieldValues, getDependencies],
+    async ([_values, dependencies]) => {
+      if (!dependencies || !dependencies?.triggerFields?.length) {
+        return;
+      }
+      resetConditionState();
+      const {
+        componentProps,
+        disabled,
+        if: whenIf,
+        required,
+        rules,
+        show,
+        trigger,
+      } = dependencies;
+
+      // 1. 优先判断if,如果if为false,则不渲染dom,后续判断也不再执行
+      const formValues = values.value;
+
+      if (isFunction(whenIf)) {
+        isIf.value = !!(await whenIf(formValues, formApi));
+        // 不渲染
+        if (!isIf.value) return;
+      }
+
+      // 2. 判断show,如果show为false,则隐藏
+      if (isFunction(show)) {
+        isShow.value = !!(await show(formValues, formApi));
+        if (!isShow.value) return;
+      }
+
+      if (isFunction(componentProps)) {
+        dynamicComponentProps.value = await componentProps(formValues, formApi);
+      }
+
+      if (isFunction(rules)) {
+        dynamicRules.value = await rules(formValues, formApi);
+      }
+
+      if (isFunction(disabled)) {
+        isDisabled.value = !!(await disabled(formValues, formApi));
+      }
+
+      if (isFunction(required)) {
+        isRequired.value = !!(await required(formValues, formApi));
+      }
+
+      if (isFunction(trigger)) {
+        await trigger(formValues, formApi);
+      }
+    },
+    { deep: true, immediate: true },
+  );
+
+  return {
+    dynamicComponentProps,
+    dynamicRules,
+    isDisabled,
+    isIf,
+    isRequired,
+    isShow,
+  };
+}

+ 97 - 0
packages/@core/ui-kit/form-ui/src/form-render/expandable.ts

@@ -0,0 +1,97 @@
+import type { FormRenderProps } from '../types';
+
+import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
+
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
+
+/**
+ * 动态计算行数
+ */
+export function useExpandable(props: FormRenderProps) {
+  const wrapperRef = useTemplateRef<HTMLElement>('wrapperRef');
+  const rowMapping = ref<Record<number, number>>({});
+  // 是否已经计算过一次
+  const isCalculated = ref(false);
+
+  const breakpoints = useBreakpoints(breakpointsTailwind);
+
+  const keepFormItemIndex = computed(() => {
+    const rows = props.collapsedRows ?? 1;
+    const mapping = rowMapping.value;
+    let maxItem = 0;
+    for (let index = 1; index <= rows; index++) {
+      maxItem += mapping?.[index] ?? 0;
+    }
+    return maxItem - 1;
+  });
+
+  watch(
+    [
+      () => props.showCollapseButton,
+      () => breakpoints.active().value,
+      () => props.schema?.length,
+    ],
+    async ([val]) => {
+      if (val) {
+        await nextTick();
+        rowMapping.value = {};
+        await calculateRowMapping();
+      }
+    },
+  );
+
+  async function calculateRowMapping() {
+    if (!props.showCollapseButton) {
+      return;
+    }
+
+    await nextTick();
+    if (!wrapperRef.value) {
+      return;
+    }
+    // 小屏幕不计算
+    if (breakpoints.smaller('sm').value) {
+      // 保持一行
+      rowMapping.value = { 1: 2 };
+      return;
+    }
+
+    const formItems = [...wrapperRef.value.children];
+
+    const container = wrapperRef.value;
+    const containerStyles = window.getComputedStyle(container);
+    const rowHeights = containerStyles
+      .getPropertyValue('grid-template-rows')
+      .split(' ');
+
+    const containerRect = container?.getBoundingClientRect();
+
+    formItems.forEach((el) => {
+      const itemRect = el.getBoundingClientRect();
+
+      // 计算元素在第几行
+      const itemTop = itemRect.top - containerRect.top;
+      let rowStart = 0;
+      let cumulativeHeight = 0;
+
+      for (const [i, rowHeight] of rowHeights.entries()) {
+        cumulativeHeight += Number.parseFloat(rowHeight);
+        if (itemTop < cumulativeHeight) {
+          rowStart = i + 1;
+          break;
+        }
+      }
+      if (rowStart > (props?.collapsedRows ?? 1)) {
+        return;
+      }
+      rowMapping.value[rowStart] = (rowMapping.value[rowStart] ?? 0) + 1;
+      isCalculated.value = true;
+    });
+  }
+
+  onMounted(() => {
+    calculateRowMapping();
+  });
+
+  return { isCalculated, keepFormItemIndex, wrapperRef };
+}

+ 283 - 0
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

@@ -0,0 +1,283 @@
+<script setup lang="ts">
+import type { ZodType } from 'zod';
+
+import type { FormSchema } from '../types';
+
+import { computed } from 'vue';
+
+import {
+  FormControl,
+  FormDescription,
+  FormField,
+  FormItem,
+  FormMessage,
+  VbenRenderContent,
+} from '@vben-core/shadcn-ui';
+import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
+
+import { toTypedSchema } from '@vee-validate/zod';
+import { useFormValues } from 'vee-validate';
+
+import { injectRenderFormProps, useFormContext } from './context';
+import useDependencies from './dependencies';
+import FormLabel from './form-label.vue';
+import { isEventObjectLike } from './helper';
+
+interface Props extends FormSchema {}
+
+const {
+  component,
+  componentProps,
+  dependencies,
+  description,
+  disabled,
+  fieldName,
+  formFieldProps,
+  label,
+  labelClass,
+  labelWidth,
+  renderComponentContent,
+  rules,
+} = defineProps<Props>();
+
+const { componentBindEventMap, componentMap, isVertical } = useFormContext();
+const formRenderProps = injectRenderFormProps();
+const values = useFormValues();
+const formApi = formRenderProps.form;
+
+const fieldComponent = computed(() => {
+  const finalComponent = isString(component)
+    ? componentMap.value[component]
+    : component;
+  if (!finalComponent) {
+    // 组件未注册
+    console.warn(`Component ${component} is not registered`);
+  }
+  return finalComponent;
+});
+
+const {
+  dynamicComponentProps,
+  dynamicRules,
+  isDisabled,
+  isIf,
+  isRequired,
+  isShow,
+} = useDependencies(() => dependencies);
+
+const labelStyle = computed(() => {
+  return labelClass?.includes('w-') || isVertical.value
+    ? {}
+    : {
+        width: `${labelWidth}px`,
+      };
+});
+
+const currentRules = computed(() => {
+  return dynamicRules.value || rules;
+});
+
+const shouldRequired = computed(() => {
+  if (!currentRules.value) {
+    return isRequired.value;
+  }
+
+  if (isRequired.value) {
+    return true;
+  }
+
+  if (isString(currentRules.value)) {
+    return currentRules.value === 'required';
+  }
+
+  let isOptional = currentRules?.value?.isOptional?.();
+
+  // 如果有设置默认值,则不是必填,需要特殊处理
+  const typeName = currentRules?.value?._def?.typeName;
+  if (typeName === 'ZodDefault') {
+    const innerType = currentRules?.value?._def.innerType;
+    if (innerType) {
+      isOptional = innerType.isOptional?.();
+    }
+  }
+
+  return !isOptional;
+});
+
+const fieldRules = computed(() => {
+  let rules = currentRules.value;
+  if (!rules) {
+    return isRequired.value ? 'required' : null;
+  }
+
+  if (isString(rules)) {
+    return rules;
+  }
+
+  const isOptional = !shouldRequired.value;
+  if (!isOptional) {
+    const unwrappedRules = (rules as any)?.unwrap?.();
+    if (unwrappedRules) {
+      rules = unwrappedRules;
+    }
+  }
+  return toTypedSchema(rules as ZodType);
+});
+
+const computedProps = computed(() => {
+  const finalComponentProps = isFunction(componentProps)
+    ? componentProps(values.value, formApi!)
+    : componentProps;
+
+  return {
+    ...finalComponentProps,
+    ...dynamicComponentProps.value,
+  };
+});
+
+const shouldDisabled = computed(() => {
+  return isDisabled.value || disabled || computedProps.value?.disabled;
+});
+
+const customContentRender = computed(() => {
+  if (!isFunction(renderComponentContent)) {
+    return {};
+  }
+  return renderComponentContent(values.value, formApi!);
+});
+
+const renderContentKey = computed(() => {
+  return Object.keys(customContentRender.value);
+});
+
+const fieldProps = computed(() => {
+  const rules = fieldRules.value;
+  return {
+    keepValue: true,
+    label,
+    ...(rules ? { rules } : {}),
+    ...formFieldProps,
+  };
+});
+
+function fieldBindEvent(slotProps: Record<string, any>) {
+  const modelValue = slotProps.componentField.modelValue;
+  const handler = slotProps.componentField['onUpdate:modelValue'];
+
+  const bindEventField = isString(component)
+    ? componentBindEventMap.value?.[component]
+    : null;
+
+  let value = modelValue;
+  // antd design 的一些组件会传递一个 event 对象
+  if (modelValue && isObject(modelValue) && bindEventField) {
+    value = isEventObjectLike(modelValue)
+      ? modelValue?.target?.[bindEventField]
+      : modelValue;
+  }
+  if (bindEventField) {
+    return {
+      [`onUpdate:${bindEventField}`]: handler,
+      [bindEventField]: value,
+      onChange: (e: Record<string, any>) => {
+        const shouldUnwrap = isEventObjectLike(e);
+        const onChange = slotProps?.componentField?.onChange;
+        if (!shouldUnwrap) {
+          return onChange?.(e);
+        }
+
+        return onChange?.(e?.target?.[bindEventField] ?? e);
+      },
+      onInput: () => {},
+    };
+  }
+  return {};
+}
+
+function createComponentProps(slotProps: Record<string, any>) {
+  const bindEvents = fieldBindEvent(slotProps);
+
+  const binds = {
+    ...slotProps.componentField,
+    ...computedProps.value,
+    ...bindEvents,
+  };
+
+  return binds;
+}
+</script>
+
+<template>
+  <FormField
+    v-if="isIf"
+    v-bind="fieldProps"
+    v-slot="slotProps"
+    :name="fieldName"
+  >
+    <FormItem
+      v-show="isShow"
+      :class="{
+        'flex-col': isVertical,
+        'flex-row items-center': !isVertical,
+      }"
+      class="flex pb-6"
+      v-bind="$attrs"
+    >
+      <FormLabel
+        v-if="!hideLabel"
+        :class="
+          cn(
+            'flex leading-6',
+            {
+              'mr-2 flex-shrink-0': !isVertical,
+              'flex-row': isVertical,
+            },
+            !isVertical && labelClass,
+          )
+        "
+        :help="help"
+        :required="shouldRequired && !hideRequiredMark"
+        :style="labelStyle"
+      >
+        {{ label }}
+      </FormLabel>
+      <div :class="cn('relative flex w-full items-center', wrapperClass)">
+        <FormControl :class="cn(controlClass)">
+          <slot
+            v-bind="{
+              ...slotProps,
+              ...createComponentProps(slotProps),
+              disabled: shouldDisabled,
+            }"
+          >
+            <component
+              :is="fieldComponent"
+              v-bind="createComponentProps(slotProps)"
+              :disabled="shouldDisabled"
+            >
+              <template v-for="name in renderContentKey" :key="name" #[name]>
+                <VbenRenderContent
+                  :content="customContentRender[name]"
+                  v-bind="slotProps"
+                />
+              </template>
+              <!-- <slot></slot> -->
+            </component>
+          </slot>
+        </FormControl>
+        <!-- 自定义后缀 -->
+        <div v-if="suffix" class="ml-1">
+          <VbenRenderContent :content="suffix" />
+        </div>
+
+        <FormDescription v-if="description">
+          <VbenRenderContent :content="description" />
+        </FormDescription>
+
+        <Transition name="slide-up">
+          <FormMessage class="absolute -bottom-[22px]" />
+        </Transition>
+      </div>
+    </FormItem>
+  </FormField>
+</template>

+ 20 - 0
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import { FormLabel, VbenHelpTooltip } from '@vben-core/shadcn-ui';
+
+interface Props {
+  help?: string;
+  required?: boolean;
+}
+
+defineProps<Props>();
+</script>
+
+<template>
+  <FormLabel class="flex flex-row-reverse items-center">
+    <VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
+      {{ help }}
+    </VbenHelpTooltip>
+    <slot></slot>
+    <span v-if="required" class="text-destructive mr-[2px]">*</span>
+  </FormLabel>
+</template>

+ 140 - 0
packages/@core/ui-kit/form-ui/src/form-render/form.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import type { GenericObject } from 'vee-validate';
+import type { ZodTypeAny } from 'zod';
+
+import type { FormRenderProps, FormSchema, FormShape } from '../types';
+
+import { computed } from 'vue';
+
+import { Form } from '@vben-core/shadcn-ui';
+import { cn, isString, merge } from '@vben-core/shared/utils';
+
+import { provideFormRenderProps } from './context';
+import { useExpandable } from './expandable';
+import FormField from './form-field.vue';
+import { getBaseRules, getDefaultValueInZodStack } from './helper';
+
+interface Props extends FormRenderProps {}
+
+const props = withDefaults(defineProps<Props>(), {
+  collapsedRows: 1,
+  commonConfig: () => ({}),
+  showCollapseButton: false,
+  wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
+});
+
+const emits = defineEmits<{
+  submit: [event: any];
+}>();
+
+provideFormRenderProps(props);
+
+const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);
+
+const shapes = computed(() => {
+  const resultShapes: FormShape[] = [];
+  props.schema?.forEach((schema) => {
+    const { fieldName } = schema;
+    const rules = schema.rules as ZodTypeAny;
+
+    let typeName = '';
+    if (rules && !isString(rules)) {
+      typeName = rules._def.typeName;
+    }
+
+    const baseRules = getBaseRules(rules) as ZodTypeAny;
+
+    resultShapes.push({
+      default: getDefaultValueInZodStack(rules),
+      fieldName,
+      required: !['ZodNullable', 'ZodOptional'].includes(typeName),
+      rules: baseRules,
+    });
+  });
+  return resultShapes;
+});
+
+const formComponent = computed(() => (props.form ? 'form' : Form));
+
+const formComponentProps = computed(() => {
+  return props.form
+    ? {
+        onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
+      }
+    : {
+        onSubmit: (val: GenericObject) => emits('submit', val),
+      };
+});
+
+const formCollapsed = computed(() => {
+  return props.collapsed && isCalculated.value;
+});
+
+const computedSchema = computed((): FormSchema[] => {
+  const {
+    componentProps = {},
+    controlClass = '',
+    disabled,
+    formFieldProps = {},
+    formItemClass = '',
+    hideLabel = false,
+    hideRequiredMark = false,
+    labelClass = '',
+    labelWidth = 100,
+    wrapperClass = '',
+  } = props.commonConfig;
+  return (props.schema || []).map((schema, index): FormSchema => {
+    const keepIndex = keepFormItemIndex.value;
+
+    const hidden =
+      // 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
+      props.showCollapseButton && !!formCollapsed.value && keepIndex
+        ? keepIndex <= index
+        : false;
+
+    return {
+      disabled,
+      hideLabel,
+      hideRequiredMark,
+      labelWidth,
+      wrapperClass,
+      ...schema,
+      componentProps: merge({}, schema.componentProps, componentProps),
+      controlClass: cn(controlClass, schema.controlClass),
+      formFieldProps: {
+        ...formFieldProps,
+        ...schema.formFieldProps,
+      },
+      formItemClass: cn(
+        'flex-shrink-0',
+        { hidden },
+        formItemClass,
+        schema.formItemClass,
+      ),
+      labelClass: cn(labelClass, schema.labelClass),
+    };
+  });
+});
+</script>
+
+<template>
+  <component :is="formComponent" v-bind="formComponentProps">
+    <div ref="wrapperRef" :class="wrapperClass" class="grid">
+      <template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
+        <!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
+          <slot :definition="cSchema" :name="cSchema.fieldName"> </slot>
+        </div> -->
+        <FormField
+          v-bind="cSchema"
+          :class="cSchema.formItemClass"
+          :rules="cSchema.rules"
+        >
+          <template #default="slotProps">
+            <slot v-bind="slotProps" :name="cSchema.fieldName"> </slot>
+          </template>
+        </FormField>
+      </template>
+      <slot :shapes="shapes"></slot>
+    </div>
+  </component>
+</template>

+ 60 - 0
packages/@core/ui-kit/form-ui/src/form-render/helper.ts

@@ -0,0 +1,60 @@
+import type {
+  AnyZodObject,
+  ZodDefault,
+  ZodEffects,
+  ZodNumber,
+  ZodString,
+  ZodTypeAny,
+} from 'zod';
+
+import { isObject, isString } from '@vben-core/shared/utils';
+
+/**
+ * Get the lowest level Zod type.
+ * This will unpack optionals, refinements, etc.
+ */
+export function getBaseRules<
+  ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
+>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
+  if (!schema || isString(schema)) return null;
+  if ('innerType' in schema._def)
+    return getBaseRules(schema._def.innerType as ChildType);
+
+  if ('schema' in schema._def)
+    return getBaseRules(schema._def.schema as ChildType);
+
+  return schema as ChildType;
+}
+
+/**
+ * Search for a "ZodDefault" in the Zod stack and return its value.
+ */
+export function getDefaultValueInZodStack(schema: ZodTypeAny): any {
+  if (!schema || isString(schema)) {
+    return;
+  }
+  const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
+
+  if (typedSchema._def.typeName === 'ZodDefault')
+    return typedSchema._def.defaultValue();
+
+  if ('innerType' in typedSchema._def) {
+    return getDefaultValueInZodStack(
+      typedSchema._def.innerType as unknown as ZodTypeAny,
+    );
+  }
+  if ('schema' in typedSchema._def) {
+    return getDefaultValueInZodStack(
+      (typedSchema._def as any).schema as ZodTypeAny,
+    );
+  }
+
+  return undefined;
+}
+
+export function isEventObjectLike(obj: any) {
+  if (!obj || !isObject(obj)) {
+    return false;
+  }
+  return Reflect.has(obj, 'target') && Reflect.has(obj, 'stopPropagation');
+}

+ 3 - 0
packages/@core/ui-kit/form-ui/src/form-render/index.ts

@@ -0,0 +1,3 @@
+export { default as Form } from './form.vue';
+export { default as FormField } from './form-field.vue';
+export { default as FormLabel } from './form-label.vue';

+ 11 - 0
packages/@core/ui-kit/form-ui/src/index.ts

@@ -0,0 +1,11 @@
+export { setupVbenForm } from './config';
+export type {
+  BaseFormComponentType,
+  FormSchema as VbenFormSchema,
+  VbenFormProps,
+} from './types';
+
+export * from './use-vben-form';
+
+// export { default as VbenForm } from './vben-form.vue';
+export * as z from 'zod';

+ 327 - 0
packages/@core/ui-kit/form-ui/src/types.ts

@@ -0,0 +1,327 @@
+import type { VbenButtonProps } from '@vben-core/shadcn-ui';
+import type { Field, FormContext, GenericObject } from 'vee-validate';
+import type { ZodTypeAny } from 'zod';
+
+import type { FormApi } from './form-api';
+
+import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
+
+export type FormLayout = 'horizontal' | 'vertical';
+
+export type BaseFormComponentType =
+  | 'DefaultResetActionButton'
+  | 'DefaultSubmitActionButton'
+  | 'VbenCheckbox'
+  | 'VbenInput'
+  | 'VbenInputPassword'
+  | 'VbenPinInput'
+  | 'VbenSelect'
+  | (Record<never, never> & string);
+
+type Breakpoints = '' | '2xl:' | '3xl:' | 'lg:' | 'md:' | 'sm:' | 'xl:';
+
+type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
+
+export type WrapperClassType =
+  | `${Breakpoints}grid-cols-${GridCols}`
+  | (Record<never, never> & string);
+
+export type FormItemClassType =
+  | `${Breakpoints}cols-end-${'auto' | GridCols}`
+  | `${Breakpoints}cols-span-${'auto' | 'full' | GridCols}`
+  | `${Breakpoints}cols-start-${'auto' | GridCols}`
+  | (Record<never, never> & string)
+  | WrapperClassType;
+
+export interface FormShape {
+  /** 默认值 */
+  default?: any;
+  /** 字段名 */
+  fieldName: string;
+  /** 是否必填 */
+  required?: boolean;
+  rules?: ZodTypeAny;
+}
+
+export type MaybeComponentPropKey =
+  | 'options'
+  | 'placeholder'
+  | 'title'
+  | keyof HtmlHTMLAttributes
+  | (Record<never, never> & string);
+
+export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
+
+export type FormActions = FormContext<GenericObject>;
+
+export type CustomRenderType = (() => Component | string) | string;
+
+export type FormSchemaRuleType =
+  | 'required'
+  | null
+  | (Record<never, never> & string)
+  | ZodTypeAny;
+
+type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
+  value: Partial<Record<string, any>>,
+  actions: FormActions,
+) => T;
+
+type FormItemDependenciesConditionWithRules = (
+  value: Partial<Record<string, any>>,
+  actions: FormActions,
+) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
+
+type FormItemDependenciesConditionWithProps = (
+  value: Partial<Record<string, any>>,
+  actions: FormActions,
+) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
+
+export interface FormItemDependencies {
+  /**
+   * 组件参数
+   * @returns 组件参数
+   */
+  componentProps?: FormItemDependenciesConditionWithProps;
+  /**
+   * 是否禁用
+   * @returns 是否禁用
+   */
+  disabled?: FormItemDependenciesCondition;
+  /**
+   * 是否渲染(删除dom)
+   * @returns 是否渲染
+   */
+  if?: FormItemDependenciesCondition;
+  /**
+   * 是否必填
+   * @returns 是否必填
+   */
+  required?: FormItemDependenciesCondition;
+  /**
+   * 字段规则
+   */
+  rules?: FormItemDependenciesConditionWithRules;
+  /**
+   * 是否隐藏(Css)
+   * @returns 是否隐藏
+   */
+  show?: FormItemDependenciesCondition;
+  /**
+   * 任意触发都会执行
+   */
+  trigger?: FormItemDependenciesCondition<void>;
+  /**
+   * 触发字段
+   */
+  triggerFields: string[];
+}
+
+type ComponentProps =
+  | ((
+      value: Partial<Record<string, any>>,
+      actions: FormActions,
+    ) => MaybeComponentProps)
+  | MaybeComponentProps;
+
+export interface FormCommonConfig {
+  /**
+   * 所有表单项的props
+   */
+  componentProps?: ComponentProps;
+  /**
+   * 所有表单项的控件样式
+   */
+  controlClass?: string;
+  /**
+   * 所有表单项的禁用状态
+   * @default false
+   */
+  disabled?: boolean;
+  /**
+   * 所有表单项的控件样式
+   * @default ""
+   */
+  formFieldProps?: Partial<typeof Field>;
+  /**
+   * 所有表单项的栅格布局
+   * @default ""
+   */
+  formItemClass?: string;
+  /**
+   * 隐藏所有表单项label
+   * @default false
+   */
+  hideLabel?: boolean;
+  /**
+   * 是否隐藏必填标记
+   * @default false
+   */
+  hideRequiredMark?: boolean;
+  /**
+   * 所有表单项的label样式
+   * @default "w-[100px]"
+   */
+  labelClass?: string;
+  /**
+   * 所有表单项的label宽度
+   */
+  labelWidth?: number;
+  /**
+   * 所有表单项的wrapper样式
+   */
+  wrapperClass?: string;
+}
+
+type RenderComponentContentType = (
+  value: Partial<Record<string, any>>,
+  api: FormActions,
+) => Record<string, any>;
+
+export type HandleSubmitFn = (
+  values: Record<string, any>,
+) => Promise<void> | void;
+
+export type HandleResetFn = (
+  values: Record<string, any>,
+) => Promise<void> | void;
+
+export interface FormSchema<
+  T extends BaseFormComponentType = BaseFormComponentType,
+> extends FormCommonConfig {
+  /** 组件 */
+  component: Component | T;
+  /** 组件参数 */
+  componentProps?: ComponentProps;
+  /** 默认值 */
+  defaultValue?: any;
+  /** 依赖 */
+  dependencies?: FormItemDependencies;
+  /** 描述 */
+  description?: string;
+  /** 字段名 */
+  fieldName: string;
+  /** 帮助信息 */
+  help?: string;
+  /** 表单项 */
+  label?: string;
+  // 自定义组件内部渲染
+  renderComponentContent?: RenderComponentContentType;
+  /** 字段规则 */
+  rules?: FormSchemaRuleType;
+  /** 后缀 */
+  suffix?: CustomRenderType;
+}
+
+export interface FormFieldProps extends FormSchema {
+  required?: boolean;
+}
+
+export interface FormRenderProps<
+  T extends BaseFormComponentType = BaseFormComponentType,
+> {
+  /**
+   * 是否展开,在showCollapseButton=true下生效
+   */
+  collapsed?: boolean;
+  /**
+   * 折叠时保持行数
+   * @default 1
+   */
+  collapsedRows?: number;
+  /**
+   * 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
+   */
+  commonConfig?: FormCommonConfig;
+  /**
+   * 组件v-model事件绑定
+   */
+  componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>;
+  /**
+   * 组件集合
+   */
+  componentMap: Record<BaseFormComponentType, Component>;
+  /**
+   * 表单实例
+   */
+  form?: FormContext<GenericObject>;
+  /**
+   * 表单项布局
+   */
+  layout?: FormLayout;
+  /**
+   * 表单定义
+   */
+  schema?: FormSchema<T>[];
+  /**
+   * 是否显示展开/折叠
+   */
+  showCollapseButton?: boolean;
+  /**
+   * 表单栅格布局
+   * @default "grid-cols-1"
+   */
+  wrapperClass?: WrapperClassType;
+}
+
+export interface ActionButtonOptions extends VbenButtonProps {
+  show?: boolean;
+  text?: string;
+}
+
+export interface VbenFormProps<
+  T extends BaseFormComponentType = BaseFormComponentType,
+> extends Omit<
+    FormRenderProps<T>,
+    'componentBindEventMap' | 'componentMap' | 'form'
+  > {
+  /**
+   * 表单操作区域class
+   */
+  actionWrapperClass?: any;
+  /**
+   * 表单重置回调
+   */
+  handleReset?: HandleResetFn;
+  /**
+   * 表单提交回调
+   */
+  handleSubmit?: HandleSubmitFn;
+  /**
+   * 重置按钮参数
+   */
+  resetButtonOptions?: ActionButtonOptions;
+
+  /**
+   * 是否显示默认操作按钮
+   */
+  showDefaultActions?: boolean;
+
+  /**
+   * 提交按钮参数
+   */
+  submitButtonOptions?: ActionButtonOptions;
+}
+
+export type ExtendedFormApi = {
+  useStore: <T = NoInfer<VbenFormProps>>(
+    selector?: (state: NoInfer<VbenFormProps>) => T,
+  ) => Readonly<Ref<T>>;
+} & FormApi;
+
+export interface VbenFormAdapterOptions<
+  T extends BaseFormComponentType = BaseFormComponentType,
+> {
+  components: Partial<Record<T, Component>>;
+  config?: {
+    baseModelPropName?: string;
+    modelPropNameMap?: Partial<Record<T, string>>;
+  };
+  defineRules?: {
+    required?: (
+      value: any,
+      params: any,
+      ctx: Record<string, any>,
+    ) => boolean | string;
+  };
+}

+ 59 - 0
packages/@core/ui-kit/form-ui/src/use-form-context.ts

@@ -0,0 +1,59 @@
+import type { FormActions, VbenFormProps } from './types';
+
+import { computed, type ComputedRef, unref, useSlots } from 'vue';
+
+import { createContext } from '@vben-core/shadcn-ui';
+import { isString } from '@vben-core/shared/utils';
+
+import { useForm } from 'vee-validate';
+import { object, type ZodRawShape } from 'zod';
+import { getDefaultsForSchema } from 'zod-defaults';
+
+export const [injectFormProps, provideFormProps] =
+  createContext<[ComputedRef<VbenFormProps> | VbenFormProps, FormActions]>(
+    'VbenFormProps',
+  );
+
+export function useFormInitial(
+  props: ComputedRef<VbenFormProps> | VbenFormProps,
+) {
+  const slots = useSlots();
+  const initialValues = generateInitialValues();
+
+  const form = useForm({
+    ...(Object.keys(initialValues)?.length ? { initialValues } : {}),
+  });
+
+  const delegatedSlots = computed(() => {
+    const resultSlots: string[] = [];
+
+    for (const key of Object.keys(slots)) {
+      if (key !== 'default') {
+        resultSlots.push(key);
+      }
+    }
+    return resultSlots;
+  });
+
+  function generateInitialValues() {
+    const initialValues: Record<string, any> = {};
+
+    const zodObject: ZodRawShape = {};
+    (unref(props).schema || []).forEach((item) => {
+      if (Reflect.has(item, 'defaultValue')) {
+        initialValues[item.fieldName] = item.defaultValue;
+      } else if (item.rules && !isString(item.rules)) {
+        zodObject[item.fieldName] = item.rules;
+      }
+    });
+
+    const schemaInitialValues = getDefaultsForSchema(object(zodObject));
+
+    return { ...initialValues, ...schemaInitialValues };
+  }
+
+  return {
+    delegatedSlots,
+    form,
+  };
+}

+ 49 - 0
packages/@core/ui-kit/form-ui/src/use-vben-form.ts

@@ -0,0 +1,49 @@
+import type {
+  BaseFormComponentType,
+  ExtendedFormApi,
+  VbenFormProps,
+} from './types';
+
+import { defineComponent, h, isReactive, onBeforeUnmount, watch } from 'vue';
+
+import { useStore } from '@vben-core/shared/store';
+
+import { FormApi } from './form-api';
+import VbenUseForm from './vben-use-form.vue';
+
+export function useVbenForm<
+  T extends BaseFormComponentType = BaseFormComponentType,
+>(options: VbenFormProps<T>) {
+  const IS_REACTIVE = isReactive(options);
+  const api = new FormApi(options);
+  const extendedApi: ExtendedFormApi = api as never;
+  extendedApi.useStore = (selector) => {
+    return useStore(api.store, selector);
+  };
+
+  const Form = defineComponent(
+    (props: VbenFormProps, { attrs, slots }) => {
+      onBeforeUnmount(() => {
+        api.unmounted();
+      });
+      return () =>
+        h(VbenUseForm, { ...props, ...attrs, formApi: extendedApi }, slots);
+    },
+    {
+      inheritAttrs: false,
+      name: 'VbenUseForm',
+    },
+  );
+  // Add reactivity support
+  if (IS_REACTIVE) {
+    watch(
+      () => options.schema,
+      () => {
+        api.setState({ schema: options.schema });
+      },
+      { immediate: true },
+    );
+  }
+
+  return [Form, extendedApi] as const;
+}

+ 72 - 0
packages/@core/ui-kit/form-ui/src/vben-form.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import type { VbenFormProps } from './types';
+
+import { ref, watchEffect } from 'vue';
+
+import { useForwardPropsEmits } from '@vben-core/composables';
+
+import FormActions from './components/form-actions.vue';
+import { COMPONENT_BIND_EVENT_MAP, COMPONENT_MAP } from './config';
+import { Form } from './form-render';
+import { provideFormProps, useFormInitial } from './use-form-context';
+
+// 通过 extends 会导致热更新卡死
+interface Props extends VbenFormProps {}
+const props = withDefaults(defineProps<Props>(), {
+  actionWrapperClass: '',
+  collapsed: false,
+  collapsedRows: 1,
+  commonConfig: () => ({}),
+  handleReset: undefined,
+  handleSubmit: undefined,
+  layout: 'horizontal',
+  resetButtonOptions: () => ({}),
+  showCollapseButton: false,
+  showDefaultActions: true,
+  submitButtonOptions: () => ({}),
+  wrapperClass: 'grid-cols-1',
+});
+
+const forward = useForwardPropsEmits(props);
+
+const currentCollapsed = ref(false);
+
+const { delegatedSlots, form } = useFormInitial(props);
+
+provideFormProps([props, form]);
+
+const handleUpdateCollapsed = (value: boolean) => {
+  currentCollapsed.value = !!value;
+};
+
+watchEffect(() => {
+  currentCollapsed.value = props.collapsed;
+});
+</script>
+
+<template>
+  <Form
+    v-bind="forward"
+    :collapsed="currentCollapsed"
+    :component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
+    :component-map="COMPONENT_MAP"
+    :form="form"
+  >
+    <template
+      v-for="slotName in delegatedSlots"
+      :key="slotName"
+      #[slotName]="slotProps"
+    >
+      <slot :name="slotName" v-bind="slotProps"></slot>
+    </template>
+    <template #default="slotProps">
+      <slot v-bind="slotProps">
+        <FormActions
+          v-if="showDefaultActions"
+          :model-value="currentCollapsed"
+          @update:model-value="handleUpdateCollapsed"
+        />
+      </slot>
+    </template>
+  </Form>
+</template>

+ 57 - 0
packages/@core/ui-kit/form-ui/src/vben-use-form.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import type { ExtendedFormApi, VbenFormProps } from './types';
+
+import { useForwardPriorityValues } from '@vben-core/composables';
+
+import FormActions from './components/form-actions.vue';
+import { COMPONENT_BIND_EVENT_MAP, COMPONENT_MAP } from './config';
+import { Form } from './form-render';
+import { provideFormProps, useFormInitial } from './use-form-context';
+
+// 通过 extends 会导致热更新卡死,所以重复写了一遍
+interface Props extends VbenFormProps {
+  formApi: ExtendedFormApi;
+}
+
+const props = defineProps<Props>();
+
+const state = props.formApi?.useStore?.();
+
+const forward = useForwardPriorityValues(props, state);
+
+const { delegatedSlots, form } = useFormInitial(forward);
+
+provideFormProps([forward, form]);
+
+props.formApi?.mount?.(form);
+
+const handleUpdateCollapsed = (value: boolean) => {
+  props.formApi?.setState({ collapsed: !!value });
+};
+</script>
+
+<template>
+  <Form
+    v-bind="forward"
+    :component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
+    :component-map="COMPONENT_MAP"
+    :form="form"
+  >
+    <template
+      v-for="slotName in delegatedSlots"
+      :key="slotName"
+      #[slotName]="slotProps"
+    >
+      <slot :name="slotName" v-bind="slotProps"></slot>
+    </template>
+    <template #default="slotProps">
+      <slot v-bind="slotProps">
+        <FormActions
+          v-if="forward.showDefaultActions"
+          :model-value="state.collapsed"
+          @update:model-value="handleUpdateCollapsed"
+        />
+      </slot>
+    </template>
+  </Form>
+</template>

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

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

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

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

+ 3 - 3
packages/@core/ui-kit/menu-ui/src/components/menu.vue

@@ -22,7 +22,7 @@ import {
 
 import { useNamespace } from '@vben-core/composables';
 import { Ellipsis } from '@vben-core/icons';
-import { isHttpUrl } from '@vben-core/shared';
+import { isHttpUrl } from '@vben-core/shared/utils';
 
 import { useResizeObserver } from '@vueuse/core';
 
@@ -430,7 +430,7 @@ $namespace: vben;
   --menu-item-padding-x: 12px;
   --menu-item-popup-padding-y: 20px;
   --menu-item-popup-padding-x: 12px;
-  --menu-item-margin-y: 3px;
+  --menu-item-margin-y: 2px;
   --menu-item-margin-x: 0px;
   --menu-item-collapse-padding-y: 23.5px;
   --menu-item-collapse-padding-x: 0px;
@@ -475,7 +475,7 @@ $namespace: vben;
   &.is-rounded {
     --menu-item-margin-x: 8px;
     --menu-item-collapse-margin-x: 6px;
-    --menu-item-radius: 10px;
+    --menu-item-radius: 8px;
   }
 
   &.is-horizontal:not(.is-rounded) {

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

@@ -1,6 +1,6 @@
 {
   "name": "@vben-core/popup-ui",
-  "version": "5.1.1",
+  "version": "5.2.1",
   "homepage": "https://github.com/vbenjs/vue-vben-admin",
   "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
   "repository": {

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

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { DrawerApi } from '../drawer-api';
 
 // 模拟 Store 类
-vi.mock('@vben-core/shared', () => {
+vi.mock('@vben-core/shared/store', () => {
   return {
     isFunction: (fn: any) => typeof fn === 'function',
     Store: class {

+ 4 - 2
packages/@core/ui-kit/popup-ui/src/drawer/drawer-api.ts

@@ -1,6 +1,7 @@
 import type { DrawerApiOptions, DrawerState } from './drawer';
 
-import { isFunction, Store } from '@vben-core/shared';
+import { Store } from '@vben-core/shared/store';
+import { bindMethods, isFunction } from '@vben-core/shared/utils';
 
 export class DrawerApi {
   private api: Pick<
@@ -58,13 +59,14 @@ export class DrawerApi {
         },
       },
     );
-
+    this.state = this.store.state;
     this.api = {
       onBeforeClose,
       onCancel,
       onConfirm,
       onOpenChange,
     };
+    bindMethods(this);
   }
 
   // 如果需要多次更新状态,可以使用 batch 方法

+ 22 - 23
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue

@@ -5,10 +5,10 @@ import { ref, watch } from 'vue';
 
 import {
   useIsMobile,
-  usePriorityValue,
+  usePriorityValues,
   useSimpleLocale,
 } from '@vben-core/composables';
-import { Info, X } from '@vben-core/icons';
+import { X } from '@vben-core/icons';
 import {
   Sheet,
   SheetClose,
@@ -18,12 +18,12 @@ import {
   SheetHeader,
   SheetTitle,
   VbenButton,
+  VbenHelpTooltip,
   VbenIconButton,
   VbenLoading,
-  VbenTooltip,
   VisuallyHidden,
 } from '@vben-core/shadcn-ui';
-import { cn } from '@vben-core/shared';
+import { cn } from '@vben-core/shared/utils';
 
 interface Props extends DrawerProps {
   class?: string;
@@ -42,20 +42,22 @@ const { $t } = useSimpleLocale();
 const { isMobile } = useIsMobile();
 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);
-const closeOnClickModal = usePriorityValue('closeOnClickModal', props, state);
-const closeOnPressEscape = usePriorityValue('closeOnPressEscape', props, state);
-const showCancelButton = usePriorityValue('showCancelButton', props, state);
-const showConfirmButton = usePriorityValue('showConfirmButton', props, state);
+const {
+  cancelText,
+  closable,
+  closeOnClickModal,
+  closeOnPressEscape,
+  confirmLoading,
+  confirmText,
+  description,
+  footer: showFooter,
+  loading: showLoading,
+  modal,
+  showCancelButton,
+  showConfirmButton,
+  title,
+  titleTooltip,
+} = usePriorityValues(props, state);
 
 watch(
   () => showLoading.value,
@@ -116,12 +118,9 @@ function pointerDownOutside(e: Event) {
             <slot name="title">
               {{ title }}
 
-              <VbenTooltip v-if="titleTooltip" side="right">
-                <template #trigger>
-                  <Info class="inline-flex size-5 cursor-pointer pb-1" />
-                </template>
+              <VbenHelpTooltip v-if="titleTooltip" trigger-class="pb-1">
                 {{ titleTooltip }}
-              </VbenTooltip>
+              </VbenHelpTooltip>
             </slot>
           </SheetTitle>
           <SheetDescription v-if="description" class="mt-1 text-xs">

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

@@ -6,7 +6,7 @@ import type {
 
 import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue';
 
-import { useStore } from '@vben-core/shared';
+import { useStore } from '@vben-core/shared/store';
 
 import VbenDrawer from './drawer.vue';
 import { DrawerApi } from './drawer-api';

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

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { ModalApi } from '../modal-api'; // 假设 ModalApi 位于同一目录
 import type { ModalState } from '../modal';
 
-vi.mock('@vben-core/shared', () => {
+vi.mock('@vben-core/shared/store', () => {
   return {
     isFunction: (fn: any) => typeof fn === 'function',
     Store: class {

+ 5 - 1
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts

@@ -1,6 +1,7 @@
 import type { ModalApiOptions, ModalState } from './modal';
 
-import { isFunction, Store } from '@vben-core/shared';
+import { Store } from '@vben-core/shared/store';
+import { bindMethods, isFunction } from '@vben-core/shared/utils';
 
 export class ModalApi {
   private api: Pick<
@@ -65,12 +66,15 @@ export class ModalApi {
       },
     );
 
+    this.state = this.store.state;
+
     this.api = {
       onBeforeClose,
       onCancel,
       onConfirm,
       onOpenChange,
     };
+    bindMethods(this);
   }
 
   // 如果需要多次更新状态,可以使用 batch 方法

+ 27 - 28
packages/@core/ui-kit/popup-ui/src/modal/modal.vue

@@ -5,10 +5,10 @@ import { computed, nextTick, ref, watch } from 'vue';
 
 import {
   useIsMobile,
-  usePriorityValue,
+  usePriorityValues,
   useSimpleLocale,
 } from '@vben-core/composables';
-import { Expand, Info, Shrink } from '@vben-core/icons';
+import { Expand, Shrink } from '@vben-core/icons';
 import {
   Dialog,
   DialogContent,
@@ -17,12 +17,12 @@ import {
   DialogHeader,
   DialogTitle,
   VbenButton,
+  VbenHelpTooltip,
   VbenIconButton,
   VbenLoading,
-  VbenTooltip,
   VisuallyHidden,
 } from '@vben-core/shadcn-ui';
-import { cn } from '@vben-core/shared';
+import { cn } from '@vben-core/shared/utils';
 
 import { useModalDraggable } from './use-modal-draggable';
 
@@ -52,25 +52,27 @@ const { $t } = useSimpleLocale();
 const { isMobile } = useIsMobile();
 const state = props.modalApi?.useStore?.();
 
-const header = usePriorityValue('header', props, state);
-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 showCancelButton = usePriorityValue('showCancelButton', props, state);
-const showConfirmButton = usePriorityValue('showConfirmButton', props, state);
+const {
+  cancelText,
+  centered,
+  closable,
+  closeOnClickModal,
+  closeOnPressEscape,
+  confirmLoading,
+  confirmText,
+  description,
+  draggable,
+  footer: showFooter,
+  fullscreen,
+  fullscreenButton,
+  header,
+  loading: showLoading,
+  modal,
+  showCancelButton,
+  showConfirmButton,
+  title,
+  titleTooltip,
+} = usePriorityValues(props, state);
 
 const shouldFullscreen = computed(
   () => (fullscreen.value && header.value) || isMobile.value,
@@ -184,12 +186,9 @@ function pointerDownOutside(e: Event) {
             {{ title }}
 
             <slot v-if="titleTooltip" name="titleTooltip">
-              <VbenTooltip side="right">
-                <template #trigger>
-                  <Info class="inline-flex size-5 cursor-pointer pb-1" />
-                </template>
+              <VbenHelpTooltip trigger-class="pb-1">
                 {{ titleTooltip }}
-              </VbenTooltip>
+              </VbenHelpTooltip>
             </slot>
           </slot>
         </DialogTitle>

+ 19 - 3
packages/@core/ui-kit/popup-ui/src/modal/use-modal.ts

@@ -2,7 +2,7 @@ import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
 
 import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue';
 
-import { useStore } from '@vben-core/shared';
+import { useStore } from '@vben-core/shared/store';
 
 import VbenModal from './modal.vue';
 import { ModalApi } from './modal-api';
@@ -33,7 +33,15 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
           ...attrs,
           ...slots,
         });
-        return () => h(connectedComponent, { ...props, ...attrs }, slots);
+        return () =>
+          h(
+            connectedComponent,
+            {
+              ...props,
+              ...attrs,
+            },
+            slots,
+          );
       },
       {
         inheritAttrs: false,
@@ -65,7 +73,15 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
   const Modal = defineComponent(
     (props: ModalProps, { attrs, slots }) => {
       return () =>
-        h(VbenModal, { ...props, ...attrs, modalApi: extendedApi }, slots);
+        h(
+          VbenModal,
+          {
+            ...props,
+            ...attrs,
+            modalApi: extendedApi,
+          },
+          slots,
+        );
     },
     {
       inheritAttrs: false,

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/components.json

@@ -11,6 +11,6 @@
   "framework": "vite",
   "aliases": {
     "components": "@vben-core/shadcn-ui/components",
-    "utils": "@vben-core/shared"
+    "utils": "@vben-core/shared/utils"
   }
 }

+ 10 - 11
packages/@core/ui-kit/shadcn-ui/package.json

@@ -11,8 +11,8 @@
   "license": "MIT",
   "type": "module",
   "scripts": {
-    "build": "pnpm unbuild",
-    "prepublishOnly": "npm run build"
+    "#build": "pnpm unbuild",
+    "#prepublishOnly": "npm run build"
   },
   "files": [
     "dist"
@@ -20,24 +20,22 @@
   "sideEffects": [
     "**/*.css"
   ],
-  "main": "./dist/index.mjs",
-  "module": "./dist/index.mjs",
+  "#main": "./dist/index.mjs",
+  "main": "./src/index.ts",
+  "#module": "./dist/index.mjs",
+  "module": "./src/index.ts",
   "exports": {
     ".": {
       "types": "./src/index.ts",
       "development": "./src/index.ts",
-      "default": "./dist/index.mjs"
-    },
-    "./*": {
-      "types": "./src/*/index.ts",
-      "development": "./src/*/index.ts",
-      "default": "./dist/*/index.mjs"
+      "//default": "./dist/index.mjs",
+      "default": "./src/index.ts"
     }
   },
   "publishConfig": {
     "exports": {
       ".": {
-        "default": "./dist/index.mjs"
+        "default": "./src/index.ts"
       }
     }
   },
@@ -50,6 +48,7 @@
     "class-variance-authority": "^0.7.0",
     "lucide-vue-next": "^0.439.0",
     "radix-vue": "^1.9.5",
+    "vee-validate": "^4.13.2",
     "vue": "^3.5.3"
   }
 }

+ 24 - 0
packages/@core/ui-kit/shadcn-ui/src/components/button/button.ts

@@ -0,0 +1,24 @@
+import type { AsTag } from 'radix-vue';
+
+import type { ButtonVariants, ButtonVariantSize } from '../ui/button';
+
+import type { Component } from 'vue';
+
+export interface VbenButtonProps {
+  /**
+   * The element or component this component should render as. Can be overwrite by `asChild`
+   * @defaultValue "div"
+   */
+  as?: AsTag | Component;
+  /**
+   * Change the default rendered element for the one passed as a child, merging their props and behavior.
+   *
+   * Read our [Composition](https://www.radix-vue.com/guides/composition.html) guide for more details.
+   */
+  asChild?: boolean;
+  class?: any;
+  disabled?: boolean;
+  loading?: boolean;
+  size?: ButtonVariantSize;
+  variant?: ButtonVariants;
+}

+ 6 - 10
packages/@core/ui-kit/shadcn-ui/src/components/button/button.vue

@@ -1,20 +1,16 @@
 <script setup lang="ts">
+import type { VbenButtonProps } from './button';
+
 import { computed } from 'vue';
 
 import { LoaderCircle } from '@vben-core/icons';
-import { cn } from '@vben-core/shared';
+import { cn } from '@vben-core/shared/utils';
 
-import { Primitive, type PrimitiveProps } from 'radix-vue';
+import { Primitive } from 'radix-vue';
 
-import { type ButtonVariants, buttonVariants } from '../ui/button';
+import { buttonVariants } from '../ui/button';
 
-interface Props extends PrimitiveProps {
-  class?: any;
-  disabled?: boolean;
-  loading?: boolean;
-  size?: ButtonVariants['size'];
-  variant?: ButtonVariants['variant'];
-}
+interface Props extends VbenButtonProps {}
 
 const props = withDefaults(defineProps<Props>(), {
   as: 'button',

+ 4 - 5
packages/@core/ui-kit/shadcn-ui/src/components/button/icon-button.vue

@@ -1,22 +1,21 @@
 <script setup lang="ts">
 import type { ButtonVariants } from '../ui/button';
+import type { VbenButtonProps } from './button';
 
 import { computed, useSlots } from 'vue';
 
-import { cn } from '@vben-core/shared';
-
-import { type PrimitiveProps } from 'radix-vue';
+import { cn } from '@vben-core/shared/utils';
 
 import { VbenTooltip } from '../tooltip';
 import VbenButton from './button.vue';
 
-interface Props extends PrimitiveProps {
+interface Props extends VbenButtonProps {
   class?: any;
   disabled?: boolean;
   onClick?: () => void;
   tooltip?: string;
   tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
-  variant?: ButtonVariants['variant'];
+  variant?: ButtonVariants;
 }
 
 const props = withDefaults(defineProps<Props>(), {

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

@@ -1,2 +1,3 @@
+export type * from './button';
 export { default as VbenButton } from './button.vue';
 export { default as VbenIconButton } from './icon-button.vue';

+ 9 - 7
packages/@core/ui-kit/shadcn-ui/src/components/checkbox/checkbox.vue

@@ -1,24 +1,26 @@
 <script setup lang="ts">
 import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue';
 
+import { useId } from 'vue';
+
 import { useForwardPropsEmits } from 'radix-vue';
 
 import { Checkbox } from '../ui/checkbox';
 
-const props = defineProps<
-  {
-    name: string;
-  } & CheckboxRootProps
->();
+const props = defineProps<CheckboxRootProps>();
 
 const emits = defineEmits<CheckboxRootEmits>();
 
 const checked = defineModel<boolean>('checked');
 
 const forwarded = useForwardPropsEmits(props, emits);
+
+const id = useId();
 </script>
 
 <template>
-  <Checkbox v-bind="forwarded" :id="name" v-model:checked="checked" />
-  <label :for="name" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
+  <div class="flex items-center">
+    <Checkbox v-bind="forwarded" :id="id" v-model:checked="checked" />
+    <label :for="id" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
+  </div>
 </template>

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/src/components/count-to-animator/count-to-animator.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import { computed, onMounted, ref, unref, watch, watchEffect } from 'vue';
 
-import { isNumber } from '@vben-core/shared';
+import { isNumber } from '@vben-core/shared/utils';
 
 import { TransitionPresets, useTransition } from '@vueuse/core';
 

+ 36 - 0
packages/@core/ui-kit/shadcn-ui/src/components/expandable-arrow/expandable-arrow.vue

@@ -0,0 +1,36 @@
+<script lang="ts" setup>
+import { ChevronDown } from '@vben-core/icons';
+import { cn } from '@vben-core/shared/utils';
+
+const props = defineProps<{
+  class?: string;
+}>();
+
+// 控制箭头展开/收起状态
+const collapsed = defineModel({ default: false });
+</script>
+
+<template>
+  <div
+    :class="
+      cn(
+        'text-primary hover:text-primary-hover inline-flex cursor-pointer items-center',
+        props.class,
+      )
+    "
+    @click="collapsed = !collapsed"
+  >
+    <slot :is-expanded="collapsed">
+      {{ collapsed }}
+      <!-- <span>{{ isExpanded ? '收起' : '展开' }}</span> -->
+    </slot>
+    <div
+      :class="{ 'rotate-180': !collapsed }"
+      class="transition-transform duration-300"
+    >
+      <slot name="icon">
+        <ChevronDown class="size-4" />
+      </slot>
+    </div>
+  </div>
+</template>

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

@@ -0,0 +1 @@
+export { default as VbenExpandableArrow } from './expandable-arrow.vue';

+ 2 - 2
packages/@core/ui-kit/shadcn-ui/src/components/full-screen/full-screen.vue

@@ -22,7 +22,7 @@ isFullscreen.value = !!(
 </script>
 <template>
   <VbenIconButton @click="toggle">
-    <Minimize v-if="isFullscreen" class="size-4" />
-    <Maximize v-else class="size-4" />
+    <Minimize v-if="isFullscreen" class="text-foreground size-4" />
+    <Maximize v-else class="text-foreground size-4" />
   </VbenIconButton>
 </template>

+ 6 - 1
packages/@core/ui-kit/shadcn-ui/src/components/icon/icon.vue

@@ -2,7 +2,12 @@
 import { type Component, computed } from 'vue';
 
 import { Icon, IconDefault } from '@vben-core/icons';
-import { isFunction, isHttpUrl, isObject, isString } from '@vben-core/shared';
+import {
+  isFunction,
+  isHttpUrl,
+  isObject,
+  isString,
+} from '@vben-core/shared/utils';
 
 const props = defineProps<{
   // 没有是否显示默认图标

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

@@ -6,10 +6,10 @@ export * from './checkbox';
 export * from './context-menu';
 export * from './count-to-animator';
 export * from './dropdown-menu';
+export * from './expandable-arrow';
 export * from './full-screen';
 export * from './hover-card';
 export * from './icon';
-export * from './input';
 export * from './input-password';
 export * from './link';
 export * from './logo';
@@ -19,9 +19,11 @@ export * from './popover';
 export * from './render-content';
 export * from './scrollbar';
 export * from './segmented';
+export * from './select';
 export * from './spinner';
 export * from './swap';
 export * from './tooltip';
+export * from './ui/accordion';
 export * from './ui/avatar';
 export * from './ui/badge';
 export * from './ui/breadcrumb';
@@ -30,16 +32,21 @@ export * from './ui/card';
 export * from './ui/checkbox';
 export * from './ui/dialog';
 export * from './ui/dropdown-menu';
+export * from './ui/form';
 export * from './ui/hover-card';
 export * from './ui/input';
+export * from './ui/label';
 export * from './ui/number-field';
 export * from './ui/pin-input';
 export * from './ui/popover';
+export * from './ui/radio-group';
 export * from './ui/scroll-area';
 export * from './ui/select';
+export * from './ui/separator';
 export * from './ui/sheet';
 export * from './ui/switch';
 export * from './ui/tabs';
+export * from './ui/textarea';
 export * from './ui/toast';
 export * from './ui/toggle';
 export * from './ui/toggle-group';

+ 25 - 20
packages/@core/ui-kit/shadcn-ui/src/components/input-password/input-password.vue

@@ -2,13 +2,18 @@
 import { ref, useSlots } from 'vue';
 
 import { Eye, EyeOff } from '@vben-core/icons';
+import { cn } from '@vben-core/shared/utils';
 
-import { useForwardProps } from 'radix-vue';
-
-import { type InputProps, VbenInput } from '../input';
+import { Input } from '../ui/input';
 import PasswordStrength from './password-strength.vue';
 
-interface Props extends InputProps {}
+interface Props {
+  class?: any;
+  /**
+   * 是否显示密码强度
+   */
+  passwordStrength?: boolean;
+}
 
 defineOptions({
   inheritAttrs: false,
@@ -19,30 +24,30 @@ const props = defineProps<Props>();
 const modelValue = defineModel<string>();
 
 const slots = useSlots();
-const forward = useForwardProps(props);
 
 const show = ref(false);
 </script>
 
 <template>
-  <div class="relative">
-    <VbenInput
+  <div class="relative w-full">
+    <Input
+      v-bind="$attrs"
       v-model="modelValue"
-      v-bind="{ ...forward, ...$attrs }"
+      :class="cn(props.class)"
       :type="show ? 'text' : 'password'"
-    >
-      <template v-if="passwordStrength">
-        <PasswordStrength :password="modelValue" />
-        <p
-          v-if="slots.strengthText"
-          class="text-muted-foreground mt-1.5 text-xs"
-        >
-          <slot name="strengthText"> </slot>
-        </p>
-      </template>
-    </VbenInput>
+    />
+    <template v-if="passwordStrength">
+      <PasswordStrength :password="modelValue" />
+      <p v-if="slots.strengthText" class="text-muted-foreground mt-1.5 text-xs">
+        <slot name="strengthText"> </slot>
+      </p>
+    </template>
     <div
-      class="hover:text-foreground text-foreground/60 absolute inset-y-0 right-0 top-3 flex cursor-pointer pr-3 text-lg leading-5"
+      :class="{
+        'top-3': !!passwordStrength,
+        'top-1/2 -translate-y-1/2 items-center': !passwordStrength,
+      }"
+      class="hover:text-foreground text-foreground/60 absolute inset-y-0 right-0 flex cursor-pointer pr-3 text-lg leading-5"
       @click="show = !show"
     >
       <Eye v-if="show" class="size-4" />

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

@@ -1,2 +0,0 @@
-export { default as VbenInput } from './input.vue';
-export type * from './types';

+ 0 - 53
packages/@core/ui-kit/shadcn-ui/src/components/input/input.vue

@@ -1,53 +0,0 @@
-<script setup lang="ts">
-import type { InputProps } from './types';
-
-import { computed } from 'vue';
-
-defineOptions({
-  inheritAttrs: false,
-});
-
-const props = defineProps<InputProps>();
-
-const modelValue = defineModel<number | string>();
-
-const inputClass = computed(() => {
-  if (props.status === 'error') {
-    return 'border-destructive';
-  }
-  return '';
-});
-</script>
-
-<template>
-  <div class="relative mb-6">
-    <label
-      v-if="!label"
-      :for="name"
-      class="mb-2 block text-sm font-medium dark:text-white"
-    >
-      {{ label }}
-    </label>
-    <input
-      :id="name"
-      v-model="modelValue"
-      :class="[props.class, inputClass]"
-      autocomplete="off"
-      class="border-input bg-input-background ring-offset-background placeholder:text-muted-foreground/60 focus-visible:ring-ring focus:border-primary flex h-10 w-full rounded-md border p-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
-      required
-      type="text"
-      v-bind="$attrs"
-    />
-
-    <slot></slot>
-
-    <Transition name="slide-up">
-      <p
-        v-if="status === 'error'"
-        class="text-destructive bottom-130 absolute mt-1 text-xs"
-      >
-        {{ errorTip }}
-      </p>
-    </Transition>
-  </div>
-</template>

+ 0 - 25
packages/@core/ui-kit/shadcn-ui/src/components/input/types.ts

@@ -1,25 +0,0 @@
-interface InputProps {
-  class?: any;
-  /**
-   * 错误提示信息
-   */
-  errorTip?: string;
-  /**
-   * 输入框的 label
-   */
-  label?: string;
-  /**
-   * 输入框的 name
-   */
-  name?: string;
-  /**
-   * 是否显示密码强度
-   */
-  passwordStrength?: boolean;
-  /**
-   * 输入框的校验状态
-   */
-  status?: 'default' | 'error';
-}
-
-export type { InputProps };

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/src/components/link/link.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { cn } from '@vben-core/shared';
+import { cn } from '@vben-core/shared/utils';
 
 import { Primitive, type PrimitiveProps } from 'radix-vue';
 

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/src/components/menu-badge/menu-badge.vue

@@ -3,7 +3,7 @@ import type { MenuRecordBadgeRaw } from '@vben-core/typings';
 
 import { computed } from 'vue';
 
-import { isValidColor } from '@vben-core/shared';
+import { isValidColor } from '@vben-core/shared/color';
 
 import BadgeDot from './menu-badge-dot.vue';
 

+ 52 - 33
packages/@core/ui-kit/shadcn-ui/src/components/pin-input/input.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { PinInputProps } from './types';
 
-import { computed, ref, watch } from 'vue';
+import { computed, onBeforeUnmount, ref, useId, watch } from 'vue';
 
 import { VbenButton } from '../button';
 import { PinInput, PinInputGroup, PinInputInput } from '../ui/pin-input';
@@ -14,21 +14,27 @@ const props = withDefaults(defineProps<PinInputProps>(), {
   btnLoading: false,
   codeLength: 6,
   handleSendCode: async () => {},
+  maxTime: 60,
 });
 
 const emit = defineEmits<{
   complete: [];
 }>();
 
+const timer = ref<ReturnType<typeof setTimeout>>();
+
 const modelValue = defineModel<string>();
 
 const inputValue = ref<string[]>([]);
+const countdown = ref(0);
 
-const inputClass = computed(() => {
-  if (props.status === 'error') {
-    return 'border-destructive';
-  }
-  return '';
+const btnText = computed(() => {
+  const countdownValue = countdown.value;
+  return props.createText?.(countdownValue);
+});
+
+const btnLoading = computed(() => {
+  return props.loading || countdown.value > 0;
 });
 
 watch(
@@ -42,45 +48,58 @@ function handleComplete(e: string[]) {
   modelValue.value = e.join('');
   emit('complete');
 }
+
+async function handleSend(e: Event) {
+  e?.preventDefault();
+  await props.handleSendCode();
+  countdown.value = props.maxTime;
+  startCountdown();
+}
+
+function startCountdown() {
+  if (countdown.value > 0) {
+    timer.value = setTimeout(() => {
+      countdown.value--;
+      startCountdown();
+    }, 1000);
+  }
+}
+
+onBeforeUnmount(() => {
+  countdown.value = 0;
+  clearTimeout(timer.value);
+});
+
+const id = useId();
 </script>
 
 <template>
-  <div class="relative mb-6">
-    <label :for="name" class="mb-2 block text-sm font-medium">
-      {{ label }}
-    </label>
-    <PinInput
-      :id="name"
-      v-model="inputValue"
-      :class="inputClass"
-      class="flex justify-between"
-      otp
-      placeholder="○"
-      type="number"
-      @complete="handleComplete"
-    >
-      <PinInputGroup>
+  <PinInput
+    :id="id"
+    v-model="inputValue"
+    class="flex w-full justify-between"
+    otp
+    placeholder="○"
+    type="number"
+    @complete="handleComplete"
+  >
+    <div class="relative flex w-full">
+      <PinInputGroup class="mr-2">
         <PinInputInput
-          v-for="(id, index) in codeLength"
-          :key="id"
+          v-for="(item, index) in codeLength"
+          :key="item"
           :index="index"
         />
       </PinInputGroup>
       <VbenButton
         :loading="btnLoading"
-        class="w-[300px] xl:w-full"
+        class="flex-grow"
         size="lg"
         variant="outline"
-        @click="handleSendCode"
+        @click="handleSend"
       >
         {{ btnText }}
       </VbenButton>
-    </PinInput>
-    <p
-      v-if="status === 'error'"
-      class="text-destructive bottom-130 absolute mt-1 text-xs"
-    >
-      {{ errorTip }}
-    </p>
-  </div>
+    </div>
+  </PinInput>
 </template>

+ 6 - 18
packages/@core/ui-kit/shadcn-ui/src/components/pin-input/types.ts

@@ -1,38 +1,26 @@
 interface PinInputProps {
-  /**
-   * 发送验证码按钮loading
-   */
-  btnLoading?: boolean;
-  /**
-   * 发送验证码按钮文本
-   */
-  btnText?: string;
   class?: any;
   /**
    * 验证码长度
    */
   codeLength?: number;
   /**
-   * 错误提示信息
+   * 发送验证码按钮文本
    */
-  errorTip?: string;
+  createText?: (countdown: number) => string;
   /**
    * 自定义验证码发送逻辑
    * @returns
    */
   handleSendCode?: () => Promise<void>;
   /**
-   * 输入框的 label
-   */
-  label: string;
-  /**
-   * 输入框的 name
+   * 发送验证码按钮loading
    */
-  name: string;
+  loading?: boolean;
   /**
-   * 输入框的校验状态
+   * 最大重试时间
    */
-  status?: 'default' | 'error';
+  maxTime?: number;
 }
 
 export type { PinInputProps };

+ 35 - 22
packages/@core/ui-kit/shadcn-ui/src/components/render-content/render-content.vue

@@ -1,26 +1,39 @@
-<script setup lang="ts">
-import type { Component } from 'vue';
+<script lang="ts">
+import type { Component, PropType } from 'vue';
+import { defineComponent, h } from 'vue';
 
-defineOptions({
-  name: 'RenderContent',
-});
+import { isFunction, isObject } from '@vben-core/shared/utils';
 
-const props = withDefaults(
-  defineProps<{
-    content: Component | string | undefined;
-    props?: Record<string, any>;
-  }>(),
-  {
-    props: () => ({}),
+export default defineComponent({
+  name: 'RenderContent',
+  props: {
+    content: {
+      default: undefined as
+        | PropType<(() => any) | Component | string>
+        | undefined,
+      type: [Object, String, Function],
+    },
   },
-);
-
-const isComponent = typeof props.content === 'object' && props.content !== null;
+  setup(props, { attrs, slots }) {
+    return () => {
+      if (!props.content) {
+        return null;
+      }
+      const isComponent =
+        (isObject(props.content) || isFunction(props.content)) &&
+        props.content !== null;
+      if (!isComponent) {
+        return props.content;
+      }
+      return h(props.content as never, {
+        ...attrs,
+        props: {
+          ...props,
+          ...attrs,
+        },
+        slots,
+      });
+    };
+  },
+});
 </script>
-
-<template>
-  <component :is="content" v-bind="props" v-if="isComponent" />
-  <template v-else-if="!isComponent">
-    {{ content }}
-  </template>
-</template>

+ 1 - 1
packages/@core/ui-kit/shadcn-ui/src/components/scrollbar/scrollbar.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue';
 
-import { cn } from '@vben-core/shared';
+import { cn } from '@vben-core/shared/utils';
 
 import { ScrollArea, ScrollBar } from '../ui/scroll-area';
 

Some files were not shown because too many files changed in this diff