Browse Source

Merge branch 'main' into main

pull/8367/head
失忆 4 weeks ago
committed by GitHub
parent
commit
30a0a5e70c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/bright-spinners-wait.md
  2. 5
      .changeset/calm-apes-select.md
  3. 5
      .changeset/calm-themes-reset.md
  4. 5
      .changeset/eight-deserts-kneel.md
  5. 7
      .changeset/fancy-ears-walk.md
  6. 6
      .changeset/fresh-tiptap-model.md
  7. 5
      .changeset/gentle-bells-ring.md
  8. 7
      .changeset/lucky-pandas-hover.md
  9. 5
      .changeset/quiet-caches-wait.md
  10. 5
      .changeset/steady-grids-measure.md
  11. 16
      .gitattributes
  12. 3
      .github/config.yml
  13. 2
      .github/workflows/issue-close-require.yml
  14. 2
      .github/workflows/stale.yml
  15. 1
      .gitignore
  16. 2
      .vscode/extensions.json
  17. 2
      README.ja-JP.md
  18. 2
      README.md
  19. 2
      README.zh-CN.md
  20. 3
      apps/backend-mock/CHANGELOG.md
  21. 2
      apps/backend-mock/package.json
  22. 47
      apps/backend-mock/utils/mock-data.ts
  23. 23
      apps/web-antd/CHANGELOG.md
  24. 2
      apps/web-antd/package.json
  25. 27
      apps/web-antd/src/adapter/form.ts
  26. 22
      apps/web-antd/src/adapter/vxe-table.ts
  27. 12
      apps/web-antd/src/api/core/auth.ts
  28. 9
      apps/web-antd/src/layouts/basic.vue
  29. 2
      apps/web-antd/src/views/_core/authentication/register.vue
  30. 2
      apps/web-antd/src/views/_core/profile/password-setting.vue
  31. 6
      apps/web-antd/src/views/demos/antd/index.vue
  32. 23
      apps/web-antdv-next/CHANGELOG.md
  33. 2
      apps/web-antdv-next/package.json
  34. 130
      apps/web-antdv-next/src/adapter/component/__tests__/theme-aware-button.test.ts
  35. 17
      apps/web-antdv-next/src/adapter/component/index.ts
  36. 47
      apps/web-antdv-next/src/adapter/component/theme-aware-button.ts
  37. 33
      apps/web-antdv-next/src/adapter/form.test.ts
  38. 29
      apps/web-antdv-next/src/adapter/form.ts
  39. 22
      apps/web-antdv-next/src/adapter/vxe-table.ts
  40. 12
      apps/web-antdv-next/src/api/core/auth.ts
  41. 21
      apps/web-antdv-next/src/app.vue
  42. 9
      apps/web-antdv-next/src/layouts/basic.vue
  43. 2
      apps/web-antdv-next/src/views/_core/authentication/register.vue
  44. 2
      apps/web-antdv-next/src/views/_core/profile/password-setting.vue
  45. 23
      apps/web-ele/CHANGELOG.md
  46. 2
      apps/web-ele/package.json
  47. 4
      apps/web-ele/src/adapter/component/index.ts
  48. 27
      apps/web-ele/src/adapter/form.ts
  49. 22
      apps/web-ele/src/adapter/vxe-table.ts
  50. 12
      apps/web-ele/src/api/core/auth.ts
  51. 9
      apps/web-ele/src/layouts/basic.vue
  52. 2
      apps/web-ele/src/views/_core/authentication/register.vue
  53. 2
      apps/web-ele/src/views/_core/profile/password-setting.vue
  54. 8
      apps/web-ele/vite.config.ts
  55. 25
      apps/web-naive/CHANGELOG.md
  56. 2
      apps/web-naive/package.json
  57. 4
      apps/web-naive/src/adapter/component/index.ts
  58. 27
      apps/web-naive/src/adapter/form.ts
  59. 22
      apps/web-naive/src/adapter/vxe-table.ts
  60. 12
      apps/web-naive/src/api/core/auth.ts
  61. 9
      apps/web-naive/src/layouts/basic.vue
  62. 2
      apps/web-naive/src/views/_core/authentication/register.vue
  63. 2
      apps/web-naive/src/views/_core/profile/password-setting.vue
  64. 16
      apps/web-naive/src/views/demos/form/modal.vue
  65. 6
      apps/web-naive/src/views/demos/naive/array-form/index.vue
  66. 23
      apps/web-tdesign/CHANGELOG.md
  67. 2
      apps/web-tdesign/package.json
  68. 4
      apps/web-tdesign/src/adapter/component/index.ts
  69. 27
      apps/web-tdesign/src/adapter/form.ts
  70. 22
      apps/web-tdesign/src/adapter/vxe-table.ts
  71. 12
      apps/web-tdesign/src/api/core/auth.ts
  72. 9
      apps/web-tdesign/src/layouts/basic.vue
  73. 2
      apps/web-tdesign/src/views/_core/authentication/register.vue
  74. 2
      apps/web-tdesign/src/views/_core/profile/password-setting.vue
  75. 9
      apps/web-tdesign/vite.config.ts
  76. 3
      docs/.vitepress/config/en.mts
  77. 3
      docs/.vitepress/config/zh.mts
  78. 12
      docs/CHANGELOG.md
  79. 2
      docs/package.json
  80. 32
      docs/src/_env/adapter/form.ts
  81. 5
      docs/src/components/common-ui/vben-alert.md
  82. 27
      docs/src/components/common-ui/vben-drawer.md
  83. 308
      docs/src/components/common-ui/vben-form.md
  84. 27
      docs/src/components/common-ui/vben-modal.md
  85. 2
      docs/src/demos/vben-descriptions/size/index.vue
  86. 4
      docs/src/demos/vben-descriptions/span/index.vue
  87. 4
      docs/src/demos/vben-descriptions/vertical/index.vue
  88. 13
      docs/src/demos/vben-drawer/shared-data/drawer.vue
  89. 2
      docs/src/demos/vben-form/custom/index.vue
  90. 34
      docs/src/demos/vben-form/dynamic/index.vue
  91. 2
      docs/src/demos/vben-form/rules/index.vue
  92. 144
      docs/src/demos/vben-form/value-format/index.vue
  93. 13
      docs/src/demos/vben-modal/shared-data/modal.vue
  94. 5
      docs/src/en/components/common-ui/vben-alert.md
  95. 27
      docs/src/en/components/common-ui/vben-drawer.md
  96. 181
      docs/src/en/components/common-ui/vben-form.md
  97. 27
      docs/src/en/components/common-ui/vben-modal.md
  98. 370
      docs/src/en/guide/essentials/cache.md
  99. 7
      docs/src/en/guide/essentials/route.md
  100. 151
      docs/src/en/guide/essentials/stores.md

5
.changeset/bright-spinners-wait.md

@ -0,0 +1,5 @@
---
'@vben/layouts': patch
---
fix route spinner timing during fast and overlapping navigation

5
.changeset/calm-apes-select.md

@ -0,0 +1,5 @@
---
'@vben/common-ui': patch
---
fix: forward ApiComponent updates for custom model value props

5
.changeset/calm-themes-reset.md

@ -0,0 +1,5 @@
---
'@vben-core/preferences': patch
---
fix: apply preference reset theme updates before cache persistence

5
.changeset/eight-deserts-kneel.md

@ -0,0 +1,5 @@
---
'@vben-core/form-ui': patch
---
fix(@vben-core/form-ui): 字段名与 <form> 固有属性冲突时剥离原生 name(#8214)

7
.changeset/fancy-ears-walk.md

@ -1,7 +0,0 @@
---
'@vben/styles': patch
'@vben-core/form-ui': patch
'@vben/web-naive': patch
---
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray

6
.changeset/fresh-tiptap-model.md

@ -0,0 +1,6 @@
---
'@vben/web-antdv-next': patch
'@vben/playground': patch
---
fix: bind VbenTiptap through the standard Vue model protocol

5
.changeset/gentle-bells-ring.md

@ -0,0 +1,5 @@
---
'@vben/layouts': patch
---
fix(@vben/layouts): keep the notification status indicator circular across theme radii

7
.changeset/lucky-pandas-hover.md

@ -0,0 +1,7 @@
---
'@vben-core/layout-ui': patch
---
fix(@vben-core/layout-ui): guard sidebar hover handlers in mobile drawer mode
移动端抽屉模式不存在 hover 语义。resize 跨断点时浏览器会对正在卸载/重排的侧栏派发合成 mouseenter/mouseleave,`handleMouseleave` 缺少 `isMobile` 守卫会把折叠态写入 `collapse` 并经 v-model 链持久化,导致窗口放大后侧栏保持折叠(#8274)。本次为 `handleMouseenter`/`handleMouseleave` 增加 `isMobile` 守卫,并附 4 项回归测试(移动端 mouseenter/mouseleave 不写状态、桌面端行为不变)。

5
.changeset/quiet-caches-wait.md

@ -0,0 +1,5 @@
---
'@vben-core/preferences': patch
---
fix(@vben-core/preferences): avoid unscoped localStorage before initialization

5
.changeset/steady-grids-measure.md

@ -0,0 +1,5 @@
---
'@vben/plugins': patch
---
fix(@vben/plugins): keep vxe toolbar options stable when slot content updates

16
.gitattributes

@ -4,8 +4,18 @@
* text=auto eol=lf
# Declare files that will always have CRLF line endings on checkout.
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
*.cmd text eol=crlf
*.[cC][mM][dD] text eol=crlf
*.bat text eol=crlf
*.[bB][aA][tT] text eol=crlf
# Denote all files that are truly binary and should not be modified.
*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary
*.ico binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.svg binary
*.woff binary
*.woff2 binary

3
.github/config.yml

@ -21,12 +21,10 @@ newPRWelcomeComment: |
firstPRMergeComment: >
Thanks for your contribution! 🎉🎉🎉
# Comment to be posted to on first time issues
newIssueWelcomeComment: >
Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers!
# *OPTIONAL* default titles to check against for lack of descriptiveness
# MUST BE ALL LOWERCASE
requestInfoDefaultTitles:
@ -36,4 +34,3 @@ requestInfoDefaultTitles:
# *Required* Comment to reply with
requestInfoReplyComment: >
Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context.

2
.github/workflows/issue-close-require.yml

@ -19,7 +19,7 @@ jobs:
steps:
# 关闭未活动的 Issues
- name: Close Inactive Issues
uses: actions/stale@v10
uses: actions/stale@v11
with:
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
stale-issue-label: needs-reproduction # Label that flags an issue as stale.

2
.github/workflows/stale.yml

@ -9,7 +9,7 @@ jobs:
if: github.repository == 'vbenjs/vue-vben-admin'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'

1
.gitignore

@ -60,3 +60,4 @@ vite.config.ts.*
skills-lock.json
.atomcode
datalog
.playwright-mcp

2
.vscode/extensions.json

@ -8,8 +8,6 @@
"oxc.oxc-vscode",
// Visual Studio Code 的官方 Stylelint 扩展
"stylelint.vscode-stylelint",
// 使用 oxfmt 的代码格式化程序
"oxc.oxc-vscode",
// 支持 dotenv 文件语法
"mikestead.dotenv",
// YAML 语言支持,供 ESLint 校验 pnpm-workspace.yaml 等文件

2
README.ja-JP.md

@ -128,7 +128,7 @@ Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
## スター歴史
[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date)
[![Star History Chart](https://star-history.dera.page/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
## 寄付

2
README.md

@ -128,7 +128,7 @@ Support modern browsers, not IE
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date)
[![Star History Chart](https://star-history.dera.page/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
## Donate

2
README.zh-CN.md

@ -128,7 +128,7 @@ Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
## Star 历史
[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date)
[![Star History Chart](https://star-history.dera.page/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.dera.page/#vbenjs/vue-vben-admin&Date)
## 捐赠

3
apps/backend-mock/CHANGELOG.md

@ -0,0 +1,3 @@
# @vben/backend-mock
## 5.8.0

2
apps/backend-mock/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/backend-mock",
"version": "5.7.0",
"version": "5.8.0",
"description": "",
"private": true,
"license": "MIT",

47
apps/backend-mock/utils/mock-data.ts

@ -19,6 +19,7 @@ export const MOCK_USERS: UserInfo[] = [
realName: 'Vben',
roles: ['super'],
username: 'vben',
homePath: '/dashboard/workspace',
},
{
id: 1,
@ -194,18 +195,46 @@ export const MOCK_MENUS = [
export const MOCK_MENU_LIST = [
{
id: 1,
name: 'Workspace',
name: 'Dashboard',
status: 1,
type: 'menu',
icon: 'mdi:dashboard',
path: '/workspace',
component: '/dashboard/workspace/index',
type: 'catalog',
icon: 'lucide:layout-dashboard',
path: '/dashboard',
meta: {
icon: 'carbon:workspace',
title: 'page.dashboard.workspace',
affixTab: true,
order: 0,
icon: 'lucide:layout-dashboard',
order: -1,
title: 'page.dashboard.title',
},
children: [
{
id: 101,
pid: 1,
status: 1,
type: 'menu',
name: 'Analytics',
path: 'analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: 'page.dashboard.analytics',
keepAlive: true,
},
},
{
id: 102,
pid: 1,
status: 1,
type: 'menu',
name: 'Workspace',
path: 'workspace',
component: '/views/dashboard/workspace/index',
meta: {
icon: 'carbon:workspace',
title: 'page.dashboard.workspace',
},
},
],
},
{
id: 2,

23
apps/web-antd/CHANGELOG.md

@ -0,0 +1,23 @@
# @vben/web-antd
## 5.8.0
### Patch Changes
- [#8308](https://github.com/vbenjs/vue-vben-admin/pull/8308) [`81f179e`](https://github.com/vbenjs/vue-vben-admin/commit/81f179ed4c39cffe05aa0243843679ffd5c13dcd) Thanks [@kilisamemarisaaa](https://github.com/kilisamemarisaaa)! - fix(apps): pass withCredentials as request config for refresh/logout
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268), [`390efe9`](https://github.com/vbenjs/vue-vben-admin/commit/390efe9f604d7994f82156b89b0a76f61788a5d9), [`728ba5f`](https://github.com/vbenjs/vue-vben-admin/commit/728ba5f5744b5c2de3850a99bff5afe0b9872198)]:
- @vben/styles@5.8.0
- @vben/plugins@5.8.0
- @vben/preferences@5.8.0
- @vben/layouts@5.8.0
- @vben/common-ui@5.8.0
- @vben/access@5.8.0
- @vben/hooks@5.8.0
- @vben/constants@5.8.0
- @vben/request@5.8.0
- @vben/icons@5.8.0
- @vben/locales@5.8.0
- @vben/stores@5.8.0
- @vben/types@5.8.0
- @vben/utils@5.8.0

2
apps/web-antd/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/web-antd",
"version": "5.7.0",
"version": "5.8.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

27
apps/web-antd/src/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@ -22,7 +23,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@ -41,9 +42,27 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

22
apps/web-antd/src/adapter/vxe-table.ts

@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table';

12
apps/web-antd/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

9
apps/web-antd/src/layouts/basic.vue

@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

2
apps/web-antd/src/views/_core/authentication/register.vue

@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

2
apps/web-antd/src/views/_core/profile/password-setting.vue

@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

6
apps/web-antd/src/views/demos/antd/index.vue

@ -12,7 +12,8 @@ function info() {
function error() {
message.error({
content: 'Once upon a time you dressed so fine',
duration: 2500,
// ant-design-vue 的 message.duration 单位是「秒」, 不是毫秒
duration: 2.5,
});
}
@ -25,7 +26,8 @@ function success() {
function notify(type: NotificationType) {
notification[type]({
duration: 2500,
// ant-design-vue 的 notification.duration 单位是「秒」, 不是毫秒
duration: 2.5,
message: '说点啥呢',
type,
});

23
apps/web-antdv-next/CHANGELOG.md

@ -0,0 +1,23 @@
# @vben/web-antdv-next
## 5.8.0
### Patch Changes
- [#8308](https://github.com/vbenjs/vue-vben-admin/pull/8308) [`81f179e`](https://github.com/vbenjs/vue-vben-admin/commit/81f179ed4c39cffe05aa0243843679ffd5c13dcd) Thanks [@kilisamemarisaaa](https://github.com/kilisamemarisaaa)! - fix(apps): pass withCredentials as request config for refresh/logout
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268), [`390efe9`](https://github.com/vbenjs/vue-vben-admin/commit/390efe9f604d7994f82156b89b0a76f61788a5d9), [`728ba5f`](https://github.com/vbenjs/vue-vben-admin/commit/728ba5f5744b5c2de3850a99bff5afe0b9872198)]:
- @vben/styles@5.8.0
- @vben/plugins@5.8.0
- @vben/preferences@5.8.0
- @vben/layouts@5.8.0
- @vben/common-ui@5.8.0
- @vben/access@5.8.0
- @vben/hooks@5.8.0
- @vben/constants@5.8.0
- @vben/request@5.8.0
- @vben/icons@5.8.0
- @vben/locales@5.8.0
- @vben/stores@5.8.0
- @vben/types@5.8.0
- @vben/utils@5.8.0

2
apps/web-antdv-next/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/web-antdv-next",
"version": "5.7.0",
"version": "5.8.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

130
apps/web-antdv-next/src/adapter/component/__tests__/theme-aware-button.test.ts

@ -0,0 +1,130 @@
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import type { App, Component } from 'vue';
import { createApp, defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createThemeAwareButton } from '../theme-aware-button';
const mocks = vi.hoisted(() => ({
compact: false,
contextTheme: undefined as object | undefined,
isDark: false,
providerThemes: [] as any[],
tokens: { colorPrimary: '#e11d48' },
}));
vi.mock('@vben/hooks', () => ({
useAntdDesignTokens: () => ({ tokens: mocks.tokens }),
}));
vi.mock('@vben/preferences', () => ({
preferences: {
app: {
get compact() {
return mocks.compact;
},
},
},
usePreferences: () => ({
isDark: {
get value() {
return mocks.isDark;
},
},
}),
}));
vi.mock('antdv-next/config-provider/context', () => ({
useConfig: () => ({ value: { theme: mocks.contextTheme } }),
}));
vi.mock('antdv-next', async () => {
const { defineComponent, h } =
await vi.importActual<typeof import('vue')>('vue');
return {
ConfigProvider: defineComponent({
props: { theme: Object },
setup(props, { slots }) {
mocks.providerThemes.push(props.theme);
return () =>
h('section', { 'data-theme-provider': '' }, slots.default?.());
},
}),
theme: {
compactAlgorithm: 'compact',
darkAlgorithm: 'dark',
defaultAlgorithm: 'default',
},
};
});
const Button = defineComponent({
inheritAttrs: false,
props: { type: String },
setup(props, { attrs, slots }) {
return () =>
h(
'button',
{ ...attrs, 'data-button-type': props.type },
slots.default?.(),
);
},
});
let activeApp: App | undefined;
function mountButton(component: Component) {
const container = document.createElement('div');
document.body.append(container);
activeApp = createApp(() =>
h(component, { 'data-probe': 'button' }, () => 'Submit'),
);
activeApp.mount(container);
return container;
}
beforeEach(() => {
mocks.compact = false;
mocks.contextTheme = undefined;
mocks.isDark = false;
mocks.providerThemes.length = 0;
});
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
});
describe('createThemeAwareButton', () => {
it('uses the existing ConfigProvider context without adding a wrapper', () => {
mocks.contextTheme = { token: mocks.tokens };
const container = mountButton(createThemeAwareButton(Button, 'primary'));
expect(container.querySelector('[data-theme-provider]')).toBeNull();
expect(container.querySelector('button')?.dataset.buttonType).toBe(
'primary',
);
expect(container.querySelector('button')?.dataset.probe).toBe('button');
});
it('provides the current Vben theme when no ConfigProvider is present', () => {
mocks.compact = true;
mocks.isDark = true;
const container = mountButton(createThemeAwareButton(Button, 'default'));
expect(container.querySelector('[data-theme-provider]')).not.toBeNull();
expect(mocks.providerThemes).toEqual([
{
algorithm: ['dark', 'compact'],
token: mocks.tokens,
},
]);
expect(container.querySelector('button')?.dataset.buttonType).toBe(
'default',
);
});
});

17
apps/web-antdv-next/src/adapter/component/index.ts

@ -73,6 +73,8 @@ import { isEmpty } from '@vben/utils';
import { message, Modal, notification } from 'antdv-next';
import { upload_file } from '#/api';
import { createThemeAwareButton } from './theme-aware-button';
type AdapterUploadProps = UploadProps & {
aspectRatio?: string;
crop?: boolean;
@ -89,6 +91,7 @@ const AutoComplete = defineAsyncComponent(
const Button = defineAsyncComponent(
() => import('antdv-next/dist/button/index'),
);
const Checkbox = defineAsyncComponent(
() => import('antdv-next/dist/checkbox/index'),
);
@ -295,7 +298,7 @@ async function previewImage(
return h(
PreviewGroupComponent,
{
class: 'hidden',
classes: { popup: { root: '!z-2000' } },
preview: {
open: open.value,
current: currentIndex,
@ -716,9 +719,7 @@ async function initComponentAdapter() {
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
DefaultButton: createThemeAwareButton(Button, 'default'),
Divider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
@ -726,15 +727,11 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
PrimaryButton: createThemeAwareButton(Button, 'primary'),
Radio,
RadioGroup,
RangePicker,

47
apps/web-antdv-next/src/adapter/component/theme-aware-button.ts

@ -0,0 +1,47 @@
import type { Component } from 'vue';
import { computed, defineComponent, h } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { ConfigProvider, theme } from 'antdv-next';
import { useConfig } from 'antdv-next/config-provider/context';
function createThemeAwareButton(
Button: Component,
type: 'default' | 'primary',
) {
return defineComponent({
inheritAttrs: false,
setup(props, { attrs, slots }) {
const config = useConfig();
if (config.value?.theme) {
return () => h(Button, { ...attrs, ...props, type }, slots);
}
const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens();
const buttonTheme = computed(() => {
const algorithm = [
isDark.value ? theme.darkAlgorithm : theme.defaultAlgorithm,
];
if (preferences.app.compact) {
algorithm.push(theme.compactAlgorithm);
}
return { algorithm, token: tokens };
});
return () =>
h(
ConfigProvider,
{ theme: buttonTheme.value },
{
default: () => h(Button, { ...attrs, ...props, type }, slots),
},
);
},
});
}
export { createThemeAwareButton };

33
apps/web-antdv-next/src/adapter/form.test.ts

@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest';
import { initSetupVbenForm } from './form';
const mocks = vi.hoisted(() => ({
setupVbenForm: vi.fn(),
}));
vi.mock('@vben/common-ui', () => ({
setupVbenForm: mocks.setupVbenForm,
useVbenForm: vi.fn(),
z: {},
}));
vi.mock('@vben/locales', () => ({
$t: (key: string) => key,
}));
describe('antdv-next form adapter', () => {
it('keeps VbenTiptap on the standard Vue model protocol', async () => {
await initSetupVbenForm();
expect(mocks.setupVbenForm).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
modelPropNameMap: expect.objectContaining({
RichEditor: 'modelValue',
}),
}),
}),
);
});
});

29
apps/web-antdv-next/src/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@ -18,11 +19,13 @@ async function initSetupVbenForm() {
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
// VbenTiptap uses Vue's standard modelValue/update:modelValue pair.
RichEditor: 'modelValue',
Switch: 'checked',
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@ -40,9 +43,27 @@ async function initSetupVbenForm() {
},
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

22
apps/web-antdv-next/src/adapter/vxe-table.ts

@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table';

12
apps/web-antdv-next/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

21
apps/web-antdv-next/src/app.vue

@ -4,7 +4,7 @@ import { computed, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { App, ConfigProvider, theme } from 'antdv-next';
import { App, ConfigProvider, StyleProvider, theme } from 'antdv-next';
import { antdLocale } from '#/locales';
@ -30,18 +30,21 @@ const tokenTheme = computed(() => {
});
watch(
tokenTheme,
(themeConfig) => {
ConfigProvider.config({ theme: themeConfig });
[tokenTheme, antdLocale],
([themeConfig, locale]) => {
ConfigProvider.config({ theme: themeConfig, locale });
},
{ immediate: true },
);
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
<!-- layer: antd 组件样式注入 @layer antd,让 Tailwind 工具类可以覆盖组件样式 -->
<StyleProvider layer>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
</StyleProvider>
</template>

9
apps/web-antdv-next/src/layouts/basic.vue

@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

2
apps/web-antdv-next/src/views/_core/authentication/register.vue

@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

2
apps/web-antdv-next/src/views/_core/profile/password-setting.vue

@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

23
apps/web-ele/CHANGELOG.md

@ -0,0 +1,23 @@
# @vben/web-ele
## 5.8.0
### Patch Changes
- [#8308](https://github.com/vbenjs/vue-vben-admin/pull/8308) [`81f179e`](https://github.com/vbenjs/vue-vben-admin/commit/81f179ed4c39cffe05aa0243843679ffd5c13dcd) Thanks [@kilisamemarisaaa](https://github.com/kilisamemarisaaa)! - fix(apps): pass withCredentials as request config for refresh/logout
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268), [`390efe9`](https://github.com/vbenjs/vue-vben-admin/commit/390efe9f604d7994f82156b89b0a76f61788a5d9), [`728ba5f`](https://github.com/vbenjs/vue-vben-admin/commit/728ba5f5744b5c2de3850a99bff5afe0b9872198)]:
- @vben/styles@5.8.0
- @vben/plugins@5.8.0
- @vben/preferences@5.8.0
- @vben/layouts@5.8.0
- @vben/common-ui@5.8.0
- @vben/access@5.8.0
- @vben/hooks@5.8.0
- @vben/constants@5.8.0
- @vben/request@5.8.0
- @vben/icons@5.8.0
- @vben/locales@5.8.0
- @vben/stores@5.8.0
- @vben/types@5.8.0
- @vben/utils@5.8.0

2
apps/web-ele/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/web-ele",
"version": "5.7.0",
"version": "5.8.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

4
apps/web-ele/src/adapter/component/index.ts

@ -284,9 +284,7 @@ async function initComponentAdapter() {
inputComponent: ElInput,
}),
Input: withDefaultPlaceholder(ElInput, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input', {
style: { width: '100%' },
}),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

27
apps/web-ele/src/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@ -16,7 +17,7 @@ async function initSetupVbenForm() {
CheckboxGroup: 'model-value',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@ -33,9 +34,27 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

22
apps/web-ele/src/adapter/vxe-table.ts

@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
@ -71,8 +72,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table';

12
apps/web-ele/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

9
apps/web-ele/src/layouts/basic.vue

@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

2
apps/web-ele/src/views/_core/authentication/register.vue

@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

2
apps/web-ele/src/views/_core/profile/password-setting.vue

@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

8
apps/web-ele/vite.config.ts

@ -1,4 +1,4 @@
import { defineConfig } from '@vben/vite-config';
import { defineConfig, viteCssLayerPlugin } from '@vben/vite-config';
import ElementPlus from 'unplugin-element-plus/vite';
@ -7,9 +7,9 @@ export default defineConfig(async () => {
application: {},
vite: {
plugins: [
ElementPlus({
format: 'esm',
}),
// element-plus 的 css 包进 @layer el,使 Tailwind 工具类可覆盖组件样式
viteCssLayerPlugin({ layerName: 'el', packageName: 'element-plus' }),
ElementPlus({ format: 'esm' }),
],
server: {
proxy: {

25
apps/web-naive/CHANGELOG.md

@ -0,0 +1,25 @@
# @vben/web-naive
## 5.8.0
### Patch Changes
- [#8308](https://github.com/vbenjs/vue-vben-admin/pull/8308) [`81f179e`](https://github.com/vbenjs/vue-vben-admin/commit/81f179ed4c39cffe05aa0243843679ffd5c13dcd) Thanks [@kilisamemarisaaa](https://github.com/kilisamemarisaaa)! - fix(apps): pass withCredentials as request config for refresh/logout
- [#7978](https://github.com/vbenjs/vue-vben-admin/pull/7978) [`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998) Thanks [@SaleriHQ](https://github.com/SaleriHQ)! - feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268), [`390efe9`](https://github.com/vbenjs/vue-vben-admin/commit/390efe9f604d7994f82156b89b0a76f61788a5d9), [`728ba5f`](https://github.com/vbenjs/vue-vben-admin/commit/728ba5f5744b5c2de3850a99bff5afe0b9872198)]:
- @vben/styles@5.8.0
- @vben/plugins@5.8.0
- @vben/preferences@5.8.0
- @vben/layouts@5.8.0
- @vben/common-ui@5.8.0
- @vben/access@5.8.0
- @vben/hooks@5.8.0
- @vben/constants@5.8.0
- @vben/request@5.8.0
- @vben/icons@5.8.0
- @vben/locales@5.8.0
- @vben/stores@5.8.0
- @vben/types@5.8.0
- @vben/utils@5.8.0

2
apps/web-naive/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/web-naive",
"version": "5.7.0",
"version": "5.8.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

4
apps/web-naive/src/adapter/component/index.ts

@ -225,9 +225,7 @@ async function initComponentAdapter() {
inputComponent: NInput,
}),
Input: withDefaultPlaceholder(NInput, 'input'),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input', {
style: { width: '100%' },
}),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

27
apps/web-naive/src/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@ -20,7 +21,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@ -37,9 +38,27 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

22
apps/web-naive/src/adapter/vxe-table.ts

@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table';

12
apps/web-naive/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

9
apps/web-naive/src/layouts/basic.vue

@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

2
apps/web-naive/src/views/_core/authentication/register.vue

@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

2
apps/web-naive/src/views/_core/profile/password-setting.vue

@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

16
apps/web-naive/src/views/demos/form/modal.vue

@ -7,6 +7,10 @@ defineOptions({
name: 'FormModelDemo',
});
interface FormModalData {
values?: Record<string, unknown>;
}
const [Form, formApi] = useVbenForm({
schema: [
{
@ -44,25 +48,27 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
const [Modal, modalApi] = useVbenModal<FormModalData>({
fullscreenButton: false,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values } = modalApi.getData<Record<string, any>>();
if (values) {
formApi.setValues(values);
const data = modalApi.getData();
if (data?.values) {
formApi.setValues(data.values);
}
}
},
title: '内嵌表单示例',
});
defineExpose({ modalApi });
</script>
<template>
<Modal>

6
apps/web-naive/src/views/demos/naive/array-form/index.vue

@ -24,7 +24,7 @@ const [Form, formApi] = useVbenForm({
component: 'VbenFormFieldArray',
fieldName: 'members',
label: '项目成员',
// 初始化为空数组,供内部 useFieldArray 使用
// 初始化为空数组,供数组编辑器使用
defaultValue: [],
componentProps: {
min: 1,
@ -113,9 +113,7 @@ async function getFormValues() {
<template #header-extra>
<NButton class="mr-2" @click="setFormValues">设置表单值</NButton>
<NButton class="mr-2" @click="getFormValues">获取表单值</NButton>
<NButton type="primary" @click="formApi.submitForm()">
提交校验
</NButton>
<NButton type="primary" @click="formApi.submit()"> 提交校验 </NButton>
</template>
<Form />
</NCard>

23
apps/web-tdesign/CHANGELOG.md

@ -0,0 +1,23 @@
# @vben/web-tdesign
## 5.8.0
### Patch Changes
- [#8308](https://github.com/vbenjs/vue-vben-admin/pull/8308) [`81f179e`](https://github.com/vbenjs/vue-vben-admin/commit/81f179ed4c39cffe05aa0243843679ffd5c13dcd) Thanks [@kilisamemarisaaa](https://github.com/kilisamemarisaaa)! - fix(apps): pass withCredentials as request config for refresh/logout
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268), [`390efe9`](https://github.com/vbenjs/vue-vben-admin/commit/390efe9f604d7994f82156b89b0a76f61788a5d9), [`728ba5f`](https://github.com/vbenjs/vue-vben-admin/commit/728ba5f5744b5c2de3850a99bff5afe0b9872198)]:
- @vben/styles@5.8.0
- @vben/plugins@5.8.0
- @vben/preferences@5.8.0
- @vben/layouts@5.8.0
- @vben/common-ui@5.8.0
- @vben/access@5.8.0
- @vben/hooks@5.8.0
- @vben/constants@5.8.0
- @vben/request@5.8.0
- @vben/icons@5.8.0
- @vben/locales@5.8.0
- @vben/stores@5.8.0
- @vben/types@5.8.0
- @vben/utils@5.8.0

2
apps/web-tdesign/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/web-tdesign",
"version": "5.7.0",
"version": "5.8.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

4
apps/web-tdesign/src/adapter/component/index.ts

@ -239,9 +239,7 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
// InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
// Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

27
apps/web-tdesign/src/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@ -22,7 +23,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@ -41,9 +42,27 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) {
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;

22
apps/web-tdesign/src/adapter/vxe-table.ts

@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component';
@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table';

12
apps/web-tdesign/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

9
apps/web-tdesign/src/layouts/basic.vue

@ -217,7 +217,12 @@ watch(
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<BasicLayout
:avatar
:text="userStore.userInfo?.realName"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
>
<template #user-dropdown>
<UserDropdown
:avatar
@ -225,8 +230,8 @@ watch(
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
@logout="handleLogout"
/>
</template>
<template #notification>

2
apps/web-tdesign/src/views/_core/authentication/register.vue

@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

2
apps/web-tdesign/src/views/_core/profile/password-setting.vue

@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

9
apps/web-tdesign/vite.config.ts

@ -1,9 +1,16 @@
import { defineConfig } from '@vben/vite-config';
import { defineConfig, viteCssLayerPlugin } from '@vben/vite-config';
export default defineConfig(async () => {
return {
application: {},
vite: {
plugins: [
// tdesign 的 css 包进 @layer td,使 Tailwind 工具类可覆盖组件样式
viteCssLayerPlugin({
layerName: 'td',
packageName: 'tdesign-vue-next',
}),
],
server: {
proxy: {
'/api': {

3
docs/.vitepress/config/en.mts

@ -83,6 +83,9 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ link: 'essentials/settings', text: 'Configuration' },
{ link: 'essentials/icons', text: 'Icons' },
{ link: 'essentials/styles', text: 'Styles' },
{ link: 'essentials/utils', text: 'Utils' },
{ link: 'essentials/stores', text: 'Stores' },
{ link: 'essentials/cache', text: 'Cache' },
{ link: 'essentials/external-module', text: 'External Modules' },
{ link: 'essentials/build', text: 'Build and Deployment' },
{ link: 'essentials/server', text: 'Server Interaction and Data Mock' },

3
docs/.vitepress/config/zh.mts

@ -80,6 +80,9 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ link: 'essentials/settings', text: '配置' },
{ link: 'essentials/icons', text: '图标' },
{ link: 'essentials/styles', text: '样式' },
{ link: 'essentials/utils', text: '工具' },
{ link: 'essentials/stores', text: '状态管理' },
{ link: 'essentials/cache', text: '缓存' },
{ link: 'essentials/external-module', text: '外部模块' },
{ link: 'essentials/build', text: '构建与部署' },
{ link: 'essentials/server', text: '服务端交互与数据Mock' },

12
docs/CHANGELOG.md

@ -0,0 +1,12 @@
# @vben/docs
## 5.8.0
### Patch Changes
- Updated dependencies [[`9ffd42f`](https://github.com/vbenjs/vue-vben-admin/commit/9ffd42f013825f94278165027bc210a5314d3998), [`142b544`](https://github.com/vbenjs/vue-vben-admin/commit/142b5442c2270090720a92671a0573cfe6974fa3), [`1d95f29`](https://github.com/vbenjs/vue-vben-admin/commit/1d95f298bdce48a1f3d4cec1ed1f182bfe265268)]:
- @vben/styles@5.8.0
- @vben-core/shadcn-ui@5.8.0
- @vben/plugins@5.8.0
- @vben/common-ui@5.8.0
- @vben/locales@5.8.0

2
docs/package.json

@ -1,6 +1,6 @@
{
"name": "@vben/docs",
"version": "5.7.0",
"version": "5.8.0",
"private": true,
"type": "module",
"scripts": {

32
docs/src/_env/adapter/form.ts

@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
FormValues,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@ -24,7 +25,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@ -40,9 +41,30 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
Record<never, never>,
TFormValues,
TSubmitValues
>,
) {
return useForm<
TFormValues,
ComponentType,
Record<never, never>,
TSubmitValues
>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = FormProps<ComponentType, Record<never, never>, TFormValues, TSubmitValues>;

5
docs/src/components/common-ui/vben-alert.md

@ -80,10 +80,7 @@ export type PromptProps<T = any> = {
component?: Component;
componentProps?: Recordable<any>;
componentSlots?:
| (() => any)
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
(() => any) | Recordable<unknown> | VNode | VNodeArrayChildren;
defaultValue?: T;
modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>;

27
docs/src/components/common-ui/vben-drawer.md

@ -50,6 +50,29 @@ Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 dra
<DemoPreview dir="demos/vben-drawer/shared-data" />
### 数据类型约束
推荐在 connected 子组件中声明一次数据类型并暴露 `drawerApi`,外部会从 `connectedComponent` 自动推导 `setData` 和 `getData` 的类型:
```ts
// connected 子组件
const [Drawer, drawerApi] = useVbenDrawer<EditData>();
defineExpose({ drawerApi });
// 外部组件,无需重复声明 EditData
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: EditDrawer,
});
```
无法从组件公开实例推导时,可以显式使用 `useVbenDrawer<EditData>()`。需要让多个文件共享同一契约时,可以在独立模块中预绑定:
```ts
export const useEditDrawer = createVbenDrawer<EditData>();
```
三种方式的优先级为:显式泛型、connected component 自动推导、`unknown`。普通 SFC 通过 `defineExpose` 支持自动推导;泛型 SFC、函数式组件或被标注为宽 `Component` 的组件应使用显式泛型或契约工厂。`getData()` 在尚未调用 `setData()` 时返回 `undefined`,业务允许 `null`、部分对象等值时,需要在数据泛型中准确声明。
::: info 注意
- `VbenDrawer` 组件对于参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
@ -143,8 +166,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
| setState | 动态设置抽屉状态属性 | `(((prev: DrawerState) => Partial<DrawerState>)\| Partial<DrawerState>)=>drawerApi` |
| open | 打开弹窗 | `()=>void` | --- |
| close | 关闭弹窗 | `()=>void` | --- |
| setData | 设置共享数据 | `<T>(data:T)=>drawerApi` | --- |
| getData | 获取共享数据 | `<T>()=>T` | --- |
| setData | 设置共享数据 | `(data:TData)=>drawerApi` | --- |
| getData | 获取共享数据 | `()=>TData\|undefined` | --- |
| useStore | 获取可响应式状态 | - | --- |
| lock | 将抽屉标记为提交中,锁定当前状态 | `(isLock:boolean)=>drawerApi` | >5.5.3 |
| unlock | lock方法的反操作,解除抽屉的锁定状态,也是lock(false)的别名 | `()=>drawerApi` | >5.5.3 |

308
docs/src/components/common-ui/vben-form.md

@ -4,6 +4,24 @@ outline: deep
# Vben Form 表单
::: warning 字段插槽破坏性变更
字段命名 slot 的控件绑定已统一收拢到 `slotProps.componentProps`。旧写法会把 `field`、`formApi`、`values` 等表单元数据一并传给实际控件,可能产生无效属性和 Vue 运行时警告。
```vue
<!-- 旧写法 -->
<Input v-bind="slotProps" />
<!-- 新写法 -->
<Input v-bind="slotProps.componentProps" />
```
请将所有字段 slot 的 `v-bind="slotProps"` 迁移为 `v-bind="slotProps.componentProps"`。根级的 `field`、`componentField`、`modelValue`、`name`、`disabled`、`isInValid`、`values` 和 `formApi` 仍可用于模板逻辑,但不会再自动传入实际控件。
当前版本启动 Vben 应用或 Playground 开发服务器时会在终端输出一次迁移警告,页面加载时浏览器控制台也会提示。该提示不会进入生产构建,并计划在下个版本移除。
:::
框架提供的表单组件,可适配 `Element Plus`、`Ant Design Vue`、`Naive UI` 等框架。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
@ -16,18 +34,23 @@ outline: deep
## 适配器
表单底层使用 [vee-validate](https://vee-validate.logaretm.com/v4/) 进行表单验证,所以你可以使用 `vee-validate` 的所有功能。对于不同的 UI 框架,我们提供了适配器,以便更好的适配不同的 UI 框架。
表单内部使用 [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) 管理状态与校验生命周期,并使用 [Zod 4](https://zod.dev/v4) 描述 schema。业务侧仍通过 `useVbenForm`、`FormApi` 和组件适配器使用表单,不应直接依赖底层 TanStack 实例。
从 Zod 3 或旧表单引擎升级时,请先阅读 [Zod 4 与 TanStack Form 迁移指南](/guide/in-depth/zod-v4-form-migration)。
### 适配器说明
每个应用都有不同的 UI 框架,所以在应用的 `src/adapter/form` 和 `src/adapter/component` 内部,你可以根据自己的需求,进行组件适配。下面是 `Ant Design Vue` 的适配器示例代码,可根据注释查看说明:
必须先初始化组件适配器,再调用 `setupVbenForm`。每次调用都会以当前全局组件注册表重建组件及模型属性映射;重复初始化时,已从注册表移除的组件会同步清理,内置组件及其默认绑定保持不变。
::: details ant design vue 表单适配器
```ts
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@ -42,6 +65,8 @@ setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 仅当组件不发送 update:*、只发送 change 时启用
changeEventFallback: false,
// 一些组件库空值为 null,重置表单时需要和实际组件行为保持一致
emptyStateValue: null,
// 一些组件是 v-model:checked 或者 v-model:fileList
@ -52,7 +77,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@ -70,11 +95,20 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;
```
:::
@ -230,16 +264,47 @@ export { initComponentAdapter };
<DemoPreview dir="demos/vben-form/query" />
## 值格式化
## 表单值编解码
当组件的展示值与后端真正需要的 payload 不一致时,可以在 schema 上使用 `valueFormat`。它会在 `getValues()`、提交、以及依赖这些输出的方法中生效。
当组件值与后端 payload 不一致时,使用表单级 `codec` 统一定义双向转换。`encode` 接收完整 `TFormValues` 并返回完整 `TSubmitValues`;`decode` 执行反向转换。多字段拆分、合并和删除都在一个纯函数边界完成,不依赖 schema 顺序或字符串路径写入。
- `return xxx`:回写当前字段
- `setValue('startTime', xxx)`:写入其他字段
- `return undefined`:保持当前字段已被移除,适合把一个字段拆成多个字段
`codec` 直接写在 `useVbenForm` 选项中即可。只需标注 `encode` 的表单值入参,`TSubmitValues` 会从返回对象自动推导,并传递给 `decode`、`getValues()` 和提交回调:
```ts
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});
```
<DemoPreview dir="demos/vben-form/value-format" />
## 性能基准
表单性能基准覆盖组件初始化、单字段与批量更新、重置、Zod 校验、动态 schema、字段联动、codec 编码与快照,以及数组字段编辑、增删和子 schema 更新。完整运行:
```bash
pnpm test:benchmark
```
只检查表单相关基准时,可以直接指定文件:
```bash
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts
```
基准结果用于比较同一环境、同一场景在修改前后的相对变化,不应把单次运行的绝对耗时作为跨机器阈值。运行前应停止开发服务器等高 CPU 任务,并保持 Node.js 版本一致。benchmark 文件不会进入普通 `test:unit` 流程。
## 表单校验
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
@ -252,6 +317,8 @@ export { initComponentAdapter };
_注意_ 需要指定 `dependencies` 的 `triggerFields` 属性,设置由谁的改动来触发,以便表单组件能够正确的联动。
新代码推荐使用 `dependencies.resolve(context)` 一次返回完整动态状态。它只在 `triggerFields` 变化时执行,并原子更新 `if`、`show`、`disabled`、`required`、`rules`、`componentProps`、`help` 和 `renderComponentContent`,避免多个异步回调产生中间状态。原有多回调结构继续兼容。
<DemoPreview dir="demos/vben-form/dynamic" />
## 自定义组件
@ -287,29 +354,110 @@ const [Form, formApi] = useVbenForm({
</template>
```
### 类型传递与插槽
使用 `useVbenForm<TFormValues, TSubmitValues>` 分别声明组件表单值和提交值。schema、slots、`setValues`、`getRawValues()` 使用 `TFormValues`;`getValues()` 和 `submit()` 返回 `Promise<TSubmitValues>`,其中 `submit()` 只接收可选的原生 `Event`;`handleSubmit` 第一参数使用 `TSubmitValues`。两种结构相同时只传一个泛型即可。
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values, rawValues) {
// values: AccountFormValues
// rawValues: Readonly<AccountFormValues>
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
const values = await formApi.getValues(); // AccountFormValues
return values;
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value、componentField.modelValue 均为 string -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<!-- values: AccountFormValues -->
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>
```
字段命名插槽提供完整控件绑定 `componentProps`,以及 `field`、`componentField`、`modelValue`、`name`、`disabled`、`isInValid`、`values` 和 `formApi`。默认插槽提供 `shapes`、`values` 和 `formApi`;`reset-before`、`submit-before`、`expand-before`、`expand-after` 提供 `values` 和 `formApi`。
建议为表单声明没有字符串索引签名的精确接口,使每个字段插槽都能推导自己的值类型。使用 `Record<string, unknown>` 等宽泛类型时,slot props 仍保持完整结构,不再整体退化为 `any`,但字段值只能推导为索引值类型。
### FormApi
useVbenForm 返回的第二个参数,是一个对象,包含了一些表单的方法。
| 方法名 | 描述 | 类型 | 版本号 |
| --- | --- | --- | --- |
| submitForm | 提交表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| resetForm | 重置表单 | `()=>Promise<void>` | - |
| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record<string, any>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取表单值 | `(fields:Record<string, any>,shouldValidate: boolean = false)=>Promise<void>` | - |
| validate | 表单校验 | `()=>Promise<void>` | - |
| validateField | 校验指定字段 | `(fieldName: string)=>Promise<ValidationResult<unknown>>` | - |
| submit | 提交表单 | `(e?: Event) => Promise<TSubmitValues>` | - |
| validateAndSubmit | 校验通过后提交表单 | `() => Promise<TSubmitValues \| undefined>` | - |
| reset | 重置表单 | `(state?: FormResetState<TFormValues>, options?: FormResetOptions) => Promise<void>` | - |
| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName<TFormValues> \| FormFieldName<TFormValues>[]) => Promise<void>` | - |
| setValues | 深层补丁更新表单值,默认会过滤不在 schema 中定义的字段 | `(fields: FormValuePatch<TFormValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| setSubmitValues | 通过 codec.decode 回填完整提交值 | `(values: TSubmitValues, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取经过 codec.encode 或旧格式化管道的提交值 | `() => Promise<TSubmitValues>` | - |
| getRawValues | 获取未格式化的独立表单值快照 | `() => Promise<TFormValues>` | - |
| getValueSnapshot | 一次获取表单值和提交值 | `() => Promise<FormValueSnapshot<TFormValues, TSubmitValues>>` | - |
| formatValues | 编码指定的表单值快照 | `(rawValues: Readonly<TFormValues>) => TSubmitValues` | - |
| validate | 表单校验 | `() => Promise<FormValidationResult>` | - |
| validateField | 校验指定字段 | `(fieldName: string) => Promise<FormValidationResult>` | - |
| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - |
| resetValidate | 重置表单校验 | `()=>Promise<void>` | - |
| updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` | - |
| setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise<void>` | - |
| setState | 设置组件状态(props) | `(stateOrFn:\| ((prev: VbenFormProps) => Partial<VbenFormProps>)\| Partial<VbenFormProps>)=>Promise<void>` | - |
| getState | 获取组件状态(props) | `()=>Promise<VbenFormProps>` | - |
| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - | - |
| form | 稳定的 `FormContextApi`,提供 values、errors、set/reset/validate/submit 与数组字段操作,不暴露底层 TanStack 泛型 | `FormContextApi` | - |
| getFieldComponentRef | 获取指定字段的组件实例 | `<T=unknown>(fieldName: string)=>T` | >5.5.3 |
| getFocusedField | 获取当前已获得焦点的字段 | `()=>string\|undefined` | >5.5.3 |
`setValues` 在默认的 `filterFields=true` 模式下会将普通对象作为深层补丁合并,因此更新 `profile.email` 时会保留 `profile` 下其他已声明字段和默认值。数组、日期、Day.js、`null`、`undefined` 等叶值仍会整体覆盖。需要替换整个对象分支时,请使用 `setFieldValue('profile', nextProfile)`;需要绕过 schema 字段过滤时,可以将 `filterFields` 设为 `false`。
旧命名 `submitForm`、`validateAndSubmitForm`、`resetForm`、`resetValidate` 分别对应 `submit`、`validateAndSubmit`、`reset`、`clearValidation`。它们仍可调用,但已标记 `@deprecated`,开发环境每个旧名称只警告一次,生产环境静默。
### FormContextApi 响应式读取
`formApi.form` 提供细粒度 selector。字段组件应优先使用字段级方法,避免订阅整份 values 或 errors:
| 方法 | 返回值 | 用途 |
| --- | --- | --- |
| `useFieldValue(fieldName)` | `Readonly<Ref<FormFieldValue>>` | 订阅一个字段值。 |
| `useFieldValues(fieldNames)` | `Readonly<Ref<FormFieldValue[]>>` | 订阅一组声明字段值。 |
| `useFieldError(fieldName)` | `Readonly<Ref<string \| undefined>>` | 订阅一个字段错误。 |
| `useValues()` | `Readonly<Ref<TValues>>` | 订阅整份表单值。 |
| `useSelector(selector)` | `Readonly<Ref<TResult>>` | 兼容入口,可从 `{ values, errors, meta }` 组合选择状态。 |
```ts
const email = formApi.form.useFieldValue('email');
const emailError = formApi.form.useFieldError('email');
const submitting = formApi.form.useSelector((state) => state.meta.submitting);
```
## Props
所有属性都可以传入 `useVbenForm` 的第一个参数中。
@ -323,8 +471,9 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| actionLayout | 表单操作按钮位置 | `'newLine' \| 'rowEnd' \| 'inline'` | `rowEnd` |
| actionPosition | 表单操作按钮对齐方式 | `'left' \| 'center' \| 'right'` | `right` |
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - |
| codec | 表单值与提交值的双向编解码器 | `FormCodec<TFormValues, TSubmitValues>` | - |
| handleSubmit | 表单提交回调 | `(values: TSubmitValues, rawValues: Readonly<TFormValues>) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(rawValues: Readonly<TFormValues>, fieldsChanged: string[], getFormattedValues: () => TSubmitValues) => void` | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
@ -341,41 +490,23 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
::: tip handleValuesChange
::: warning formApi.form 的挂载时机
`handleValuesChange` 回调函数的第一个参数`values`装载了表单改变后的当前值对象,第二个参数`fieldsChanged`是一个数组,包含了所有被改变的字段名。注意:第二个参数仅在v5.5.4(不含)以上版本可用,并且传递的是已在schema中定义的字段名。如果你使用了字段映射并且需要检查是哪些字段发生了变化的话,请注意该参数并不会包含映射后的字段名。
`formApi.form` 是 `<Form />` 挂载后注入的 `FormContextApi`。不要在调用 `useVbenForm` 时从第二个返回值中解构或缓存 `form`,否则会保留挂载前的空引用。业务操作优先使用 `formApi` 上会等待挂载的公开方法,例如 `getRawValues()`、`setFieldError()`、`setFieldValue()` 和 `validate()`;只有在已经挂载的表单上下文中才直接使用 `formApi.form` 的细粒度订阅方法。
:::
::: tip fieldMappingTime
此属性用于将表单内的数组值映射成 2 个字段,它应当传入一个数组,数组的每一项是一个映射规则,规则的第一个成员是一个字符串,表示需要映射的字段名,第二个成员是一个数组,表示映射后的字段名,第三个成员是一个可选的格式掩码,用于格式化日期时间字段;也可以提供一个格式化函数(参数分别为当前值和当前字段名,返回格式化后的值)。如果明确地将格式掩码设为null,则原值映射而不进行格式化(适用于非日期时间字段)。例如:`[['timeRange', ['startTime', 'endTime'], 'YYYY-MM-DD']]`,`timeRange`应当是一个至少具有2个成员的数组类型的值。Form会将`timeRange`的值前两个值分别按照格式掩码`YYYY-MM-DD`格式化后映射到`startTime`和`endTime`字段上。每一项的第三个参数是一个可选的格式掩码,
:::
::: tip valueFormat
::: tip handleValuesChange
`valueFormat` 适合处理“组件值”和“提交值”不一致的场景。例如:
`handleValuesChange` 的第一个参数是未编码的只读 `TFormValues`,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行 codec 或旧格式化管道。
- `RangePicker` 返回 `[dayjs, dayjs]`,但后端需要 `{ startTime, endTime }`
- `DatePicker` 返回 `dayjs`,但后端只需要时间戳
`getRawValues()` 和 `getValues()` 分别只生成一份目标快照;确实需要同时比较两种结构时再调用 `getValueSnapshot()`。`handleSubmit(values, rawValues)` 会在提交边界同时提供格式化结果和对应的原始快照。
`valueFormat` 会在 `getValues()` 过程中执行:
:::
- 返回 `undefined`:当前字段保持删除状态
- 返回其他值:回写当前字段
- 调用 `setValue(key, nextValue)`:写入一个或多个新字段
::: tip 旧格式化 API
```ts
{
component: 'RangePicker',
fieldName: 'reportRange',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
}
```
`schema.valueFormat`、`fieldMappingTime` 和 `arrayToStringFields` 仍保持原运行时行为,但已经标记为 `@deprecated`,开发环境首次使用时会提示迁移。配置 codec 后只执行 codec;同时存在的旧配置会被忽略,避免重复转换。
:::
@ -410,6 +541,11 @@ export interface ActionButtonOptions {
```ts
export interface FormCommonConfig {
/**
* 仅当组件不发送 update:*、只发送 change 时启用兼容回退
* @default false
*/
changeEventFallback?: boolean;
/**
* 所有表单项的props
*/
@ -431,7 +567,7 @@ export interface FormCommonConfig {
* 所有表单项的控件样式
* @default {}
*/
formFieldProps?: Partial<typeof Field>;
formFieldProps?: FormFieldOptions;
/**
* 所有表单项的栅格布局
* @default ""
@ -454,8 +590,9 @@ export interface FormCommonConfig {
labelClass?: string;
/**
* 所有表单项的label宽度
* 设置为 `auto` 时,水平布局下会按当前表单可见 label 的最大宽度自动对齐
*/
labelWidth?: number;
labelWidth?: number | string;
/**
* 所有表单项的model属性名。使用自定义组件时可通过此配置指定组件的model属性名。已经在modelPropNameMap中注册的组件不受此配置影响
* @default "modelValue"
@ -475,11 +612,14 @@ export interface FormCommonConfig {
```ts
export interface FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> extends FormCommonConfig {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
componentProps?:
| MaybeComponentProps
| ((ctx: FormSchemaContext<TValues>) => MaybeComponentProps);
/** 默认值 */
defaultValue?: any;
/** 依赖 */
@ -489,26 +629,32 @@ export interface FormSchema<
/** 字段名,也作为自定义插槽的名称 */
fieldName: string;
/** 帮助信息 */
help?: CustomRenderType;
help?: string | ((ctx: FormSchemaContext<TValues>) => Component | string);
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单的标签(如果是一个string,会用于默认必选规则的消息提示) */
label?: CustomRenderType;
/** 自定义组件内部渲染 */
renderComponentContent?: RenderComponentContentType;
renderComponentContent?: (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/** 获取 getValues() 输出时格式化当前字段 */
/** @deprecated 使用表单级 codec */
valueFormat?: FormValueFormat;
}
```
顶层 `componentProps`、`help` 和 `renderComponentContent` 函数只接收轻量 `FormSchemaContext`,适合数组行索引、字段路径等 schema 信息。需要读取表单值时,使用 `dependencies.resolve({ values, ... })`,避免每个字段订阅整份 values。
:::
::: details FormValueFormat
`FormValueFormat` 是兼容类型,已标记为 `@deprecated`。新代码应使用 `FormCodec<TFormValues, TSubmitValues>`。
```ts
type FormValueFormat = (
value: any,
@ -529,29 +675,37 @@ type FormValueFormat = (
```ts
dependencies: {
// 触发字段。只有这些字段值变动时,联动才会触发
triggerFields: ['name'],
// 动态判断当前字段是否需要显示,不显示则直接销毁
if(values,formApi){},
// 动态判断当前字段是否需要显示,不显示用css隐藏
show(values,formApi){},
// 动态判断当前字段是否需要禁用
disabled(values,formApi){},
// 字段变更时,都会触发该函数
trigger(values,formApi){},
// 动态rules
rules(values,formApi){},
// 动态必填
required(values,formApi){},
// 动态组件参数
componentProps(values,formApi){},
triggerFields: ['type', 'role'],
resolve({ values, actions, controller, schema }) {
const editable = values.type === 'editable';
return {
componentProps: { placeholder: schema.fieldName },
disabled: !editable,
required: values.role === 'owner',
rules: editable ? 'required' : null,
show: values.type !== 'hidden',
};
},
}
```
`resolve` 返回的字段会一次性提交;支持 `if`、`show`、`disabled`、`required`、`rules`、`componentProps`、`help` 和 `renderComponentContent`。未返回 `rules` 时继续使用静态规则,显式返回 `rules: null` 时关闭静态规则。`actions` 是稳定的 `FormContextApi`,`controller` 是高层 FormApi,`schema` 包含字段名和数组行上下文。
旧的 `if/show/disabled/required/rules/componentProps/trigger` 回调语法仍完整兼容并保持原求值顺序,但已标记为 `@deprecated`,开发环境首次使用时会提示迁移。新旧语法在同一个 dependencies 对象中互斥;绕过类型同时传入时以 `resolve` 为准。
### 表单校验
表单校验需要通过 schema 内的 `rules` 属性进行配置。
字段默认在 blur、change 和 submit 时校验。使用 `formFieldProps.validateOn` 限制交互触发时机,submit 始终校验;异步校验可通过 `asyncDebounceMs` 防抖:
```ts
formFieldProps: {
asyncDebounceMs: 300,
validateOn: ['blur'],
}
```
rules的值可以是字符串(预定义的校验规则名称),也可以是一个zod的schema。
#### 预定义的校验规则
@ -617,6 +771,18 @@ import { z } from '#/adapter/form';
::: tip 字段插槽
除了以上内置插槽之外,`schema`属性中每个字段的`fieldName`都可以作为插槽名称,这些字段插槽的优先级高于`component`定义的组件。也就是说,当提供了与`fieldName`同名的插槽时,这些插槽的内容将会作为这些字段的组件,此时`component`的值将会被忽略。
除了以上内置插槽之外,`schema` 属性中每个字段的 `fieldName` 都可以作为插槽名称。这些字段插槽的优先级高于 `component` 定义的组件。
字段 slot 的控件绑定统一收拢在 `componentProps` 中,其中包含模型值、对应的 `update:*` 事件、schema/common/dependencies props 和 disabled 状态:
```vue
<Form>
<template #fieldName="slotProps">
<Input v-bind="slotProps.componentProps" />
</template>
</Form>
```
`field`、`componentField`、`modelValue`、`name`、`disabled`、`isInValid`、`values` 和 `formApi` 保留在 slot 根级,供模板逻辑使用,不会自动传入实际控件。
:::

27
docs/src/components/common-ui/vben-modal.md

@ -56,6 +56,29 @@ Modal 内的内容一般业务中,会比较复杂,所以我们可以将 moda
<DemoPreview dir="demos/vben-modal/shared-data" />
### 数据类型约束
推荐在 connected 子组件中声明一次数据类型并暴露 `modalApi`,外部会从 `connectedComponent` 自动推导 `setData` 和 `getData` 的类型:
```ts
// connected 子组件
const [Modal, modalApi] = useVbenModal<EditData>();
defineExpose({ modalApi });
// 外部组件,无需重复声明 EditData
const [Modal, modalApi] = useVbenModal({
connectedComponent: EditModal,
});
```
无法从组件公开实例推导时,可以显式使用 `useVbenModal<EditData>()`。需要让多个文件共享同一契约时,可以在独立模块中预绑定:
```ts
export const useEditModal = createVbenModal<EditData>();
```
三种方式的优先级为:显式泛型、connected component 自动推导、`unknown`。普通 SFC 通过 `defineExpose` 支持自动推导;泛型 SFC、函数式组件或被标注为宽 `Component` 的组件应使用显式泛型或契约工厂。`getData()` 在尚未调用 `setData()` 时返回 `undefined`,业务允许 `null`、部分对象等值时,需要在数据泛型中准确声明。
## 动画类型
通过 `animationType` 属性可以控制弹窗的动画效果:
@ -162,8 +185,8 @@ const [Modal, modalApi] = useVbenModal({
| setState | 动态设置弹窗状态属性 | `(((prev: ModalState) => Partial<ModalState>)\| Partial<ModalState>)=>modalApi` | - |
| open | 打开弹窗 | `()=>void` | - |
| close | 关闭弹窗 | `()=>void` | - |
| setData | 设置共享数据 | `<T>(data:T)=>modalApi` | - |
| getData | 获取共享数据 | `<T>()=>T` | - |
| setData | 设置共享数据 | `(data:TData)=>modalApi` | - |
| getData | 获取共享数据 | `()=>TData\|undefined` | - |
| useStore | 获取可响应式状态 | - | - |
| lock | 将弹窗标记为提交中,锁定当前状态 | `(isLock:boolean)=>modalApi` | >5.5.2 |
| unlock | lock方法的反操作,解除弹窗的锁定状态,也是lock(false)的别名 | `()=>modalApi` | >5.5.3 |

2
docs/src/demos/vben-descriptions/size/index.vue

@ -9,7 +9,7 @@ const items = [
];
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 16px">
<div class="flex gap-4 flex-col">
<VbenDescriptions
size="small"
bordered

4
docs/src/demos/vben-descriptions/span/index.vue

@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { DescriptionsItemType } from '@vben/common-ui';
import { VbenDescriptions } from '@vben/common-ui';
const items = [
const items: DescriptionsItemType[] = [
{ content: '1', label: 'A' },
{ content: '2(span: 2)', label: 'B', span: 2 },
{ content: '3', label: 'C' },

4
docs/src/demos/vben-descriptions/vertical/index.vue

@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { DescriptionsItemType } from '@vben/common-ui';
import { VbenDescriptions } from '@vben/common-ui';
const items = [
const items: DescriptionsItemType[] = [
{ content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' },
{ content: '中国 · 杭州', label: '居住地' },

13
docs/src/demos/vben-drawer/shared-data/drawer.vue

@ -3,9 +3,14 @@ import { ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
const data = ref();
interface SharedData {
content: string;
payload: string;
}
const [Drawer, drawerApi] = useVbenDrawer({
const data = ref<SharedData>();
const [Drawer, drawerApi] = useVbenDrawer<SharedData>({
onCancel() {
drawerApi.close();
},
@ -14,10 +19,12 @@ const [Drawer, drawerApi] = useVbenDrawer({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
data.value = drawerApi.getData<Record<string, any>>();
data.value = drawerApi.getData();
}
},
});
defineExpose({ drawerApi });
</script>
<template>
<Drawer title="数据共享示例">

2
docs/src/demos/vben-form/custom/index.vue

@ -62,7 +62,7 @@ function onSubmit(values: Record<string, any>) {
<template>
<Form>
<template #field3="slotProps">
<Input placeholder="请输入" v-bind="slotProps" />
<Input placeholder="请输入" v-bind="slotProps.componentProps" />
</template>
</Form>
</template>

34
docs/src/demos/vben-form/dynamic/index.vue

@ -124,26 +124,28 @@ const [Form] = useVbenForm({
showSearch: true,
},
dependencies: {
componentProps(values) {
resolve({ values }) {
if (values.field2 === '123') {
return {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
},
};
}
return {};
return { componentProps: {} };
},
triggerFields: ['field2'],
},

2
docs/src/demos/vben-form/rules/index.vue

@ -69,7 +69,7 @@ const [Form] = useVbenForm({
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.string().email('请输入正确的邮箱'),
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',

144
docs/src/demos/vben-form/value-format/index.vue

@ -1,47 +1,80 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
const transformedValues = ref<Record<string, any>>({});
const liveValues = ref<Record<string, any>>({});
interface ValueFormatFormValues {
firstName?: string;
lastName?: string;
tags?: string[];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
fullName: [values.firstName, values.lastName].filter(Boolean).join(' '),
tags: (values.tags ?? []).join(','),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
const [firstName = '', ...lastNameParts] = values.fullName
.trim()
.split(/\s+/);
return {
firstName,
lastName: lastNameParts.join(' '),
tags: values.tags ? values.tags.split(',') : [],
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
handleValuesChange,
schema: [
{
component: 'RangePicker',
fieldName: 'reportRange',
help: '通过 setValue 拆分为 startTime / endTime,并移除原字段',
label: '统计时间范围',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
component: 'Input',
fieldName: 'firstName',
help: '与姓氏一起编码为 fullName',
label: '名字',
},
{
component: 'DatePicker',
fieldName: 'deadline',
help: '直接 return 时间戳,保留原字段名',
label: '截止时间',
valueFormat(value) {
return value?.valueOf();
},
component: 'Input',
fieldName: 'lastName',
help: '与名字一起编码为 fullName',
label: '姓氏',
},
{
component: 'Input',
component: 'Select',
componentProps: {
placeholder: '请输入关键字',
mode: 'multiple',
options: [
{ label: '管理员', value: 'admin' },
{ label: '审核员', value: 'reviewer' },
{ label: '访客', value: 'guest' },
],
placeholder: '请选择标签',
},
fieldName: 'keyword',
label: '关键字',
fieldName: 'tags',
help: '数组编码为逗号分隔字符串',
label: '标签',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
@ -53,22 +86,8 @@ const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: Record<string, any>) {
return JSON.stringify(
value,
(_key, currentValue) => {
return isFormattableDateValue(currentValue)
? currentValue.format('YYYY-MM-DD HH:mm:ss')
: currentValue;
},
2,
);
}
function isFormattableDateValue(
value: unknown,
): value is { format: (template: string) => string } {
return !!value && typeof value === 'object' && 'format' in value;
function formatJsonPreview(value: unknown) {
return JSON.stringify(value, null, 2);
}
async function handleInspectValues() {
@ -76,44 +95,55 @@ async function handleInspectValues() {
message.success('已刷新 getValues 输出');
}
function handleSubmit(values: Record<string, any>) {
async function handleSetSubmitValues() {
await formApi.setSubmitValues({
fullName: 'Ada Lovelace',
tags: 'admin,reviewer',
});
await syncPreviewValues();
message.success('已通过 codec.decode 回填提交值');
}
function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
async function syncPreviewValues(values?: Record<string, any>) {
liveValues.value = values ?? formApi.form?.values ?? {};
function handleValuesChange(
values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
const rawValues = values ?? (await formApi.getRawValues());
liveValues.value = { ...rawValues };
transformedValues.value = await formApi.getValues();
}
onMounted(async () => {
await nextTick();
watch(
() => formApi.form?.values,
async (values) => {
await syncPreviewValues(values);
},
{
deep: true,
immediate: true,
},
);
await syncPreviewValues();
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Tag color="processing">return 值:回写当前字段</Tag>
<Tag color="success">setValue:拆分写入其他字段</Tag>
<Tag color="warning">return undefined:保持原字段删除</Tag>
<Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">多字段转换原子执行</Tag>
</div>
<Card title="valueFormat 示例">
<Card title="Codec 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetSubmitValues">从提交值回填</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
@ -123,12 +153,12 @@ onMounted(async () => {
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="原始 form.values(组件值)">
<Card title="getRawValues() 输出(组件值)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出(valueFormat 后)">
<Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>

13
docs/src/demos/vben-modal/shared-data/modal.vue

@ -3,9 +3,14 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
const data = ref();
interface SharedData {
content: string;
payload: string;
}
const [Modal, modalApi] = useVbenModal({
const data = ref<SharedData>();
const [Modal, modalApi] = useVbenModal<SharedData>({
onCancel() {
modalApi.close();
},
@ -14,10 +19,12 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
data.value = modalApi.getData<Record<string, any>>();
data.value = modalApi.getData();
}
},
});
defineExpose({ modalApi });
</script>
<template>
<Modal title="数据共享示例">

5
docs/src/en/components/common-ui/vben-alert.md

@ -66,10 +66,7 @@ export type PromptProps<T = any> = {
component?: Component;
componentProps?: Recordable<any>;
componentSlots?:
| (() => any)
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
(() => any) | Recordable<unknown> | VNode | VNodeArrayChildren;
defaultValue?: T;
modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>;

27
docs/src/en/components/common-ui/vben-drawer.md

@ -23,6 +23,29 @@ const [Drawer, drawerApi] = useVbenDrawer({
- Default drawer behavior can be adjusted in `apps/<app>/src/bootstrap.ts` through `setDefaultDrawerProps(...)`.
- `setState(...)` works on `DrawerState`, not `ModalState`.
## Shared Data Types
The recommended approach is to declare the data type once in the connected component and expose `drawerApi`. The outer call then infers the data contract from `connectedComponent`:
```ts
// Connected component
const [Drawer, drawerApi] = useVbenDrawer<EditData>();
defineExpose({ drawerApi });
// Outer component, EditData is inferred
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: EditDrawer,
});
```
Use `useVbenDrawer<EditData>()` explicitly when the component type cannot expose the contract. For larger features, pre-bind one reusable contract in a separate module:
```ts
export const useEditDrawer = createVbenDrawer<EditData>();
```
The precedence is explicit generic, connected component inference, then `unknown`. Plain SFCs support inference through `defineExpose`; generic SFCs, functional components, and components widened to `Component` should use an explicit generic or contract factory. `getData()` returns `undefined` before `setData()` is called. Include `null` or partial payloads in the data type when they are valid business values.
## Key Props
| Prop | Description | Type |
@ -50,7 +73,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
| `setState(...)` | updates drawer state |
| `open()` | opens the drawer |
| `close()` | closes the drawer |
| `setData(data)` | stores shared data |
| `getData<T>()` | reads shared data |
| `setData(data: TData)` | stores typed shared data |
| `getData()` | returns `TData \| undefined` |
| `lock(isLocked = true)` | locks the drawer into submitting state |
| `unlock()` | alias for `lock(false)` |

181
docs/src/en/components/common-ui/vben-form.md

@ -4,8 +4,30 @@ outline: deep
# Vben Form
::: warning Field Slot Breaking Change
Named field slot control bindings are now grouped under `slotProps.componentProps`. The old binding forwards form metadata such as `field`, `formApi`, and `values` to the rendered control, which can produce invalid attributes and Vue runtime warnings.
```vue
<!-- Old usage -->
<Input v-bind="slotProps" />
<!-- New usage -->
<Input v-bind="slotProps.componentProps" />
```
Migrate every field slot from `v-bind="slotProps"` to `v-bind="slotProps.componentProps"`. Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`, but it is no longer forwarded automatically to the rendered control.
In this release, starting a Vben application or Playground development server prints this migration warning in the terminal, and loading the page prints the same warning in the browser console. The warning is excluded from production builds and is planned for removal in the next release.
:::
`Vben Form` is the shared form abstraction used across different UI-library variants such as `Ant Design Vue`, `Element Plus`, `Naive UI`, and other adapters added inside this repository.
It uses [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) internally for state and validation lifecycles, with [Zod 4](https://zod.dev/v4) schemas. Application code should continue using `useVbenForm`, `FormApi`, and the adapter layer instead of depending on the raw TanStack instance.
Read the [Zod 4 and TanStack Form migration guide](/en/guide/in-depth/zod-v4-form-migration) before upgrading an existing project.
> If some details are not obvious from the docs, check the live demos as well.
## Adapter Setup
@ -19,12 +41,15 @@ The current adapter pattern is:
- map special `v-model:*` prop names through `modelPropNameMap`
- keep the form empty state aligned with the actual UI library behavior
Each `setupVbenForm` call rebuilds the component and model-prop mappings from the current shared component registry. Repeated setup removes components that are no longer registered while preserving built-in components and their default bindings.
### Form Adapter Example
```ts
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@ -46,7 +71,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@ -62,11 +87,20 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;
```
### Component Adapter Example
@ -191,23 +225,144 @@ Create the form through `useVbenForm`:
<DemoPreview dir="demos/vben-form/basic" />
## Value Formatting
## Typed Values and Slots
Use `useVbenForm<TFormValues, TSubmitValues>` to declare component-facing form values and submission values separately. Schema, slots, selectors, and `setValues` use `TFormValues`; `getValues()` and `submit()` return `Promise<TSubmitValues>`, while `submit()` only accepts an optional native `Event`; the first `handleSubmit` argument is `TSubmitValues`. Pass one generic when both shapes are identical.
`setValues` accepts a deep `FormValuePatch<TFormValues>`. With the default `filterFields=true`, plain objects are merged as patches before fields outside the schema are removed, so updating `profile.email` preserves declared sibling fields and defaults under `profile`. Arrays, dates, Day.js values, `null`, and `undefined` replace the corresponding value atomically. Use `setFieldValue('profile', nextProfile)` to replace an entire object branch, or set `filterFields` to `false` to bypass schema filtering.
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values); // AccountFormValues
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
return formApi.getValues(); // Promise<AccountFormValues>
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value and componentField.modelValue are strings -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>
```
Named field slots expose grouped control bindings through `componentProps`, together with `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`.
Use a precise form-value interface without a string index signature to infer each field value. A broad type such as `Record<string, unknown>` keeps the complete slot-prop structure instead of degrading the whole scope to `any`, but field values can only use the declared index value type.
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape.
## Field Slots
- return a value to write back to the current field
- call `setValue(key, nextValue)` to write derived fields
- return `undefined` to keep the original field removed after decomposition
Control bindings are grouped under `componentProps`. It contains the model value, matching `update:*` event, schema/common/dependency props, and disabled state:
```vue
<Form>
<template #fieldName="slotProps">
<Input v-bind="slotProps.componentProps" />
</template>
</Form>
```
Root metadata remains available for template logic through `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`; it is not forwarded automatically to the rendered control.
## Form Codec
Use the form-level `codec` when component values and the backend payload have different shapes. `encode` converts the complete `TFormValues` object to `TSubmitValues`; `decode` performs the inverse conversion. Multi-field splits and merges are atomic and do not depend on schema order or string-path writes.
Define `codec` directly in the `useVbenForm` options. Annotate only the form-value input of `encode`; `TSubmitValues` is inferred from its return object and flows into `decode`, `getValues()`, and submit callbacks:
```ts
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});
```
<DemoPreview dir="demos/vben-form/value-format" />
`schema.valueFormat`, `fieldMappingTime`, and `arrayToStringFields` remain runtime-compatible but are deprecated. When a codec is configured it takes precedence and deprecated transforms are ignored.
## Performance Benchmarks
The form benchmarks cover component initialization, single-field and batch updates, reset, Zod validation, dynamic schemas, dependencies, codec encoding and snapshots, plus array editing, row mutations, and child-schema updates. Run the complete benchmark suite with:
```bash
pnpm test:benchmark
```
To run only the form benchmarks, pass both files explicitly:
```bash
pnpm exec vitest bench --run packages/@core/ui-kit/form-ui/__tests__/form-component-performance.benchmark.ts packages/@core/ui-kit/form-ui/__tests__/form-performance.benchmark.ts
```
Use benchmark results to compare relative changes on the same machine and runtime; do not treat one run's absolute timings as portable thresholds. Stop CPU-intensive development servers first and keep the Node.js version consistent. Benchmark files are not included in the regular `test:unit` command.
::: warning Mounted form context
`formApi.form` is the `FormContextApi` injected after `<Form />` mounts. Do not destructure or cache `form` from the second `useVbenForm` return value during setup, because that captures the pre-mount empty reference. Prefer mount-aware public methods such as `getRawValues()`, `setFieldError()`, `setFieldValue()`, and `validate()` for business actions. Access fine-grained subscription methods on `formApi.form` only from an already-mounted form context.
:::
## Key API Notes
- `useVbenForm` returns `[Form, formApi]`
- `useVbenForm<TFormValues, TSubmitValues>` keeps component values and submission values distinct
- prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation`
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
- `clearValidation` invalidates in-flight async results before clearing errors
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
- `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
- `handleValuesChange(values, fieldsChanged)` receives readonly `TFormValues` before codec or legacy formatting
- its third `getFormattedValues` argument formats lazily, so raw-only change handlers avoid clone and transform work
- `getRawValues()` returns only an independent raw snapshot, `getValues()` returns only the formatted payload, and `getValueSnapshot()` returns both
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
- `fieldMappingTime`, `arrayToStringFields`, and `schema.valueFormat` are deprecated compatibility options
- `codec.encode` defines the `getValues()` payload and `codec.decode` powers complete `setSubmitValues()` fills
- `formApi.form` exposes the mounted `FormContextApi`; do not destructure or cache it before `<Form />` mounts
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state
- legacy `setupVbenForm({ defineRules })` still works, warns once in development, and is silent in production; use `rules` for new code
- prefer `dependencies: { triggerFields, resolve(context) }` for one atomic dynamic-state patch; legacy dependency callbacks remain supported but are deprecated and warn once in development
- top-level `componentProps`, `help`, and `renderComponentContent` functions receive `FormSchemaContext`; value-dependent rendering belongs in `dependencies.resolve`
- use `formFieldProps.validateOn` with `blur` and/or `change`; submit always validates, and `asyncDebounceMs` debounces async validators
- use `changeEventFallback: true` only for components that emit `change` without an `update:*` event
## Reference

27
docs/src/en/components/common-ui/vben-modal.md

@ -23,6 +23,29 @@ const [Modal, modalApi] = useVbenModal({
- When `connectedComponent` is present, avoid pushing extra modal props through the connected side. Prefer `useVbenModal(...)` or `modalApi.setState(...)`.
- Default modal behavior can be adjusted in `apps/<app>/src/bootstrap.ts` through `setDefaultModalProps(...)`.
## Shared Data Types
The recommended approach is to declare the data type once in the connected component and expose `modalApi`. The outer call then infers the data contract from `connectedComponent`:
```ts
// Connected component
const [Modal, modalApi] = useVbenModal<EditData>();
defineExpose({ modalApi });
// Outer component, EditData is inferred
const [Modal, modalApi] = useVbenModal({
connectedComponent: EditModal,
});
```
Use `useVbenModal<EditData>()` explicitly when the component type cannot expose the contract. For larger features, pre-bind one reusable contract in a separate module:
```ts
export const useEditModal = createVbenModal<EditData>();
```
The precedence is explicit generic, connected component inference, then `unknown`. Plain SFCs support inference through `defineExpose`; generic SFCs, functional components, and components widened to `Component` should use an explicit generic or contract factory. `getData()` returns `undefined` before `setData()` is called. Include `null` or partial payloads in the data type when they are valid business values.
## Key Props
| Prop | Description | Type |
@ -50,7 +73,7 @@ const [Modal, modalApi] = useVbenModal({
| `setState(...)` | updates modal state |
| `open()` | opens the modal |
| `close()` | closes the modal |
| `setData(data)` | stores shared data |
| `getData<T>()` | reads shared data |
| `setData(data: TData)` | stores typed shared data |
| `getData()` | returns `TData \| undefined` |
| `lock(isLocked = true)` | locks the modal into submitting state |
| `unlock()` | alias for `lock(false)` |

370
docs/src/en/guide/essentials/cache.md

@ -0,0 +1,370 @@
---
outline: deep
---
# Cache
::: tip Preface
A strategy-pattern-based async storage solution that supports multiple backends (localStorage, IndexedDB, Memory) behind a unified API. All methods are async so callers need no changes when switching drivers.
:::
::: tip
`@vben/utils` re-exports the full cache module — business code can import everything uniformly from `@vben/utils`.
:::
## Architecture
```shell
┌───────────────────────────────────────────────┐
│ StorageManager │
│ ┌─────────────┐ ┌───────────────────────┐ │
│ │ Prefix isolation │ │ TTL expiry │ │
│ └─────────────┘ └───────────────────────┘ │
├───────────────────────────────────────────────┤
│ IStorageDriver │
├──────────┬─────────────────┬──────────────────┤
│ Local │ IndexedDB │ Memory │
│ Storage │ Driver │ Driver │
│ Driver │ │ │
└──────────┴─────────────────┴──────────────────┘
```
**Layer responsibilities:**
| Layer | Responsibility |
| --- | --- |
| `StorageManager` | Namespace prefix isolation, TTL expiry checks, unified public API |
| `IStorageDriver` | Pure KV storage abstraction interface |
| Driver implementations | Talk to concrete storage engines, unaware of prefix or TTL |
## Quick Start
### Basic usage
When `driver` is omitted, the browser uses `LocalStorageDriver` if `localStorage` is available, otherwise falls back to `MemoryStorageDriver` (e.g. Safari private mode); SSR/Node uses `MemoryStorageDriver`:
```ts
import { StorageManager } from '@vben/utils';
const cache = new StorageManager({ prefix: 'myapp' });
// Write a value
await cache.setItem('user', { name: 'John', age: 28 });
// Read a value
const user = await cache.getItem('user');
// => { name: 'John', age: 28 }
// Read with a default value
const settings = await cache.getItem('settings', { theme: 'light' });
// Returns { theme: 'light' } if absent
// Delete a value
await cache.removeItem('user');
// Clear all entries under the current prefix
await cache.clear();
```
### With TTL expiry
The third argument of `setItem` is the TTL in milliseconds. Once expired, reads return the default value (lazy deletion):
```ts
import { StorageManager } from '@vben/utils';
const cache = new StorageManager({ prefix: 'session' });
// Expires in 5 minutes
await cache.setItem('token', 'abc123', 5 * 60 * 1000);
// Reads normally within 5 minutes
const token = await cache.getItem('token');
// => 'abc123'
// Returns null after 5 minutes
const expiredToken = await cache.getItem('token');
// => null
// Actively clean up all expired entries
await cache.clearExpiredItems();
```
## Storage Drivers
### Local storage driver (default)
`LocalStorageDriver`: based on the browser's `localStorage` / `sessionStorage`, data is persisted.
```ts
import { LocalStorageDriver, StorageManager } from '@vben/utils';
// Use localStorage (default)
const cache = new StorageManager({
driver: new LocalStorageDriver(),
prefix: 'app',
});
// Use sessionStorage
const sessionCache = new StorageManager({
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }),
prefix: 'app',
});
```
**Characteristics:**
- Synchronous API wrapped in async to keep the interface unified
- Automatic JSON serialization / deserialization
- Corrupt data is auto-cleared and returns `null`
- Storage limit ~5–10MB (browser-dependent)
**Use cases:** user preferences, small config data, token storage
### IndexedDB driver
`IndexedDBDriver`: based on the browser's IndexedDB, supports large structured data storage.
```ts
import { IndexedDBDriver, StorageManager } from '@vben/utils';
const cache = new StorageManager({
driver: new IndexedDBDriver({
dbName: 'my-app-db', // Database name, default 'vben-storage'
dbVersion: 1, // Database version, default 1
storeName: 'cache-store', // Object store name, default 'kv-store'
}),
prefix: 'data',
});
// Store large or complex data (IndexedDB natively supports structured cloning)
await cache.setItem('table-data', largeDataArray);
await cache.setItem('config', {
columns: [...],
filters: [...],
pagination: { page: 1, size: 20 },
});
```
**Characteristics:**
- Lazy initialization: opens the database on first operation, no manual `init()`
- Large capacity (typically hundreds of MB to GB)
- Supports structured cloning (Date, RegExp, Blob, etc.)
- Natively async, does not block the main thread
**Use cases:** offline data caching, large table data, file/image caching, complex business data
### Memory storage driver
`MemoryStorageDriver`: based on an in-memory `Map`, data is not persisted and is lost on page refresh.
```ts
import { MemoryStorageDriver, StorageManager } from '@vben/utils';
const cache = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test',
});
```
**Characteristics:**
- Fastest read/write
- No browser API dependency
- Data is destroyed with the page lifecycle
**Use cases:** unit tests, SSR rendering, temporary runtime caching
### Driver comparison
| Feature | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
| --- | --- | --- | --- |
| Persistence | ✅ | ✅ | ❌ |
| Capacity | 5–10 MB | Hundreds of MB+ | Memory-bound |
| Speed | Fast (sync) | Medium (async I/O) | Fastest |
| Data type | JSON-serializable only | Structured clone | Any JS object |
| Browser support | All modern browsers | All modern browsers | Any environment |
| Blocks main thread | Yes | No | No |
| Use case | Config, tokens, small data | Offline cache, big data | Tests, SSR |
## API Reference
### StorageManager
#### Constructor
```ts
new StorageManager(options?: StorageManagerOptions)
```
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` in browser when `localStorage` is available, `new MemoryStorageDriver()` otherwise (Safari private mode, SSR/Node) | Storage driver instance |
| `prefix` | `string` | `''` | Key prefix for namespace isolation |
#### Methods
| Method | Signature | Description |
| --- | --- | --- |
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | Get an entry; returns the default if expired or absent |
| `setItem` | `setItem(key: string, value: unknown, ttl?: number): Promise<void>` | Set an entry, with optional TTL (ms) |
| `removeItem` | `removeItem(key: string): Promise<void>` | Delete the given entry |
| `clear` | `clear(): Promise<void>` | Clear all entries under the current prefix |
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | Actively clean up all expired entries |
| `keys` | `keys(): Promise<string[]>` | Return all keys under the current prefix (prefix stripped) |
### IStorageDriver interface
Custom drivers implement this interface:
```ts
interface IStorageDriver {
clear(): Promise<void>;
getItem<T>(key: string): Promise<null | T>;
keys(): Promise<string[]>;
removeItem(key: string): Promise<void>;
setItem(key: string, value: unknown): Promise<void>;
}
```
## Advanced Usage
### Custom Driver
Implement `IStorageDriver` to plug in any storage engine. Example with cookies:
```ts
import type { IStorageDriver } from '@vben/utils';
class CookieStorageDriver implements IStorageDriver {
async getItem<T>(key: string): Promise<null | T> {
const value = getCookie(key);
return value ? JSON.parse(value) : null;
}
async setItem(key: string, value: unknown): Promise<void> {
setCookie(key, JSON.stringify(value));
}
async removeItem(key: string): Promise<void> {
deleteCookie(key);
}
async clear(): Promise<void> {
clearAllCookies();
}
async keys(): Promise<string[]> {
return getAllCookieNames();
}
}
const cache = new StorageManager({
driver: new CookieStorageDriver(),
prefix: 'ck',
});
```
### Dynamic driver selection by environment
```ts
import {
IndexedDBDriver,
LocalStorageDriver,
MemoryStorageDriver,
StorageManager,
} from '@vben/utils';
function createStorageManager(prefix: string) {
// SSR environment uses the memory driver
if (typeof window === 'undefined') {
return new StorageManager({
driver: new MemoryStorageDriver(),
prefix,
});
}
// Large-data scenarios use IndexedDB
if (needsLargeStorage()) {
return new StorageManager({
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
prefix,
});
}
// Default to localStorage
return new StorageManager({ prefix });
}
```
### Namespace isolation
Different modules use different prefixes so they do not interfere:
```ts
const userCache = new StorageManager({ prefix: 'user' });
const configCache = new StorageManager({ prefix: 'config' });
await userCache.setItem('profile', { name: 'John' });
await configCache.setItem('profile', { theme: 'dark' });
await userCache.getItem('profile'); // => { name: 'John' }
await configCache.getItem('profile'); // => { theme: 'dark' }
// Clears only the user-prefixed data, config is unaffected
await userCache.clear();
await configCache.getItem('profile'); // => { theme: 'dark' }
```
### Scheduled cleanup of expired data
```ts
const cache = new StorageManager({ prefix: 'app' });
// Clean up once on app startup
await cache.clearExpiredItems();
// Or schedule it (every 10 minutes)
setInterval(
async () => {
await cache.clearExpiredItems();
},
10 * 60 * 1000,
);
```
## Storage Format
The structure `StorageManager` stores at the Driver layer:
```ts
interface StorageItem<T> {
expiry?: number; // Expiry timestamp (ms); undefined means never expires
value: T; // Actual business data
}
```
The actual stored key is formatted as `{prefix}-{key}`. For example, `prefix = 'app'`, `key = 'user'` produces the stored key `app-user`.
## Expiry Strategy
A dual strategy of **lazy deletion + active cleanup**:
| Strategy | When | Description |
| --- | --- | --- |
| Lazy deletion | On `getItem` | Checks expiry on read; deletes and returns the default if expired |
| Active cleanup | On `clearExpiredItems` | Iterates all prefixed keys and deletes expired ones |
## Notes
1. **All methods are async** — even the synchronous localStorage is wrapped in Promises so callers need no changes when switching drivers.
2. **TTL is in milliseconds** — `setItem('key', value, 60000)` expires in 60 seconds.
3. **IndexedDB lazy initialization** — no manual `init()` or `open()`; the DB connection is opened on first operation and reused.
4. **Prefix isolation is logical** — `clear()` only clears data under the current prefix; with an empty prefix, `clear()` / `keys()` operate on all keys in the selected driver.
5. **LocalStorageDriver error handling** — auto-clears corrupt data on JSON parse failure and returns `null`.
6. **IndexedDB version upgrade** — increment `dbVersion` to modify the objectStore structure; the current implementation creates the objectStore in the `upgradeneeded` handler.

7
docs/src/en/guide/essentials/route.md

@ -341,12 +341,7 @@ interface RouteMeta {
* Badge color
*/
badgeVariants?:
| 'default'
| 'destructive'
| 'primary'
| 'success'
| 'warning'
| string;
'default' | 'destructive' | 'primary' | 'success' | 'warning' | string;
/**
* The children of the current route are not displayed in the menu
* @default false

151
docs/src/en/guide/essentials/stores.md

@ -0,0 +1,151 @@
---
outline: deep
---
# Stores
::: tip
`@vben/stores` is already imported uniformly under each `app`; no separate installation is needed. The package also re-exports `pinia`'s `defineStore` and `storeToRefs`, so business code can import them uniformly from `@vben/stores`.
:::
## User Store
`useUserStore`, store id `core-user`. Wraps user info and roles.
::: details UserState definition
| Field | Default | Description |
| ----------- | ------- | ----------- |
| `userInfo` | `null` | User info |
| `userRoles` | `[]` | User roles |
:::
### Set user info
`setUserInfo(userInfo)`: Sets user info and syncs roles from `userInfo.roles` into `userRoles`.
```ts
import { useUserStore } from '@vben/stores';
const userStore = useUserStore();
userStore.setUserInfo({ id: 1, name: 'vben', roles: ['admin'] });
userStore.userRoles; // ['admin']
```
`useUserStore` has no `persist` configured — user info is runtime state, usually returned by an API after login and invalidated on logout.
### Set user roles
`setUserRoles(roles)`: Directly sets the user role list.
```ts
import { useUserStore } from '@vben/stores';
const userStore = useUserStore();
userStore.setUserRoles(['admin', 'editor']);
userStore.userRoles; // ['admin', 'editor']
```
### Get user info
`useUserStore` has no dedicated getter — read the state directly. Use `storeToRefs` to destructure while keeping reactivity.
```ts
import { storeToRefs, useUserStore } from '@vben/stores';
const userStore = useUserStore();
// Direct access
userStore.userInfo;
userStore.userRoles;
// Keep reactivity
const { userInfo, userRoles } = storeToRefs(userStore);
```
## Timezone Store
`useTimezoneStore`, store id `core-timezone`. A setup-style store wrapping timezone state.
::: details Exposed state and methods
| Name | Description |
| --- | --- |
| `timezone` | Current timezone; initial value comes from `getCurrentTimezone()` |
| `setTimezone(timezone)` | Set the timezone and sync it to the dayjs default timezone |
| `getTimezoneOptions()` | Get the timezone option list; defaults to `DEFAULT_TIME_ZONE_OPTIONS` |
| `$reset()` | Reset the timezone to `getCurrentTimezone()` |
:::
### Set timezone
`setTimezone(timezone)`: Sets the current timezone and syncs it to the dayjs default timezone (`dayjs.tz.setDefault`).
```ts
import { useTimezoneStore } from '@vben/stores';
const store = useTimezoneStore();
await store.setTimezone('America/New_York');
store.timezone; // 'America/New_York'
```
### Get timezone options
`getTimezoneOptions()`: Returns the timezone option list, defaults to `DEFAULT_TIME_ZONE_OPTIONS`; can be overridden via `setTimezoneHandler`.
```ts
import { useTimezoneStore } from '@vben/stores';
const store = useTimezoneStore();
const options = await store.getTimezoneOptions();
// [{ label: 'UTC+8', value: 'Asia/Shanghai' }, ...]
```
### Reset timezone
`$reset()`: Resets `timezone` to the current timezone returned by `getCurrentTimezone()`. It only resets the store's internal ref and does **not** sync dayjs's default timezone (only `setTimezone` does).
```ts
import { useTimezoneStore } from '@vben/stores';
const store = useTimezoneStore();
store.$reset();
store.timezone; // back to the value of getCurrentTimezone()
```
### Inject custom timezone handler
`setTimezoneHandler`: Injects a custom timezone handler module that can override `getTimezone` / `getTimezoneOptions` / `setTimezone`, useful for persisting user timezone preferences via a backend API.
```ts
import { setTimezoneHandler, useTimezoneStore } from '@vben/stores';
setTimezoneHandler({
async getTimezone() {
return (await fetchUserSettings()).timezone;
},
async setTimezone(timezone) {
await saveUserSettings({ timezone });
},
async getTimezoneOptions() {
return [{ label: 'UTC+8', value: 'Asia/Shanghai' }];
},
});
const store = useTimezoneStore();
await store.setTimezone('Asia/Shanghai');
```
### Persistence strategy
```ts
persist: {
pick: ['timezone'];
}
```
The `timezone` field is persisted and preserved on page refresh; the handler logic injected by `setTimezoneHandler` is runtime config and is not persisted.

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

Loading…
Cancel
Save