Merge remote-tracking branch 'vben/main' into vben-dev

# Conflicts:
#	.gitignore
#	.husky/pre-commit
#	.vscode/extensions.json
#	.vscode/settings.json
#	README.ja-JP.md
#	README.md
#	README.zh-CN.md
#	apps/backend-mock/api/auth/codes.ts
#	apps/backend-mock/api/auth/login.post.ts
#	apps/backend-mock/api/auth/logout.post.ts
#	apps/backend-mock/api/auth/refresh.post.ts
#	apps/backend-mock/api/menu/all.ts
#	apps/backend-mock/api/status.ts
#	apps/backend-mock/api/table/list.ts
#	apps/backend-mock/api/test.get.ts
#	apps/backend-mock/api/test.post.ts
#	apps/backend-mock/api/user/info.ts
#	apps/backend-mock/middleware/1.api.ts
#	apps/backend-mock/nitro.config.ts
#	apps/backend-mock/package.json
#	apps/backend-mock/routes/[...].ts
#	apps/backend-mock/tsconfig.json
#	apps/backend-mock/utils/cookie-utils.ts
#	apps/backend-mock/utils/jwt-utils.ts
#	apps/backend-mock/utils/mock-data.ts
#	apps/backend-mock/utils/response.ts
#	apps/web-antd/.env
#	apps/web-antd/src/adapter/component/index.ts
#	apps/web-antd/src/adapter/form.ts
#	apps/web-antd/src/api/request.ts
#	apps/web-antd/src/layouts/auth.vue
#	apps/web-antd/src/layouts/basic.vue
#	apps/web-antd/src/preferences.ts
#	apps/web-antd/src/router/guard.ts
#	apps/web-antd/src/router/routes/modules/demos.ts
#	apps/web-antd/src/store/auth.ts
#	apps/web-antd/src/views/demos/antd/index.vue
#	apps/web-antdv-next/vite.config.ts
#	apps/web-ele/.env
#	apps/web-ele/index.html
#	apps/web-ele/package.json
#	apps/web-ele/src/adapter/component/index.ts
#	apps/web-ele/src/adapter/form.ts
#	apps/web-ele/src/adapter/vxe-table.ts
#	apps/web-ele/src/api/request.ts
#	apps/web-ele/src/bootstrap.ts
#	apps/web-ele/src/layouts/auth.vue
#	apps/web-ele/src/layouts/basic.vue
#	apps/web-ele/src/locales/index.ts
#	apps/web-ele/src/locales/langs/en-US/demos.json
#	apps/web-ele/src/locales/langs/en-US/page.json
#	apps/web-ele/src/locales/langs/zh-CN/demos.json
#	apps/web-ele/src/locales/langs/zh-CN/page.json
#	apps/web-ele/src/preferences.ts
#	apps/web-ele/src/router/guard.ts
#	apps/web-ele/src/router/routes/core.ts
#	apps/web-ele/src/router/routes/modules/dashboard.ts
#	apps/web-ele/src/router/routes/modules/demos.ts
#	apps/web-ele/src/router/routes/modules/vben.ts
#	apps/web-ele/src/store/auth.ts
#	apps/web-ele/src/views/_core/authentication/code-login.vue
#	apps/web-ele/src/views/_core/authentication/forget-password.vue
#	apps/web-ele/src/views/_core/authentication/register.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-ele/src/views/dashboard/analytics/index.vue
#	apps/web-ele/src/views/demos/element/index.vue
#	apps/web-ele/src/views/demos/form/basic.vue
#	apps/web-ele/tsconfig.json
#	apps/web-ele/tsconfig.node.json
#	apps/web-naive/.env
#	apps/web-naive/index.html
#	apps/web-naive/package.json
#	apps/web-naive/src/adapter/component/index.ts
#	apps/web-naive/src/adapter/form.ts
#	apps/web-naive/src/adapter/vxe-table.ts
#	apps/web-naive/src/api/request.ts
#	apps/web-naive/src/bootstrap.ts
#	apps/web-naive/src/layouts/auth.vue
#	apps/web-naive/src/layouts/basic.vue
#	apps/web-naive/src/locales/index.ts
#	apps/web-naive/src/locales/langs/en-US/demos.json
#	apps/web-naive/src/locales/langs/en-US/page.json
#	apps/web-naive/src/locales/langs/zh-CN/demos.json
#	apps/web-naive/src/locales/langs/zh-CN/page.json
#	apps/web-naive/src/preferences.ts
#	apps/web-naive/src/router/guard.ts
#	apps/web-naive/src/router/routes/core.ts
#	apps/web-naive/src/router/routes/modules/dashboard.ts
#	apps/web-naive/src/router/routes/modules/demos.ts
#	apps/web-naive/src/router/routes/modules/vben.ts
#	apps/web-naive/src/store/auth.ts
#	apps/web-naive/src/views/_core/authentication/code-login.vue
#	apps/web-naive/src/views/_core/authentication/forget-password.vue
#	apps/web-naive/src/views/_core/authentication/register.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-naive/src/views/dashboard/analytics/index.vue
#	apps/web-naive/src/views/demos/form/basic.vue
#	apps/web-naive/src/views/demos/naive/index.vue
#	apps/web-naive/tsconfig.json
#	apps/web-naive/tsconfig.node.json
#	apps/web-naive/vite.config.ts
#	docs/.vitepress/components/demo-preview.vue
#	docs/.vitepress/components/preview-group.vue
#	docs/.vitepress/config/en.mts
#	docs/.vitepress/config/index.mts
#	docs/.vitepress/config/plugins/demo-preview.ts
#	docs/.vitepress/config/shared.mts
#	docs/.vitepress/config/zh.mts
#	docs/.vitepress/theme/components/site-layout.vue
#	docs/.vitepress/theme/components/vben-contributors.vue
#	docs/.vitepress/theme/styles/variables.css
#	docs/package.json
#	docs/src/_env/adapter/component.ts
#	docs/src/_env/adapter/form.ts
#	docs/src/_env/adapter/vxe-table.ts
#	docs/src/commercial/community.md
#	docs/src/components/common-ui/vben-count-to-animator.md
#	docs/src/components/common-ui/vben-drawer.md
#	docs/src/components/common-ui/vben-form.md
#	docs/src/components/common-ui/vben-modal.md
#	docs/src/components/common-ui/vben-vxe-table.md
#	docs/src/components/layout-ui/page.md
#	docs/src/demos/vben-drawer/auto-height/drawer.vue
#	docs/src/demos/vben-drawer/basic/index.vue
#	docs/src/demos/vben-drawer/dynamic/index.vue
#	docs/src/demos/vben-drawer/shared-data/drawer.vue
#	docs/src/demos/vben-drawer/shared-data/index.vue
#	docs/src/demos/vben-form/api/index.vue
#	docs/src/demos/vben-form/basic/index.vue
#	docs/src/demos/vben-form/custom/index.vue
#	docs/src/demos/vben-form/dynamic/index.vue
#	docs/src/demos/vben-form/query/index.vue
#	docs/src/demos/vben-form/rules/index.vue
#	docs/src/demos/vben-modal/auto-height/modal.vue
#	docs/src/demos/vben-modal/basic/index.vue
#	docs/src/demos/vben-modal/dynamic/index.vue
#	docs/src/demos/vben-modal/shared-data/index.vue
#	docs/src/demos/vben-modal/shared-data/modal.vue
#	docs/src/demos/vben-vxe-table/basic/index.vue
#	docs/src/demos/vben-vxe-table/custom-cell/index.vue
#	docs/src/demos/vben-vxe-table/edit-row/index.vue
#	docs/src/demos/vben-vxe-table/fixed/index.vue
#	docs/src/demos/vben-vxe-table/form/index.vue
#	docs/src/demos/vben-vxe-table/remote/index.vue
#	docs/src/demos/vben-vxe-table/tree/index.vue
#	docs/src/demos/vben-vxe-table/virtual/index.vue
#	docs/src/en/guide/essentials/development.md
#	docs/src/en/guide/essentials/external-module.md
#	docs/src/en/guide/essentials/route.md
#	docs/src/en/guide/essentials/server.md
#	docs/src/en/guide/essentials/settings.md
#	docs/src/en/guide/in-depth/access.md
#	docs/src/en/guide/in-depth/loading.md
#	docs/src/en/guide/in-depth/locale.md
#	docs/src/en/guide/in-depth/theme.md
#	docs/src/en/guide/introduction/quick-start.md
#	docs/src/en/guide/introduction/thin.md
#	docs/src/en/guide/introduction/vben.md
#	docs/src/en/guide/other/faq.md
#	docs/src/en/guide/project/dir.md
#	docs/src/en/guide/project/standard.md
#	docs/src/en/guide/project/tailwindcss.md
#	docs/src/en/index.md
#	docs/src/friend-links/index.md
#	docs/src/guide/essentials/build.md
#	docs/src/guide/essentials/development.md
#	docs/src/guide/essentials/external-module.md
#	docs/src/guide/essentials/route.md
#	docs/src/guide/essentials/server.md
#	docs/src/guide/essentials/settings.md
#	docs/src/guide/in-depth/access.md
#	docs/src/guide/in-depth/check-updates.md
#	docs/src/guide/in-depth/loading.md
#	docs/src/guide/in-depth/locale.md
#	docs/src/guide/in-depth/theme.md
#	docs/src/guide/introduction/quick-start.md
#	docs/src/guide/introduction/thin.md
#	docs/src/guide/introduction/vben.md
#	docs/src/guide/introduction/why.md
#	docs/src/guide/other/faq.md
#	docs/src/guide/project/dir.md
#	docs/src/guide/project/standard.md
#	docs/src/guide/project/tailwindcss.md
#	docs/src/index.md
#	docs/tsconfig.json
#	package.json
#	packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
#	packages/constants/src/core.ts
#	packages/effects/common-ui/src/components/index.ts
#	packages/effects/common-ui/src/ui/dashboard/analysis/analysis-overview.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-header.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-project.vue
#	packages/effects/layouts/src/authentication/authentication.vue
#	packages/effects/layouts/src/basic/layout.vue
#	packages/effects/layouts/src/widgets/notification/notification.vue
#	packages/effects/layouts/src/widgets/notification/types.ts
#	packages/icons/src/iconify/index.ts
#	packages/icons/src/svg/index.ts
#	packages/stores/src/modules/user.ts
#	playground/src/adapter/component/index.ts
#	playground/src/store/auth.ts
#	pnpm-lock.yaml
#	vben-admin.code-workspace
This commit is contained in:
李琦
2026-08-10 16:55:17 +08:00
1278 changed files with 71754 additions and 19298 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/design",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -23,11 +23,16 @@
"exports": {
"./bem": {
"development": "./src/scss-bem/bem.scss",
"production": "./src/scss-bem/bem.scss",
"default": "./dist/bem.scss"
},
"./theme": {
"default": "./src/css/global.css"
},
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/design.css"
}
},
@@ -37,5 +42,8 @@
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@iconify/json": "catalog:"
}
}

View File

@@ -1,160 +1 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*,
::after,
::before {
@apply border-border;
box-sizing: border-box;
border-style: solid;
border-width: 0;
}
html {
@apply text-foreground bg-background font-sans text-[100%];
font-variation-settings: normal;
line-height: 1.15;
text-size-adjust: 100%;
font-synthesis-weight: none;
scroll-behavior: smooth;
text-rendering: optimizelegibility;
-webkit-tap-highlight-color: transparent;
/* -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; */
}
#app,
body,
html {
@apply size-full;
/* scrollbar-gutter: stable; */
}
body {
min-height: 100vh;
/* pointer-events: auto !important; */
/* overflow: overlay; */
/* -webkit-font-smoothing: antialiased; */
/* -moz-osx-font-smoothing: grayscale; */
}
a,
a:active,
a:hover,
a:link,
a:visited {
@apply no-underline;
}
::view-transition-new(root),
::view-transition-old(root) {
@apply animate-none mix-blend-normal;
}
::view-transition-old(root) {
@apply z-[1];
}
::view-transition-new(root) {
@apply z-[2147483646];
}
html.dark::view-transition-old(root) {
@apply z-[2147483646];
}
html.dark::view-transition-new(root) {
@apply z-[1];
}
input::placeholder,
textarea::placeholder {
@apply opacity-100;
}
/* input:-webkit-autofill {
@apply border-none;
box-shadow: 0 0 0 1000px transparent inset;
} */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
@apply m-0 appearance-none;
}
/* 只有非mac下才进行调整,mac下使用默认滚动条 */
html:not([data-platform='macOs']) {
::-webkit-scrollbar {
@apply h-[10px] w-[10px];
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-sm border-none;
}
::-webkit-scrollbar-track {
@apply rounded-sm border-none bg-transparent shadow-none;
}
::-webkit-scrollbar-button {
@apply hidden;
}
}
}
@layer components {
.flex-center {
@apply flex items-center justify-center;
}
.flex-col-center {
@apply flex flex-col items-center justify-center;
}
.outline-box {
@apply outline-border relative cursor-pointer rounded-md p-1 outline outline-1;
}
.outline-box::after {
@apply absolute left-1/2 top-1/2 z-20 h-0 w-[1px] rounded-sm opacity-0 outline outline-2 outline-transparent transition-all duration-300 content-[""];
}
.outline-box.outline-box-active {
@apply outline-primary outline outline-2;
}
.outline-box.outline-box-active::after {
display: none;
}
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary left-0 top-0 h-full w-full p-1 opacity-100;
}
.vben-link {
@apply text-primary hover:text-primary-hover active:text-primary-active cursor-pointer;
}
.card-box {
@apply bg-card text-card-foreground border-border rounded-xl border;
}
}
html.invert-mode {
@apply invert;
}
html.grayscale-mode {
@apply grayscale;
}
@import '@vben/tailwind-config/theme';

View File

@@ -1,15 +1,17 @@
@reference "@vben/tailwind-config/theme";
/* Make clicks pass-through */
#nprogress {
@apply pointer-events-none;
}
#nprogress .bar {
@apply bg-primary fixed left-0 top-0 z-[1031] h-[2px] w-full;
@apply bg-primary fixed top-0 left-0 z-1031 h-0.5 w-full;
}
/* Fancy blur effect */
#nprogress .peg {
@apply absolute right-0 block h-full w-[100px];
@apply absolute right-0 block h-full w-25;
box-shadow:
0 0 10px hsl(var(--primary)),
@@ -20,11 +22,11 @@
/* Remove these to get rid of the spinner */
#nprogress .spinner {
@apply fixed right-4 top-4 z-[1031] block;
@apply fixed top-4 right-4 z-1031 block;
}
#nprogress .spinner-icon {
@apply border-t-primary border-l-primary size-4 rounded-full border-[2px] border-solid border-transparent;
@apply border-t-primary border-l-primary size-4 rounded-full border-2 border-solid border-transparent;
animation: nprogress-spinner 400ms linear infinite;
}

View File

@@ -80,7 +80,9 @@
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
transition:
opacity 0.3s,
transform 0.3s;
}
.fade-slide-enter-from {

View File

@@ -1,5 +1,5 @@
.side-content {
animation-duration: 0.2s;
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -37,7 +37,7 @@
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-10px);
transform: translateY(50px);
}
to {
@@ -49,7 +49,7 @@
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(-10px);
transform: translateX(-50px);
}
to {
@@ -61,7 +61,7 @@
@keyframes slide-right {
from {
opacity: 0;
transform: translateX(-10px);
transform: translateX(50px);
}
to {
@@ -73,7 +73,7 @@
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(-50px);
}
to {
@@ -81,3 +81,21 @@
transform: translateY(0);
}
}
.z-popup {
z-index: var(--popup-z-index);
}
@keyframes shrink {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}

View File

@@ -15,7 +15,11 @@
--card-foreground: 210 40% 98%;
/* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */
--popover: 222.82deg 8.43% 12.27%;
/* --popover: 222.82deg 8.43% 12.27%; */
/* 弹出层的背景色与主题区域背景色太过接近 */
--popover: 0 0% 14.2%;
--popover-foreground: 210 40% 98%;
/* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */

View File

@@ -1,7 +1,10 @@
:root {
--font-family: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto,
'Helvetica Neue', arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/** 弹出层的基础层级 **/
--popup-z-index: 2000;
--font-family:
-apple-system, blinkmacsystemfont, 'Segoe UI', roboto, 'Helvetica Neue',
arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/* Default background color of <body />...etc */
--background: 0 0% 100%;
@@ -90,6 +93,7 @@
/* 基本文字大小 */
--font-size-base: 16px;
--menu-font-size: calc(var(--font-size-base) * 0.875);
/* =============component & UI============= */
@@ -105,6 +109,90 @@
color-scheme: light;
}
.light,
.light[data-theme='custom'],
.light[data-theme='default'] {
/* Default background color of <body />...etc */
--background: 0 0% 100%;
/* 主体区域背景色 */
--background-deep: 216 20.11% 95.47%;
--foreground: 210 6% 21%;
/* Background color for <Card /> */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
/* 主题颜色 */
--primary-foreground: 0 0% 98%;
/* Used for destructive actions such as <Button variant="destructive"> */
--destructive-foreground: 0 0% 98%;
/* Used for success actions such as <message> */
--info-foreground: 220, 4%, 58%;
--success-foreground: 0 0% 98%;
/* Used for warning actions such as <message> */
--warning-foreground: 0 0% 98%;
/* Secondary colors for <Button /> */
--secondary: 240 5% 96%;
--secondary-foreground: 240 6% 10%;
/* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */
--accent: 240 5% 96%;
--accent-dark: 216 14% 93%;
--accent-darker: 216 11% 91%;
--accent-lighter: 240 0% 98%;
--accent-hover: 200deg 10% 90%;
--accent-foreground: 240 6% 10%;
/* Darker color */
--heavy: 192deg 9.43% 89.61%;
--heavy-foreground: var(--accent-foreground);
/* Default border color */
--border: 240 5.9% 90%;
/* Border color for inputs such as <Input />, <Select />, <Textarea /> */
--input: 240deg 5.88% 90%;
--input-placeholder: 217 10.6% 65%;
--input-background: 0 0% 100%;
/* Used for focus ring */
--ring: 222.2 84% 4.9%;
/* ============= custom ============= */
/* 遮罩颜色 */
--overlay: 0 0% 0% / 45%;
--overlay-content: 0 0% 95% / 45%;
/* =============component & UI============= */
/* menu */
--sidebar: 0 0% 100%;
--sidebar-deep: 0 0% 100%;
--menu: var(--sidebar);
/* header */
--header: 0 0% 100%;
accent-color: var(--primary);
color-scheme: light;
}
.light[data-theme='violet'],
[data-theme='violet'] .light,
[data-theme='violet'] {
/* --background: 0 0% 100%; */
--foreground: 224 71.4% 4.1%;
@@ -126,6 +214,8 @@
--ring: 262.1 83.3% 57.8%;
}
.light[data-theme='pink'],
[data-theme='pink'] .light,
[data-theme='pink'] {
/* --background: 0 0% 100%; */
--foreground: 240 10% 3.9%;
@@ -147,6 +237,8 @@
--ring: 346.8 77.2% 49.8%;
}
.light[data-theme='rose'],
[data-theme='rose'] .light,
[data-theme='rose'] {
/* --background: 0 0% 100%; */
--foreground: 240 10% 3.9%;
@@ -168,6 +260,8 @@
--ring: 346.8 77.2% 49.8%;
}
.light[data-theme='sky-blue'],
[data-theme='sky-blue'] .light,
[data-theme='sky-blue'] {
/* --background: 0 0% 100%; */
--foreground: 222.2 84% 4.9%;
@@ -189,6 +283,8 @@
--ring: 221.2 83.2% 53.3%;
}
.light[data-theme='deep-blue'],
[data-theme='deep-blue'] .light,
[data-theme='deep-blue'] {
/* --background: 0 0% 100%; */
--foreground: 222.2 84% 4.9%;
@@ -210,6 +306,8 @@
--ring: 221.2 83.2% 53.3%;
}
.light[data-theme='green'],
[data-theme='green'] .light,
[data-theme='green'] {
/* --background: 0 0% 100%; */
--foreground: 240 10% 3.9%;
@@ -231,6 +329,8 @@
--ring: 142.1 76.2% 36.3%;
}
.light[data-theme='deep-green'],
[data-theme='deep-green'] .light,
[data-theme='deep-green'] {
/* --background: 0 0% 100%; */
--foreground: 240 10% 3.9%;
@@ -252,6 +352,8 @@
--ring: 142.1 76.2% 36.3%;
}
.light[data-theme='orange'],
[data-theme='orange'] .light,
[data-theme='orange'] {
/* --background: 0 0% 100%; */
--foreground: 20 14.3% 4.1%;
@@ -273,6 +375,8 @@
--ring: 24.6 95% 53.1%;
}
.light[data-theme='yellow'],
[data-theme='yellow'] .light,
[data-theme='yellow'] {
/* --background: 0 0% 100%; */
--foreground: 20 14.3% 4.1%;
@@ -294,6 +398,8 @@
--ring: 20 14.3% 4.1%;
}
.light[data-theme='zinc'],
[data-theme='zinc'] .light,
[data-theme='zinc'] {
/* --background: 0 0% 100%; */
--foreground: 240 10% 3.9%;
@@ -315,6 +421,8 @@
--ring: 240 5.9% 10%;
}
.light[data-theme='neutral'],
[data-theme='neutral'] .light,
[data-theme='neutral'] {
/* --background: 0 0% 100%; */
--foreground: 0 0% 3.9%;
@@ -336,6 +444,8 @@
--ring: 0 0% 3.9%;
}
.light[data-theme='slate'],
[data-theme='slate'] .light,
[data-theme='slate'] {
/* --background: 0 0% 100%; */
--foreground: 222.2 84% 4.9%;
@@ -357,6 +467,8 @@
--ring: 222.2 84% 4.9%;
}
.light[data-theme='gray'],
[data-theme='gray'] .light,
[data-theme='gray'] {
/* --background: 0 0% 100%; */
--foreground: 224 71.4% 4.1%;

View File

@@ -1,4 +1,2 @@
import './default.css';
import './dark.css';
export {};

View File

@@ -4,5 +4,3 @@ import './css/global.css';
import './css/transition.css';
import './css/nprogress.css';
import './css/ui.css';
export {};

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { defineConfig } from '@vben/vite-config';
export default defineConfig(async () => {

View File

@@ -1,7 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/icons",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,7 +11,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild"
"build": "pnpm exec tsdown"
},
"files": [
"dist"
@@ -22,6 +22,7 @@
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
@@ -35,7 +36,7 @@
},
"dependencies": {
"@iconify/vue": "catalog:",
"lucide-vue-next": "catalog:",
"@lucide/vue": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -1,60 +1,95 @@
export {
TextAlignCenter as AlignCenter,
TextAlignStart as AlignLeft,
TextAlignEnd as AlignRight,
ArrowDown,
ArrowLeft,
ArrowLeftFromLine as MdiMenuOpen,
ArrowLeftToLine,
ArrowRightFromLine as MdiMenuClose,
ArrowRightLeft,
ArrowRightToLine,
ArrowUp,
ArrowUpToLine,
Bell,
Bold,
BookOpenText,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsDown,
ChevronsLeft,
ChevronsRight,
Circle,
CircleAlert,
CircleCheckBig,
CircleHelp,
CircleX,
Copy,
CornerDownLeft,
Download,
Ellipsis,
Eraser,
Expand,
ExternalLink,
Eye,
EyeOff,
FoldHorizontal,
Fullscreen,
Github,
Grid,
Grip,
GripVertical,
Heading1,
Heading2,
Highlighter,
Menu as IconDefault,
ImagePlus,
Inbox,
Info,
InspectionPanel,
Italic,
Languages,
LayoutGrid,
Link2,
List,
ListOrdered,
LoaderCircle,
LockKeyhole,
LogOut,
MailCheck,
Maximize,
Menu as IconDefault,
ArrowRightFromLine as MdiMenuClose,
ArrowLeftFromLine as MdiMenuOpen,
Menu,
MessageSquareCode,
Minimize,
Minimize2,
MoonStar,
Paintbrush,
Palette,
PanelLeft,
PanelRight,
Pin,
PinOff,
Plus,
Redo2,
RemoveFormatting,
RotateCw,
Search,
SearchX,
Settings,
Shrink,
Square,
SquareCheckBig,
SquareCode,
SquareMinus,
Strikethrough,
Sun,
SunMoon,
SwatchBook,
TextQuote,
Underline,
Undo2,
Unlink2,
UserRoundPen,
X,
} from 'lucide-vue-next';
} from '@lucide/vue';

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
}),
});

View File

@@ -1,14 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
'src/store',
'src/constants/index',
'src/utils/index',
'src/color/index',
'src/cache/index',
'src/global-state',
],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/shared",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,8 +11,8 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
"build": "pnpm exec tsdown",
"stub": "pnpm run build"
},
"files": [
"dist"
@@ -22,31 +22,37 @@
"./constants": {
"types": "./src/constants/index.ts",
"development": "./src/constants/index.ts",
"production": "./src/constants/index.ts",
"default": "./dist/constants/index.mjs"
},
"./utils": {
"types": "./src/utils/index.ts",
"development": "./src/utils/index.ts",
"production": "./src/utils/index.ts",
"default": "./dist/utils/index.mjs"
},
"./color": {
"types": "./src/color/index.ts",
"development": "./src/color/index.ts",
"production": "./src/color/index.ts",
"default": "./dist/color/index.mjs"
},
"./cache": {
"types": "./src/cache/index.ts",
"development": "./src/cache/index.ts",
"production": "./src/cache/index.ts",
"default": "./dist/cache/index.mjs"
},
"./store": {
"types": "./src/store.ts",
"development": "./src/store.ts",
"production": "./src/store.ts",
"default": "./dist/store.mjs"
},
"./global-state": {
"types": "./dist/global-state.d.ts",
"types": "./src/global-state.ts",
"development": "./src/global-state.ts",
"production": "./src/global-state.ts",
"default": "./dist/global-state.mjs"
}
},
@@ -85,17 +91,14 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"defu": "catalog:",
"es-toolkit": "catalog:",
"lodash.clonedeep": "catalog:",
"lodash.get": "catalog:",
"lodash.isequal": "catalog:",
"nprogress": "catalog:",
"tailwind-merge": "catalog:",
"theme-colors": "catalog:"
},
"devDependencies": {
"@types/lodash.clonedeep": "catalog:",
"@types/lodash.get": "catalog:",
"@types/lodash.isequal": "catalog:",
"@types/nprogress": "catalog:"
}
}

View File

@@ -0,0 +1,418 @@
# Cache 模块
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API 接口。
## 架构设计
```shell
┌───────────────────────────────────────────────┐
│ StorageManager │
│ ┌─────────────┐ ┌───────────────────────┐ │
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │
│ └─────────────┘ └───────────────────────┘ │
├───────────────────────────────────────────────┤
│ IStorageDriver │
├──────────┬─────────────────┬──────────────────┤
│ Local │ IndexedDB │ Memory │
│ Storage │ Driver │ Driver │
│ Driver │ │ │
└──────────┴─────────────────┴──────────────────┘
```
**分层职责:**
| 层级 | 职责 |
| ---------------- | -------------------------------------------- |
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API |
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 |
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL |
---
## 快速开始
### 基本使用(默认 localStorage)
```typescript
import { StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({ prefix: 'myapp' });
// 使用 IndexedDB
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' });
// 使用 sessionStorage
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' });
// 测试环境
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' });
// 存储数据
await cache.setItem('user', { name: '张三', age: 28 });
// 读取数据
const user = await cache.getItem('user');
// => { name: '张三', age: 28 }
// 带默认值读取
const settings = await cache.getItem('settings', { theme: 'light' });
// 如果不存在,返回 { theme: 'light' }
// 删除数据
await cache.removeItem('user');
// 清除当前前缀下所有数据
await cache.clear();
```
### 带 TTL 过期
```typescript
const cache = new StorageManager({ prefix: 'session' });
// 设置 5 分钟后过期(TTL 单位为毫秒)
await cache.setItem('token', 'abc123', 5 * 60 * 1000);
// 5 分钟内可以正常读取
const token = await cache.getItem('token');
// => 'abc123'
// 5 分钟后自动返回 null(惰性删除)
const expiredToken = await cache.getItem('token');
// => null
// 主动清理所有过期项
await cache.clearExpiredItems();
```
---
## 存储驱动
### LocalStorageDriver(默认)
基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。
```typescript
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache';
// 使用 localStorage(默认)
const cache = new StorageManager({
driver: new LocalStorageDriver(),
prefix: 'app',
});
// 使用 sessionStorage
const sessionCache = new StorageManager({
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }),
prefix: 'app',
});
```
**特点:**
- 同步 API 用 async 包装,保持接口统一
- 自动处理 JSON 序列化/反序列化
- 数据损坏时自动清除并返回 null
- 存储上限约 5-10MB(视浏览器而定)
**适用场景:** 用户偏好设置、小型配置数据、Token 存储
---
### IndexedDBDriver
基于浏览器 IndexedDB,支持大容量结构化数据存储。
```typescript
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new IndexedDBDriver({
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage'
dbVersion: 1, // 数据库版本,默认 1
storeName: 'cache-store', // 对象存储名称,默认 'kv-store'
}),
prefix: 'data',
});
// 存储大量数据
await cache.setItem('table-data', largeDataArray);
// 存储二进制友好的结构(IndexedDB 原生支持)
await cache.setItem('config', {
columns: [...],
filters: [...],
pagination: {page: 1, size: 20},
});
```
**特点:**
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()`
- 存储容量大(通常数百 MB 到 GB 级别)
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型)
- 天然异步,不阻塞主线程
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据
---
### MemoryStorageDriver
基于内存 Map,数据不持久化,页面刷新即丢失。
```typescript
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test',
});
```
**特点:**
- 读写速度最快
- 无浏览器 API 依赖
- 数据随页面生命周期销毁
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存
---
## API 参考
### StorageManager
#### 构造函数
```typescript
new StorageManager(options?: StorageManagerOptions)
```
| 参数 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 |
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 |
#### 方法
| 方法 | 签名 | 说明 |
| --- | --- | --- |
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 |
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) |
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 |
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 |
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 |
---
### IStorageDriver 接口
自定义驱动需要实现此接口:
```typescript
interface IStorageDriver {
clear(): Promise<void>;
getItem<T>(key: string): Promise<null | T>;
keys(): Promise<string[]>;
removeItem(key: string): Promise<void>;
setItem<T>(key: string, value: T): Promise<void>;
}
```
---
## 高级用法
### 自定义 Driver
```typescript
import type { IStorageDriver } from '@vben-core/shared/cache';
class CookieStorageDriver implements IStorageDriver {
async getItem<T>(key: string): Promise<null | T> {
const value = getCookie(key);
return value ? JSON.parse(value) : null;
}
async setItem<T>(key: string, value: T): 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();
}
}
// 使用自定义 Driver
const cache = new StorageManager({
driver: new CookieStorageDriver(),
prefix: 'ck',
});
```
### 根据环境动态选择 Driver
```typescript
import {
IndexedDBDriver,
LocalStorageDriver,
MemoryStorageDriver,
StorageManager,
} from '@vben-core/shared/cache';
function createStorageManager(prefix: string) {
// SSR 环境使用内存驱动
if (typeof window === 'undefined') {
return new StorageManager({
driver: new MemoryStorageDriver(),
prefix,
});
}
// 大数据场景使用 IndexedDB
if (needsLargeStorage()) {
return new StorageManager({
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
prefix,
});
}
// 默认使用 localStorage
return new StorageManager({ prefix });
}
```
### 命名空间隔离
```typescript
// 不同模块使用不同前缀,互不干扰
const userCache = new StorageManager({ prefix: 'user' });
const configCache = new StorageManager({ prefix: 'config' });
await userCache.setItem('profile', { name: '张三' });
await configCache.setItem('profile', { theme: 'dark' });
// 各自独立
await userCache.getItem('profile'); // => { name: '张三' }
await configCache.getItem('profile'); // => { theme: 'dark' }
// 只清除 user 前缀的数据
await userCache.clear();
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响)
```
### 定时清理过期数据
```typescript
const cache = new StorageManager({ prefix: 'app' });
// 应用启动时清理一次
await cache.clearExpiredItems();
// 或者定时清理(每 10 分钟)
setInterval(
async () => {
await cache.clearExpiredItems();
},
10 * 60 * 1000,
);
```
---
## 数据存储格式
`StorageManager` 在 Driver 层存储的数据结构为:
```typescript
interface StorageItem<T> {
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期
value: T; // 实际业务数据
}
```
实际存储的 key 格式为:`{prefix}-{key}`
例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。
---
## 过期策略
采用**惰性删除 + 主动清理**双重策略:
| 策略 | 触发时机 | 说明 |
| --- | --- | --- |
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 |
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 |
---
## 各 Driver 对比
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
| ---------- | ------------------- | ---------------- | ------------------- |
| 持久化 | ✅ | ✅ | ❌ |
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 |
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 |
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 |
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 |
| 阻塞主线程 | 是 | 否 | 否 |
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR |
---
## 在项目中的使用
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置:
```typescript
// packages/@core/preferences/src/preferences.ts
class PreferenceManager {
private cache: StorageManager;
constructor() {
this.cache = new StorageManager();
this.state = reactive<Preferences>({ ...defaultPreferences });
}
initPreferences = async ({ namespace }) => {
// 用应用命名空间重新初始化
this.cache = new StorageManager({ prefix: namespace });
// 从缓存加载偏好设置
const cached = await this.cache.getItem<Preferences>('preferences');
// ...
};
}
```
---
## 注意事项
1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver 时无需改动调用方。
2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。
3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。
4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。
5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。
6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 `upgradeneeded` 事件中自动创建 objectStore。

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryStorageDriver } from '../memory-storage-driver';
import { StorageManager } from '../storage-manager';
describe('storageManager', () => {
@@ -7,123 +8,115 @@ describe('storageManager', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
storageManager = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test_',
});
});
it('should set and get an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
const user = storageManager.getItem('user');
it('should set and get an item', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should return default value if item does not exist', () => {
const user = storageManager.getItem('nonexistent', {
it('should return default value if item does not exist', async () => {
const user = await storageManager.getItem('nonexistent', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should remove an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.removeItem('user');
const user = storageManager.getItem('user');
it('should remove an item', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
await storageManager.removeItem('user');
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should clear all items with the prefix', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
expect(storageManager.getItem('user1')).toBeNull();
expect(storageManager.getItem('user2')).toBeNull();
it('should clear all items with the prefix', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' });
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
await storageManager.clear();
expect(await storageManager.getItem('user1')).toBeNull();
expect(await storageManager.getItem('user2')).toBeNull();
});
it('should clear expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
it('should clear expired items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not clear non-expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
it('should not clear non-expired items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle JSON parse errors gracefully', () => {
localStorage.setItem('test_user', '{ invalid JSON }');
const user = storageManager.getItem('user', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should return null for non-existent items without default value', () => {
const user = storageManager.getItem('nonexistent');
it('should return null for non-existent items without default value', async () => {
const user = await storageManager.getItem('nonexistent');
expect(user).toBeNull();
});
it('should overwrite existing items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
const user = storageManager.getItem('user');
it('should overwrite existing items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
await storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items without expiry correctly', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
it('should handle items without expiry correctly', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(5000);
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should remove expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
it('should remove expired items when accessed', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not remove non-expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
it('should not remove non-expired items when accessed', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle multiple items with different expiry times', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
it('should handle multiple items with different expiry times', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
vi.advanceTimersByTime(1500); // 快进时间
storageManager.clearExpiredItems();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
await storageManager.clearExpiredItems();
const user1 = await storageManager.getItem('user1');
const user2 = await storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items with no expiry', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
it('should handle items with no expiry', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(10_000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should clear all items correctly', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
it('should clear all items correctly', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' });
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
await storageManager.clear();
const user1 = await storageManager.getItem('user1');
const user2 = await storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toBeNull();
});

View File

@@ -1 +1,5 @@
export * from './indexeddb-driver';
export * from './local-storage-driver';
export * from './memory-storage-driver';
export * from './storage-manager';
export type * from './types';

View File

@@ -0,0 +1,137 @@
import type { IStorageDriver } from './types';
interface IndexedDBDriverOptions {
/** 数据库名称 */
dbName?: string;
/** 数据库版本 */
dbVersion?: number;
/** 对象存储名称 */
storeName?: string;
}
/**
* IndexedDB 驱动
* 采用懒初始化模式,首次操作时自动打开数据库
*/
class IndexedDBDriver implements IStorageDriver {
private dbName: string;
private dbPromise: null | Promise<IDBDatabase> = null;
private dbVersion: number;
private storeName: string;
constructor({
dbName = 'vben-storage',
dbVersion = 1,
storeName = 'kv-store',
}: IndexedDBDriverOptions = {}) {
this.dbName = dbName;
this.dbVersion = dbVersion;
this.storeName = storeName;
}
async clear(): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.clear();
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
async getItem<T>(key: string): Promise<null | T> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.get(key);
request.addEventListener('success', () =>
resolve(request.result ?? null),
);
request.addEventListener('error', () => reject(request.error));
});
}
async keys(): Promise<string[]> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.getAllKeys();
request.addEventListener('success', () =>
resolve(request.result.map(String)),
);
request.addEventListener('error', () => reject(request.error));
});
}
async removeItem(key: string): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.delete(key);
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
async setItem(key: string, value: unknown): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.put(value, key);
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
/**
* 懒初始化:首次调用时打开数据库,后续复用同一个 Promise
*/
private getDB(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise = this.openDB().catch((error) => {
// allow retry on next call
this.dbPromise = null;
throw error;
});
}
return this.dbPromise;
}
private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.addEventListener('upgradeneeded', () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
}
export { IndexedDBDriver };
export type { IndexedDBDriverOptions };

View File

@@ -0,0 +1,70 @@
import type { IStorageDriver } from './types';
type StorageType = 'localStorage' | 'sessionStorage';
interface LocalStorageDriverOptions {
/** 使用 localStorage 还是 sessionStorage */
storageType?: StorageType;
}
/**
* LocalStorage / SessionStorage 驱动
* 用 async 包装同步 API,保持接口统一
*/
class LocalStorageDriver implements IStorageDriver {
private storage: Storage;
constructor({
storageType = 'localStorage',
}: LocalStorageDriverOptions = {}) {
if (typeof window === 'undefined') {
throw new TypeError(
'LocalStorageDriver is not available in non-browser environments. Use MemoryStorageDriver instead.',
);
}
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
async clear(): Promise<void> {
this.storage.clear();
}
async getItem<T>(key: string): Promise<null | T> {
const raw = this.storage.getItem(key);
if (raw === null) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
// 数据损坏,清除并返回 null
this.storage.removeItem(key);
return null;
}
}
async keys(): Promise<string[]> {
const result: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key !== null) {
result.push(key);
}
}
return result;
}
async removeItem(key: string): Promise<void> {
this.storage.removeItem(key);
}
async setItem(key: string, value: unknown): Promise<void> {
this.storage.setItem(key, JSON.stringify(value));
}
}
export { LocalStorageDriver };
export type { LocalStorageDriverOptions };

View File

@@ -0,0 +1,32 @@
import type { IStorageDriver } from './types';
/**
* 内存存储驱动
* 适用于测试环境和 SSR 场景,数据不持久化
*/
class MemoryStorageDriver implements IStorageDriver {
private store = new Map<string, unknown>();
async clear(): Promise<void> {
this.store.clear();
}
async getItem<T>(key: string): Promise<null | T> {
const value = this.store.get(key);
return (value as T) ?? null;
}
async keys(): Promise<string[]> {
return [...this.store.keys()];
}
async removeItem(key: string): Promise<void> {
this.store.delete(key);
}
async setItem(key: string, value: unknown): Promise<void> {
this.store.set(key, value);
}
}
export { MemoryStorageDriver };

View File

@@ -1,62 +1,54 @@
type StorageType = 'localStorage' | 'sessionStorage';
import type {
IStorageDriver,
StorageItem,
StorageManagerOptions,
} from './types';
interface StorageManagerOptions {
prefix?: string;
storageType?: StorageType;
}
interface StorageItem<T> {
expiry?: number;
value: T;
}
import { LocalStorageDriver } from './local-storage-driver';
import { MemoryStorageDriver } from './memory-storage-driver';
/**
* 存储管理器(策略模式)
* - prefix(命名空间隔离)在此层处理
* - TTL(过期机制)在此层处理
* - Driver 只负责纯粹的 KV 存取
*/
class StorageManager {
private driver: IStorageDriver;
private prefix: string;
private storage: Storage;
constructor({
prefix = '',
storageType = 'localStorage',
}: StorageManagerOptions = {}) {
constructor({ driver, prefix = '' }: StorageManagerOptions = {}) {
this.driver = driver || this.createDefaultDriver();
this.prefix = prefix;
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
/**
* 获取完整的存储键
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
if (!this.prefix && this.driver instanceof LocalStorageDriver) {
console.warn(
'[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.',
);
}
}
/**
* 清除所有带前缀的存储项
*/
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
async clear(): Promise<void> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix));
await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key)));
}
/**
* 清除所有过期的存储项
*/
clearExpiredItems(): void {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const shortKey = key.replace(this.prefix, '');
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
async clearExpiredItems(): Promise<void> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix));
for (const fullKey of prefixedKeys) {
const raw = await this.driver.getItem<StorageItem<unknown>>(fullKey);
if (raw && raw.expiry && Date.now() > raw.expiry) {
await this.driver.removeItem(fullKey);
}
}
}
@@ -65,36 +57,47 @@ class StorageManager {
* 获取存储项
* @param key 键
* @param defaultValue 当项不存在或已过期时返回的默认值
* @returns 值,如果项已过期或解析错误则返回默认值
* @returns 值,如果项已过期则返回默认值
*/
getItem<T>(key: string, defaultValue: null | T = null): null | T {
async getItem<T>(
key: string,
defaultValue: null | T = null,
): Promise<null | T> {
const fullKey = this.getFullKey(key);
const itemStr = this.storage.getItem(fullKey);
if (!itemStr) {
const raw = await this.driver.getItem<StorageItem<T>>(fullKey);
if (!raw) {
return defaultValue;
}
try {
const item: StorageItem<T> = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(fullKey);
return defaultValue;
}
return item.value;
} catch (error) {
console.error(`Error parsing item with key "${fullKey}":`, error);
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
// TTL 检查
if (raw.expiry && Date.now() > raw.expiry) {
await this.driver.removeItem(fullKey);
return defaultValue;
}
return raw.value;
}
/**
* 获取当前前缀下的所有存储键(已去除前缀部分)
*/
async keys(): Promise<string[]> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
if (!fullPrefix) return allKeys;
return allKeys
.filter((key) => key.startsWith(fullPrefix))
.map((key) => key.slice(fullPrefix.length));
}
/**
* 移除存储项
* @param key 键
*/
removeItem(key: string): void {
async removeItem(key: string): Promise<void> {
const fullKey = this.getFullKey(key);
this.storage.removeItem(fullKey);
await this.driver.removeItem(fullKey);
}
/**
@@ -103,15 +106,40 @@ class StorageManager {
* @param value 值
* @param ttl 存活时间(毫秒)
*/
setItem<T>(key: string, value: T, ttl?: number): void {
async setItem(key: string, value: unknown, ttl?: number): Promise<void> {
const fullKey = this.getFullKey(key);
const expiry = ttl ? Date.now() + ttl : undefined;
const item: StorageItem<T> = { expiry, value };
const item: StorageItem<unknown> = { expiry, value };
await this.driver.setItem(fullKey, item);
}
/**
* 根据运行环境创建默认驱动:
* - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver
* - SSR / Node 环境 → MemoryStorageDriver
*/
private createDefaultDriver(): IStorageDriver {
try {
this.storage.setItem(fullKey, JSON.stringify(item));
if (typeof window !== 'undefined' && window.localStorage) {
return new LocalStorageDriver();
}
} catch (error) {
console.error(`Error setting item with key "${fullKey}":`, error);
// localStorage access denied (e.g. Safari private mode)
console.warn(
'localStorage is not accessible, falling back to MemoryStorageDriver:',
error,
);
}
return new MemoryStorageDriver();
}
/**
* 获取完整的存储键(带前缀)
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return this.prefix ? `${this.prefix}-${key}` : key;
}
}

View File

@@ -1,17 +1,39 @@
type StorageType = 'localStorage' | 'sessionStorage';
/**
* 存储驱动接口(策略模式核心抽象)
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀
*/
interface IStorageDriver {
/** 清除所有存储项 */
clear(): Promise<void>;
interface StorageValue<T> {
data: T;
expiry: null | number;
/** 获取存储项 */
getItem<T>(key: string): Promise<null | T>;
/** 获取所有 key */
keys(): Promise<string[]>;
/** 移除存储项 */
removeItem(key: string): Promise<void>;
/** 设置存储项 */
setItem(key: string, value: unknown): Promise<void>;
}
interface IStorageCache {
clear(): void;
getItem<T>(key: string): null | T;
key(index: number): null | string;
length(): number;
removeItem(key: string): void;
setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
/**
* 带 TTL 的存储项包装结构
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
*/
interface StorageItem<T> {
expiry?: number;
value: T;
}
export type { IStorageCache, StorageType, StorageValue };
interface StorageManagerOptions {
/** 存储驱动实例 */
driver?: IStorageDriver;
/** 键前缀,用于命名空间隔离 */
prefix?: string;
}
export type { IStorageDriver, StorageItem, StorageManagerOptions };

View File

@@ -9,6 +9,8 @@ export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`;
/** 内容区域的组件ID */
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
/** layout 滚动容器ID */
export const ELEMENT_ID_LAYOUT_SCROLL = `__vben_layout_scroll`;
/**
* @zh_CN 默认命名空间

View File

@@ -19,8 +19,12 @@ export const VBEN_LOGO_URL =
*/
export const VBEN_PREVIEW_URL = 'https://www.vben.pro';
export const VBEN_ANTDV_NEXT_PREVIEW_URL = 'https://antdv-next.vben.pro';
export const VBEN_ELE_PREVIEW_URL = 'https://ele.vben.pro';
export const VBEN_NAIVE_PREVIEW_URL = 'https://naive.vben.pro';
export const VBEN_ANT_PREVIEW_URL = 'https://ant.vben.pro';
export const VBEN_TD_PREVIEW_URL = 'https://tdesign.vben.pro';

View File

@@ -0,0 +1,143 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
formatDate,
formatDateTime,
getCurrentTimezone,
getSystemTimezone,
isDate,
isDayjsObject,
setCurrentTimezone,
} from '../date';
dayjs.extend(utc);
dayjs.extend(timezone);
describe('dateUtils', () => {
const sampleISO = '2024-10-30T12:34:56Z';
const sampleTimestamp = Date.parse(sampleISO);
beforeEach(() => {
// 重置时区
dayjs.tz.setDefault();
setCurrentTimezone(); // 重置为系统默认
});
afterEach(() => {
vi.restoreAllMocks();
});
// ===============================
// formatDate
// ===============================
describe('formatDate', () => {
it('should format a valid ISO date string', () => {
const formatted = formatDate(sampleISO, 'YYYY/MM/DD');
expect(formatted).toMatch(/2024\/10\/30/);
});
it('should format a timestamp correctly', () => {
const formatted = formatDate(sampleTimestamp);
expect(formatted).toMatch(/2024-10-30/);
});
it('should format a Date object', () => {
const formatted = formatDate(new Date(sampleISO));
expect(formatted).toMatch(/2024-10-30/);
});
it('should format a dayjs object', () => {
const formatted = formatDate(dayjs(sampleISO));
expect(formatted).toMatch(/2024-10-30/);
});
it('should return original input if date is invalid', () => {
const invalid = 'not-a-date';
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const formatted = formatDate(invalid);
expect(formatted).toBe(invalid);
expect(spy).toHaveBeenCalledOnce();
});
it('should apply given format', () => {
const formatted = formatDate(sampleISO, 'YYYY-MM-DD HH:mm');
expect(formatted).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
});
// ===============================
// formatDateTime
// ===============================
describe('formatDateTime', () => {
it('should format date into full datetime', () => {
const result = formatDateTime(sampleISO);
expect(result).toMatch(/2024-10-30 \d{2}:\d{2}:\d{2}/);
});
});
// ===============================
// isDate
// ===============================
describe('isDate', () => {
it('should return true for Date instances', () => {
expect(isDate(new Date())).toBe(true);
});
it('should return false for non-Date values', () => {
expect(isDate('2024-10-30')).toBe(false);
expect(isDate(null)).toBe(false);
expect(isDate(undefined)).toBe(false);
});
});
// ===============================
// isDayjsObject
// ===============================
describe('isDayjsObject', () => {
it('should return true for dayjs objects', () => {
expect(isDayjsObject(dayjs())).toBe(true);
});
it('should return false for other values', () => {
expect(isDayjsObject(new Date())).toBe(false);
expect(isDayjsObject('string')).toBe(false);
});
});
// ===============================
// getSystemTimezone
// ===============================
describe('getSystemTimezone', () => {
it('should return a valid IANA timezone string', () => {
const tz = getSystemTimezone();
expect(typeof tz).toBe('string');
expect(tz).toMatch(/^[A-Z]+\/[A-Z_]+/i);
});
});
// ===============================
// setCurrentTimezone / getCurrentTimezone
// ===============================
describe('setCurrentTimezone & getCurrentTimezone', () => {
it('should set and retrieve the current timezone', () => {
setCurrentTimezone('Asia/Shanghai');
expect(getCurrentTimezone()).toBe('Asia/Shanghai');
});
it('should reset to system timezone when called with no args', () => {
const guessed = getSystemTimezone();
setCurrentTimezone();
expect(getCurrentTimezone()).toBe(guessed);
});
it('should update dayjs default timezone', () => {
setCurrentTimezone('America/New_York');
const d = dayjs('2024-01-01T00:00:00Z');
// 校验时区转换生效(小时变化)
expect(d.tz().format('HH')).not.toBe('00');
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { diff } from '../diff';
import { diff, diffStrict } from '../diff';
describe('diff function', () => {
it('should return an empty object when comparing identical objects', () => {
@@ -27,6 +27,12 @@ describe('diff function', () => {
expect(diff(obj1, obj2)).toEqual({ a: [1, 2, 4] });
});
it('should ignore array order changes', () => {
const obj1 = { a: [1, 2, 3] };
const obj2 = { a: [3, 2, 1] };
expect(diff(obj1, obj2)).toEqual(undefined);
});
it('should handle added keys', () => {
const obj1 = { a: 1 };
const obj2 = { a: 1, b: 2 };
@@ -51,3 +57,31 @@ describe('diff function', () => {
expect(diff(obj1, obj2)).toEqual({ a: 1 });
});
});
describe('diffStrict function', () => {
it('should return undefined when comparing identical objects', () => {
const obj1 = { a: 1, b: { c: 2 }, d: [1, 2, 3] };
const obj2 = { a: 1, b: { c: 2 }, d: [1, 2, 3] };
expect(diffStrict(obj1, obj2)).toEqual(undefined);
});
it('should detect array order changes', () => {
const obj1 = { a: ['search', 'theme', 'logout'] };
const obj2 = { a: ['logout', 'theme', 'search'] };
expect(diffStrict(obj1, obj2)).toEqual({
a: ['logout', 'theme', 'search'],
});
});
it('should detect array element changes', () => {
const obj1 = { a: [1, 2, 3] };
const obj2 = { a: [1, 2, 4] };
expect(diffStrict(obj1, obj2)).toEqual({ a: [1, 2, 4] });
});
it('should detect nested object changes', () => {
const obj1 = { a: 1, b: { c: 2, d: 4 } };
const obj2 = { a: 1, b: { c: 3, d: 4 } };
expect(diffStrict(obj1, obj2)).toEqual({ b: { c: 3 } });
});
});

View File

@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getElementVisibleRect } from '../dom';
import { ELEMENT_ID_LAYOUT_SCROLL } from '../../constants';
import {
getElementVisibleRect,
getLayoutScrollElement,
needsScrollbar,
} from '../dom';
describe('getElementVisibleRect', () => {
// 设置浏览器视口尺寸的 mock
@@ -15,6 +20,10 @@ describe('getElementVisibleRect', () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return default rect if element is undefined', () => {
expect(getElementVisibleRect()).toEqual({
bottom: 0,
@@ -116,12 +125,84 @@ describe('getElementVisibleRect', () => {
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 800,
bottom: 0,
height: 0,
left: 1100,
right: 1000,
top: 900,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
});
describe('getLayoutScrollElement', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('should return the layout scroll element', () => {
const element = document.createElement('div');
element.id = ELEMENT_ID_LAYOUT_SCROLL;
document.body.append(element);
expect(getLayoutScrollElement()).toBe(element);
});
it('should return null when the layout scroll element is missing', () => {
expect(getLayoutScrollElement()).toBeNull();
});
});
describe('needsScrollbar', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should check scrollbar state from target element', () => {
const element = document.createElement('div');
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(120);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY: 'auto',
} as CSSStyleDeclaration);
expect(needsScrollbar(element)).toBe(true);
});
it('should return false when target content does not overflow', () => {
const element = document.createElement('div');
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(100);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY: 'auto',
} as CSSStyleDeclaration);
expect(needsScrollbar(element)).toBe(false);
});
it.each(['clip', 'hidden'])(
'should ignore %s overflow targets',
(overflowY) => {
const element = document.createElement('div');
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(120);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY,
} as CSSStyleDeclaration);
expect(needsScrollbar(element)).toBe(false);
},
);
it('should fall back to document scrollbar state', () => {
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(
120,
);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY: 'auto',
} as CSSStyleDeclaration);
expect(needsScrollbar()).toBe(true);
});
});

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadScript } from '../resources';
describe('loadScript', () => {
beforeEach(() => {
// 每个测试前清空 head,保证环境干净
document.head.innerHTML = '';
});
it('should resolve when the script loads successfully', async () => {
// happy-dom v20+ auto-fires 'load' via handleDisabledFileLoadingAsSuccess
const promise = loadScript('/test-script.js');
const script = document.querySelector(
'script[src="/test-script.js"]',
) as HTMLScriptElement;
expect(script).toBeTruthy();
await expect(promise).resolves.toBeUndefined();
});
it('should not insert duplicate script and resolve immediately if already loaded', async () => {
// 先手动插入一个相同 src 的 script
const existing = document.createElement('script');
existing.src = 'bar.js';
document.head.append(existing);
// 再次调用
const promise = loadScript('bar.js');
// 立即 resolve
await expect(promise).resolves.toBeUndefined();
// head 中只保留一个
const scripts = document.head.querySelectorAll('script[src="bar.js"]');
expect(scripts).toHaveLength(1);
});
it('should reject when the script fails to load', async () => {
let capturedScript: HTMLScriptElement | null = null;
// 拦截 append,捕获 script 元素但不插入 DOM,
// 防止 happy-dom v20+ 自动触发 load 事件
const appendSpy = vi
.spyOn(document.head, 'append')
.mockImplementation((...nodes) => {
for (const node of nodes) {
if (node instanceof HTMLScriptElement) {
capturedScript = node;
}
}
});
const promise = loadScript('error.js');
appendSpy.mockRestore();
expect(capturedScript).toBeTruthy();
if (!capturedScript) {
throw new Error('Expected the captured script element to exist');
}
capturedScript.dispatchEvent(new Event('error'));
await expect(promise).rejects.toThrow('Failed to load script: error.js');
});
it('should handle multiple concurrent calls and only insert one script tag', async () => {
const p1 = loadScript('/test-script.js');
const p2 = loadScript('/test-script.js');
// happy-dom v20+ auto-fires 'load',两个 promise 都应该 resolve
await expect(p1).resolves.toBeUndefined();
await expect(p2).resolves.toBeUndefined();
// 只插入一次
const scripts = document.head.querySelectorAll(
'script[src="/test-script.js"]',
);
expect(scripts).toHaveLength(1);
});
});

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createStack, Stack } from '../stack';
describe('stack', () => {
let stack: Stack<number>;
beforeEach(() => {
stack = new Stack<number>();
});
it('push & size should work', () => {
stack.push(1, 2);
expect(stack.size).toBe(2);
});
it('peek should return top element without removing it', () => {
stack.push(1, 2);
expect(stack.peek()).toBe(2);
expect(stack.size).toBe(2);
});
it('pop should remove and return top element', () => {
stack.push(1, 2);
expect(stack.pop()).toBe(2);
expect(stack.size).toBe(1);
expect(stack.peek()).toBe(1);
});
it('pop on empty stack should return undefined', () => {
expect(stack.pop()).toBeUndefined();
expect(stack.peek()).toBeUndefined();
});
it('clear should remove all elements', () => {
stack.push(1, 2);
stack.clear();
expect(stack.size).toBe(0);
expect(stack.peek()).toBeUndefined();
});
it('toArray should return a shallow copy', () => {
stack.push(1, 2);
const arr = stack.toArray();
arr.push(3);
expect(stack.size).toBe(2);
expect(stack.toArray()).toEqual([1, 2]);
});
it('dedup should remove existing item before push', () => {
stack.push(1, 2, 1);
expect(stack.toArray()).toEqual([2, 1]);
expect(stack.size).toBe(2);
});
it('dedup = false should allow duplicate items', () => {
const s = new Stack<number>(false);
s.push(1, 1, 1);
expect(s.toArray()).toEqual([1, 1, 1]);
expect(s.size).toBe(3);
});
it('remove should delete all matching items', () => {
stack.push(1, 2, 1);
stack.remove(1);
expect(stack.toArray()).toEqual([2]);
expect(stack.size).toBe(1);
});
it('maxSize should limit stack capacity', () => {
const s = new Stack<number>(true, 3);
s.push(1, 2, 3, 4);
expect(s.toArray()).toEqual([2, 3, 4]);
expect(s.size).toBe(3);
});
it('dedup + maxSize should work together', () => {
const s = new Stack<number>(true, 3);
s.push(1, 2, 3, 2); // 去重并重新入栈
expect(s.toArray()).toEqual([1, 3, 2]);
expect(s.size).toBe(3);
});
it('createStack should create a stack instance', () => {
const s = createStack<number>(true, 2);
s.push(1, 2, 3);
expect(s.toArray()).toEqual([2, 3]);
});
});

View File

@@ -34,7 +34,9 @@ describe('stateHandler', () => {
}, 10);
// 等待过程中,期望 Promise 被 reject
await expect(handler.waitForCondition()).rejects.toThrow();
await expect(handler.waitForCondition()).rejects.toThrow(
'Condition was set to false',
);
expect(handler.isConditionTrue()).toBe(false);
});

View File

@@ -28,3 +28,21 @@ it('updateCSSVariables should update CSS variables in :root selector', () => {
updatedStyleContent?.includes('fontSize: 16px;'),
).toBe(true);
});
it('updateCSSVariables should support a custom selector', () => {
document.head.innerHTML = `<style id="tdesign-styles"></style>`;
// 使用自定义选择器(如 TDesign 的 theme-mode 选择器)更新 CSS 变量
updateCSSVariables(
{ '--td-brand-color': 'rgb(0, 82, 217)' },
'tdesign-styles',
":root[theme-mode='dark']",
);
const styleElement = document.querySelector('#tdesign-styles');
const content = styleElement?.textContent ?? '';
// 选择器与变量都应正确写入
expect(content.startsWith(":root[theme-mode='dark'] {")).toBe(true);
expect(content.includes('--td-brand-color: rgb(0, 82, 217);')).toBe(true);
});

View File

@@ -56,12 +56,6 @@ describe('bindMethods', () => {
it('should not bind getter/setter properties', () => {
class TestWithGetterSetter {
private _value: string = 'test';
constructor() {
bindMethods(this);
}
get value() {
return this._value;
}
@@ -69,6 +63,12 @@ describe('bindMethods', () => {
set value(newValue: string) {
this._value = newValue;
}
private _value: string = 'test';
constructor() {
bindMethods(this);
}
}
const instance = new TestWithGetterSetter();
@@ -138,8 +138,10 @@ describe('getNestedValue', () => {
expect(result).toBe(2);
});
it('should return the entire object if path is empty', () => {
expect(() => getNestedValue(data, '')()).toThrow();
it('should throw if path is empty', () => {
expect(() => getNestedValue(data, '')).toThrow(
'Path must be a non-empty string',
);
});
it('should handle paths with array indexes', () => {

View File

@@ -1,4 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {

View File

@@ -1,19 +1,41 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
export function formatDate(time: number | string, format = 'YYYY-MM-DD') {
dayjs.extend(utc);
dayjs.extend(timezone);
type FormatDate = Date | dayjs.Dayjs | number | string;
type Format =
| 'HH'
| 'HH:mm'
| 'HH:mm:ss'
| 'YYYY'
| 'YYYY-MM'
| 'YYYY-MM-DD'
| 'YYYY-MM-DD HH'
| 'YYYY-MM-DD HH:mm'
| 'YYYY-MM-DD HH:mm:ss'
| (string & {});
export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
if (time === undefined || time === null || time === '') {
return '';
}
try {
const date = dayjs(time);
const date = dayjs.isDayjs(time) ? time : dayjs(time);
if (!date.isValid()) {
throw new Error('Invalid date');
}
return date.format(format);
return date.tz().format(format);
} catch (error) {
console.error(`Error formatting date: ${error}`);
return time;
return String(time ?? '');
}
}
export function formatDateTime(time: number | string) {
export function formatDateTime(time?: FormatDate) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}
@@ -24,3 +46,33 @@ export function isDate(value: any): value is Date {
export function isDayjsObject(value: any): value is dayjs.Dayjs {
return dayjs.isDayjs(value);
}
/**
* 获取当前时区
* @returns 当前时区
*/
export const getSystemTimezone = () => {
return dayjs.tz.guess();
};
/**
* 自定义设置的时区
*/
let currentTimezone = getSystemTimezone();
/**
* 设置默认时区
* @param timezone
*/
export const setCurrentTimezone = (timezone?: string) => {
currentTimezone = timezone || getSystemTimezone();
dayjs.tz.setDefault(currentTimezone);
};
/**
* 获取设置的时区
* @returns 设置的时区
*/
export const getCurrentTimezone = () => {
return currentTimezone;
};

View File

@@ -1,6 +1,6 @@
// type Diff<T = any> = T;
// 比较两个数组是否相等
// 比较两个数组是否相等(忽略顺序)
function arraysEqual<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false;
@@ -18,6 +18,11 @@ function arraysEqual<T>(a: T[], b: T[]): boolean {
return true;
}
// 比较两个数组是否相等(顺序敏感)
function arraysStrictEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
// 深度对比两个值
// function deepEqual<T>(oldVal: T, newVal: T): boolean {
// if (
@@ -59,38 +64,51 @@ type DiffResult<T> = Partial<{
[K in keyof T]: T[K] extends object ? DiffResult<T[K]> : T[K];
}>;
function diff<T extends Record<string, any>>(obj1: T, obj2: T): DiffResult<T> {
function findDifferences(o1: any, o2: any): any {
if (Array.isArray(o1) && Array.isArray(o2)) {
if (!arraysEqual(o1, o2)) {
return o2;
}
return undefined;
}
type ArrayComparator = (a: any[], b: any[]) => boolean;
if (
typeof o1 === 'object' &&
typeof o2 === 'object' &&
o1 !== null &&
o2 !== null
) {
const diffResult: any = {};
const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]);
keys.forEach((key) => {
const valueDiff = findDifferences(o1[key], o2[key]);
if (valueDiff !== undefined) {
diffResult[key] = valueDiff;
function createDiff(arrayEquals: ArrayComparator) {
return function <T extends Record<string, any>>(
obj1: T,
obj2: T,
): DiffResult<T> {
function findDifferences(o1: any, o2: any): any {
if (Array.isArray(o1) && Array.isArray(o2)) {
if (!arrayEquals(o1, o2)) {
return o2;
}
});
return undefined;
}
return Object.keys(diffResult).length > 0 ? diffResult : undefined;
if (
typeof o1 === 'object' &&
typeof o2 === 'object' &&
o1 !== null &&
o2 !== null
) {
const diffResult: any = {};
const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]);
keys.forEach((key) => {
const valueDiff = findDifferences(o1[key], o2[key]);
if (valueDiff !== undefined) {
diffResult[key] = valueDiff;
}
});
return Object.keys(diffResult).length > 0 ? diffResult : undefined;
}
return o1 === o2 ? undefined : o2;
}
return o1 === o2 ? undefined : o2;
}
return findDifferences(obj1, obj2);
return findDifferences(obj1, obj2);
};
}
export { arraysEqual, diff };
// 数组比较(不含顺序)
const diff = createDiff(arraysEqual);
// 数组比较(含顺序)
const diffStrict = createDiff(arraysStrictEqual);
export { arraysEqual, arraysStrictEqual, diff, diffStrict };

View File

@@ -1,3 +1,5 @@
import { ELEMENT_ID_LAYOUT_SCROLL } from '../constants/globals';
export interface VisibleDomRect {
bottom: number;
height: number;
@@ -41,6 +43,18 @@ export function getElementVisibleRect(
const left = Math.max(rect.left, 0);
const right = Math.min(rect.right, viewWidth);
// 如果元素完全不可见,则返回一个空的矩形
if (top >= viewHeight || bottom <= 0 || left >= viewWidth || right <= 0) {
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
}
return {
bottom,
height: Math.max(0, bottom - top),
@@ -70,7 +84,24 @@ export function getScrollbarWidth() {
return scrollbarWidth;
}
export function needsScrollbar() {
export function getLayoutScrollElement() {
return document.querySelector<HTMLElement>(`#${ELEMENT_ID_LAYOUT_SCROLL}`);
}
function elementNeedsScrollbar(element: HTMLElement) {
const overflowY = window.getComputedStyle(element).overflowY;
if (overflowY === 'hidden' || overflowY === 'clip') {
return false;
}
return element.scrollHeight > element.clientHeight;
}
export function needsScrollbar(target?: HTMLElement | null) {
if (target) {
return elementNeedsScrollbar(target);
}
const doc = document.documentElement;
const body = document.body;

View File

@@ -31,6 +31,7 @@ export async function downloadFileFromUrl({
if (isChrome || isSafari) {
triggerDownload(source, resolveFileName(source, fileName));
return;
}
if (!source.includes('?')) {
source += '?download';

View File

@@ -7,6 +7,8 @@ export * from './inference';
export * from './letter';
export * from './merge';
export * from './nprogress';
export * from './resources';
export * from './stack';
export * from './state-handler';
export * from './to';
export * from './tree';
@@ -14,6 +16,6 @@ export * from './unique';
export * from './update-css-variables';
export * from './util';
export * from './window';
export { debounce, get, isEqual, set } from 'es-toolkit/compat';
// export { cloneDeep } from 'es-toolkit/object';
export { default as cloneDeep } from 'lodash.clonedeep';
export { default as get } from 'lodash.get';
export { default as isEqual } from 'lodash.isequal';

View File

@@ -0,0 +1,21 @@
/**
* 加载js文件
* @param src js文件地址
*/
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
// 如果已经加载过,直接 resolve
return resolve();
}
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => resolve());
script.addEventListener('error', () =>
reject(new Error(`Failed to load script: ${src}`)),
);
document.head.append(script);
});
}
export { loadScript };

View File

@@ -0,0 +1,103 @@
/**
* @zh_CN 栈数据结构
*/
export class Stack<T> {
/**
* @zh_CN 栈内元素数量
*/
get size() {
return this.items.length;
}
/**
* @zh_CN 是否去重
*/
private readonly dedup: boolean;
/**
* @zh_CN 栈内元素
*/
private items: T[] = [];
/**
* @zh_CN 栈的最大容量
*/
private readonly maxSize?: number;
constructor(dedup = true, maxSize?: number) {
this.maxSize = maxSize;
this.dedup = dedup;
}
/**
* @zh_CN 清空栈内元素
*/
clear() {
this.items.length = 0;
}
/**
* @zh_CN 查看栈顶元素
* @returns 栈顶元素
*/
peek(): T | undefined {
return this.items[this.items.length - 1];
}
/**
* @zh_CN 出栈
* @returns 栈顶元素
*/
pop(): T | undefined {
return this.items.pop();
}
/**
* @zh_CN 入栈
* @param items 要入栈的元素
*/
push(...items: T[]) {
items.forEach((item) => {
// 去重
if (this.dedup) {
const index = this.items.indexOf(item);
if (index !== -1) {
this.items.splice(index, 1);
}
}
this.items.push(item);
if (this.maxSize && this.items.length > this.maxSize) {
this.items.splice(0, this.items.length - this.maxSize);
}
});
}
/**
* @zh_CN 移除栈内元素
* @param itemList 要移除的元素列表
*/
remove(...itemList: T[]) {
this.items = this.items.filter((i) => !itemList.includes(i));
}
/**
* @zh_CN 保留栈内元素
* @param itemList 要保留的元素列表
*/
retain(itemList: T[]) {
this.items = this.items.filter((i) => itemList.includes(i));
}
/**
* @zh_CN 转换为数组
* @returns 栈内元素数组
*/
toArray(): T[] {
return [...this.items];
}
}
/**
* @zh_CN 创建一个栈实例
* @param dedup 是否去重
* @param maxSize 栈的最大容量
* @returns 栈实例
*/
export const createStack = <T>(dedup = true, maxSize?: number) =>
new Stack<T>(dedup, maxSize);

View File

@@ -1,14 +1,8 @@
export class StateHandler {
private condition: boolean = false;
private rejectCondition: (() => void) | null = null;
private rejectCondition: ((reason?: Error) => void) | null = null;
private resolveCondition: (() => void) | null = null;
// 清理 resolve/reject 函数
private clearPromises() {
this.resolveCondition = null;
this.rejectCondition = null;
}
isConditionTrue(): boolean {
return this.condition;
}
@@ -22,7 +16,7 @@ export class StateHandler {
setConditionFalse() {
this.condition = false;
if (this.rejectCondition) {
this.rejectCondition();
this.rejectCondition(new Error('Condition was set to false'));
this.clearPromises();
}
}
@@ -47,4 +41,10 @@ export class StateHandler {
}
});
}
// 清理 resolve/reject 函数
private clearPromises() {
this.resolveCondition = null;
this.rejectCondition = null;
}
}

View File

@@ -79,19 +79,53 @@ function filterTree<T extends Record<string, any>>(
*/
function mapTree<T, V extends Record<string, any>>(
tree: T[],
mapper: (node: T) => V,
mapper: (node: T, parent: null | V) => V,
options?: TreeConfigOptions,
parent: null | V = null,
): V[] {
const { childProps } = options || {
childProps: 'children',
};
return tree.map((node) => {
const mapperNode: Record<string, any> = mapper(node);
const mapperNode: Record<string, any> = mapper(node, parent as null | V);
if (mapperNode[childProps]) {
mapperNode[childProps] = mapTree(mapperNode[childProps], mapper, options);
mapperNode[childProps] = mapTree(
mapperNode[childProps],
mapper,
options,
mapperNode as V,
);
}
return mapperNode as V;
});
}
export { filterTree, mapTree, traverseTreeValues };
/**
* 对树形结构数据进行递归排序
* @param treeData - 树形数据数组
* @param sortFunction - 排序函数,用于定义排序规则
* @param options - 配置选项,包括子节点属性名
* @returns 排序后的树形数据
*/
function sortTree<T extends Record<string, any>>(
treeData: T[],
sortFunction: (a: T, b: T) => number,
options?: TreeConfigOptions,
): T[] {
const { childProps } = options || {
childProps: 'children',
};
return treeData.toSorted(sortFunction).map((item) => {
const children = item[childProps];
if (children && Array.isArray(children) && children.length > 0) {
return {
...item,
[childProps]: sortTree(children, sortFunction, options),
};
}
return item;
});
}
export { filterTree, mapTree, sortTree, traverseTreeValues };

View File

@@ -1,10 +1,15 @@
/**
* 更新 CSS 变量的函数
* @param variables 要更新的 CSS 变量与其新值的映射
* @param id 内联样式表的 id,便于复用与覆盖
* @param selector CSS 变量挂载的选择器,默认 `:root`。
* 对于像 TDesign 这种将变量定义在 `:root[theme-mode='dark']` 等更高优先级选择器下的组件库,
* 需要传入相同(或更高)优先级的选择器才能正确覆盖。
*/
function updateCSSVariables(
variables: { [key: string]: string },
id = '__vben-styles__',
selector = ':root',
): void {
// 获取或创建内联样式表元素
const styleElement =
@@ -13,7 +18,7 @@ function updateCSSVariables(
styleElement.id = id;
// 构建要更新的 CSS 变量的样式文本
let cssText = ':root {';
let cssText = `${selector} {`;
for (const key in variables) {
if (Object.prototype.hasOwnProperty.call(variables, key)) {
cssText += `${key}: ${variables[key]};`;

View File

@@ -30,7 +30,7 @@ function openWindow(url: string, options: OpenWindowOptions = {}): void {
function openRouteInNewWindow(path: string) {
const { hash, origin } = location;
const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash ? '/#' : ''}${fullPath}`;
const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' });
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
clean: true,
dts: true,
entry: {
'cache/index': 'src/cache/index.ts',
'color/index': 'src/color/index.ts',
'constants/index': 'src/constants/index.ts',
'global-state': 'src/global-state.ts',
store: 'src/store.ts',
'utils/index': 'src/utils/index.ts',
},
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
}),
});

View File

@@ -1,7 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/typings",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,10 +11,11 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild"
"build": "pnpm exec tsdown"
},
"files": [
"dist"
"dist",
"vue-router.d.ts"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -23,6 +24,7 @@
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./vue-router": {

View File

@@ -1,6 +1,8 @@
type LayoutType =
| 'full-content'
| 'header-mixed-nav'
| 'header-nav'
| 'header-sidebar-nav'
| 'mixed-nav'
| 'sidebar-mixed-nav'
| 'sidebar-nav';
@@ -9,11 +11,18 @@ type ThemeModeType = 'auto' | 'dark' | 'light';
/**
* 偏好设置按钮位置
* fixed 固定在右侧
* auto 自动(按布局上下文在 header/fixed 间切换)
* fixed 固定在屏幕右边缘
* header 顶栏
* auto 自动
* user-dropdown 用户的下拉弹出框中
* none 不显示
*/
type PreferencesButtonPositionType = 'auto' | 'fixed' | 'header';
type PreferencesButtonPositionType =
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
type BuiltinThemeType =
| 'custom'
@@ -38,6 +47,7 @@ type BuiltinThemeType =
type ContentCompactType = 'compact' | 'wide';
type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static';
type LayoutHeaderMenuAlignType = 'center' | 'end' | 'start';
/**
* 登录过期模式
@@ -57,8 +67,9 @@ type BreadcrumbStyleType = 'background' | 'normal';
* 权限模式
* backend 后端权限模式
* frontend 前端权限模式
* mixed 混合权限模式
*/
type AccessModeType = 'backend' | 'frontend';
type AccessModeType = 'backend' | 'frontend' | 'mixed';
/**
* 导航风格
@@ -89,12 +100,22 @@ type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
*/
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
/**
* 时区选项
*/
interface TimezoneOption {
label: string;
offset: number;
timezone: string;
}
export type {
AccessModeType,
AuthPageLayoutType,
BreadcrumbStyleType,
BuiltinThemeType,
ContentCompactType,
LayoutHeaderMenuAlignType,
LayoutHeaderModeType,
LayoutType,
LoginExpiredModeType,
@@ -103,4 +124,5 @@ export type {
PreferencesButtonPositionType,
TabsStyleType,
ThemeModeType,
TimezoneOption,
};

View File

@@ -8,6 +8,7 @@ type SelectOption = BasicOption;
type TabOption = BasicOption;
interface BasicUserInfo {
[key: string]: any;
/**
* 头像
*/
@@ -30,6 +31,12 @@ interface BasicUserInfo {
username: string;
}
type ClassType = Array<object | string> | object | string;
type ClassType =
| Array<ClassType>
| boolean
| null
| object
| string
| undefined;
export type { BasicOption, BasicUserInfo, ClassType, SelectOption, TabOption };

View File

@@ -1,20 +1,38 @@
import { type ComputedRef, type MaybeRef } from 'vue';
import type { ComputedRef, MaybeRef } from 'vue';
/**
* 类型级递归中增加深度计数
*/
type Increment<A extends unknown[]> = [...A, unknown];
/**
* 深层递归所有属性为可选
*/
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
type DeepPartial<
T,
D extends number = 10,
C extends unknown[] = [],
> = C['length'] extends D
? T
: T extends object
? {
[P in keyof T]?: DeepPartial<T[P], D, Increment<C>>;
}
: T;
/**
* 深层递归所有属性为只读
*/
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
type DeepReadonly<
T,
D extends number = 10,
C extends unknown[] = [],
> = C['length'] extends D
? T
: T extends object
? {
readonly [P in keyof T]: DeepReadonly<T[P], D, Increment<C>>;
}
: T;
/**
* 任意类型的异步函数
@@ -109,6 +127,8 @@ type MergeAll<
type EmitType = (name: Name, ...args: any[]) => void;
type MaybePromise<T> = Promise<T> | T;
export type {
AnyFunction,
AnyNormalFunction,
@@ -118,6 +138,7 @@ export type {
EmitType,
IntervalHandle,
MaybeComputedRef,
MaybePromise,
MaybeReadonlyRef,
Merge,
MergeAll,

View File

@@ -1,15 +1,16 @@
import type { Component } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import type { Component } from 'vue';
import type { Recordable } from './helper';
/**
* 扩展路由原始对象
*/
type ExRouteRecordRaw = {
type ExRouteRecordRaw = RouteRecordRaw & {
parent?: string;
parents?: string[];
path?: any;
} & RouteRecordRaw;
};
interface MenuRecordBadgeRaw {
/**
@@ -67,6 +68,10 @@ interface MenuRecordRaw extends MenuRecordBadgeRaw {
* 菜单路径,唯一,可当作key
*/
path: string;
/**
* 菜单参数
*/
query?: Recordable<any>;
/**
* 是否显示菜单
* @default true

View File

@@ -1,3 +1,8 @@
import type { RouteLocationNormalized } from 'vue-router';
export type TabDefinition = RouteLocationNormalized;
export interface TabDefinition extends RouteLocationNormalized {
/**
* 标签页的key
*/
key?: string;
}

View File

@@ -1,6 +1,5 @@
import type { Router, RouteRecordRaw } from 'vue-router';
import type { Component } from 'vue';
import type { Router, RouteRecordRaw } from 'vue-router';
interface RouteMeta {
/**
@@ -44,6 +43,14 @@ interface RouteMeta {
| 'success'
| 'warning'
| string;
/**
* 路由对应dom是否缓存起来
*/
domCached?: boolean;
/**
* 路由的完整路径作为key(默认true)
*/
fullPathKey?: boolean;
/**
* 当前路由的子级在菜单中不展现
* @default false
@@ -98,6 +105,10 @@ interface RouteMeta {
* 菜单可以看到,但是访问会被重定向到403
*/
menuVisibleWithForbidden?: boolean;
/**
* 不使用基础布局(仅在顶级生效)
*/
noBasicLayout?: boolean;
/**
* 在新窗口打开
*/
@@ -117,10 +128,13 @@ interface RouteMeta {
}
// 定义递归类型以将 RouteRecordRaw 的 component 属性更改为 string
type RouteRecordStringComponent<T = string> = {
type RouteRecordStringComponent<T = string> = Omit<
RouteRecordRaw,
'children' | 'component'
> & {
children?: RouteRecordStringComponent<T>[];
component: T;
} & Omit<RouteRecordRaw, 'children' | 'component'>;
};
type ComponentRecordType = Record<string, () => Promise<Component>>;

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
}),
});

View File

@@ -1,9 +1,8 @@
/* eslint-disable no-restricted-imports */
import type { RouteMeta as IRouteMeta } from '@vben-core/typings';
import type { RouteMeta as IRouteMeta } from './dist/index.d.mts';
import 'vue-router';
declare module 'vue-router' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
// oxlint-disable-next-line typescript/no-empty-object-type
interface RouteMeta extends IRouteMeta {}
}

View File

@@ -1,7 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/composables",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,7 +11,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild"
"build": "pnpm exec tsdown"
},
"files": [
"dist"
@@ -23,6 +23,7 @@
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
@@ -37,7 +38,7 @@
"dependencies": {
"@vben-core/shared": "workspace:*",
"@vueuse/core": "catalog:",
"radix-vue": "catalog:",
"reka-ui": "catalog:",
"sortablejs": "catalog:",
"vue": "catalog:"
},

View File

@@ -0,0 +1,129 @@
import type { App, WritableComputedRef } from 'vue';
import { createApp, nextTick } from 'vue';
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SCROLL_FIXED_CLASS, useScrollLock } from '../use-scroll-lock';
let activeApp: App | undefined;
function createScrollableElement(id?: string) {
const element = document.createElement('div');
if (id) {
element.id = id;
}
element.style.overflow = 'auto';
Object.defineProperties(element, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 120 },
});
document.body.append(element);
return element;
}
function mountScrollLock(options?: { immediate?: boolean }) {
let scrollLock: undefined | WritableComputedRef<boolean>;
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp({
setup() {
scrollLock = useScrollLock(options);
return () => null;
},
});
activeApp.mount(host);
if (!scrollLock) {
throw new Error('useScrollLock was not initialized');
}
return scrollLock;
}
async function flushMountedLock() {
await nextTick();
await nextTick();
}
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
document.body.style.cssText = '';
vi.restoreAllMocks();
});
describe('useScrollLock', () => {
it('should lock the layout scroll element first', async () => {
const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL);
element.style.setProperty('scrollbar-gutter', 'auto');
const scrollLock = mountScrollLock();
await flushMountedLock();
expect(scrollLock.value).toBe(true);
expect(element.style.overflow).toBe('hidden');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable');
expect(document.body.style.overflow).not.toBe('hidden');
activeApp?.unmount();
activeApp = undefined;
expect(element.style.overflow).toBe('auto');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('auto');
});
it('should support manual locking', async () => {
const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL);
const scrollLock = mountScrollLock({ immediate: false });
await flushMountedLock();
expect(scrollLock.value).toBe(false);
scrollLock.value = true;
expect(element.style.overflow).toBe('hidden');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable');
scrollLock.value = false;
expect(element.style.overflow).toBe('auto');
expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('');
});
it('should fall back to body and compensate fixed nodes', async () => {
document.body.style.overflow = 'auto';
Object.defineProperties(document.body, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 120 },
});
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(
120,
);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100);
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
overflowY: 'auto',
} as CSSStyleDeclaration);
const fixedNode = document.createElement('div');
fixedNode.className = SCROLL_FIXED_CLASS;
fixedNode.style.transition = 'opacity 200ms';
document.body.append(fixedNode);
const scrollLock = mountScrollLock();
await flushMountedLock();
expect(scrollLock.value).toBe(true);
expect(document.body.style.overflow).toBe('hidden');
expect(document.body.style.paddingRight).toBe('0px');
expect(fixedNode.style.paddingRight).toBe('0px');
activeApp?.unmount();
activeApp = undefined;
expect(document.body.style.overflow).toBe('auto');
expect(document.body.style.paddingRight).toBe('');
expect(fixedNode.style.paddingRight).toBe('');
});
});

View File

@@ -29,9 +29,9 @@ describe('useSortable', () => {
await initializeSortable();
// Import sortablejs to access the mocked create function
const Sortable = await import(
'sortablejs/modular/sortable.complete.esm.js'
);
const Sortable =
// @ts-expect-error - This is a dynamic import
await import('sortablejs/modular/sortable.complete.esm.js');
// Verify that Sortable.create was called with the correct parameters
expect(Sortable.default.create).toHaveBeenCalledTimes(1);

View File

@@ -10,4 +10,4 @@ export {
useForwardExpose,
useForwardProps,
useForwardPropsEmits,
} from 'radix-vue';
} from 'reka-ui';

View File

@@ -1,4 +1,7 @@
import type { CSSProperties } from 'vue';
import type { VisibleDomRect } from '@vben-core/shared/utils';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
@@ -7,10 +10,7 @@ import {
CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT,
CSS_VARIABLE_LAYOUT_HEADER_HEIGHT,
} from '@vben-core/shared/constants';
import {
getElementVisibleRect,
type VisibleDomRect,
} from '@vben-core/shared/utils';
import { getElementVisibleRect } from '@vben-core/shared/utils';
import { useCssVar, useDebounceFn } from '@vueuse/core';

View File

@@ -28,7 +28,7 @@ const _bem = (
const is: {
(name: string): string;
// eslint-disable-next-line @typescript-eslint/unified-signatures
// oxlint-disable-next-line typescript/unified-signatures
(name: string, state: boolean | undefined): string;
} = (name: string, ...args: [] | [boolean | undefined]) => {
const state = args.length > 0 ? args[0] : true;

View File

@@ -1,4 +1,5 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, getCurrentInstance, unref, useAttrs, useSlots } from 'vue';
import {

View File

@@ -1,4 +1,10 @@
import { getScrollbarWidth, needsScrollbar } from '@vben-core/shared/utils';
import { computed, nextTick, shallowRef } from 'vue';
import {
getLayoutScrollElement,
getScrollbarWidth,
needsScrollbar,
} from '@vben-core/shared/utils';
import {
useScrollLock as _useScrollLock,
@@ -8,20 +14,60 @@ import {
export const SCROLL_FIXED_CLASS = `_scroll__fixed_`;
export function useScrollLock() {
const isLocked = _useScrollLock(document.body);
const scrollbarWidth = getScrollbarWidth();
interface ScrollLockOptions {
immediate?: boolean;
}
tryOnMounted(() => {
if (!needsScrollbar()) {
function getScrollLockTarget() {
return getLayoutScrollElement() ?? document.body;
}
function getLayoutFixedNodes() {
return [...document.querySelectorAll<HTMLElement>(`.${SCROLL_FIXED_CLASS}`)];
}
export function useScrollLock(options: ScrollLockOptions = {}) {
const { immediate = true } = options;
const lockTarget = shallowRef<HTMLElement | null>(null);
const isTargetLocked = _useScrollLock(lockTarget);
const scrollbarWidth = getScrollbarWidth();
let hasScrollbarCompensation = false;
let hasScrollbarGutter = false;
function applyScrollbarGutter(target: HTMLElement) {
if (target === document.body) {
return;
}
document.body.style.paddingRight = `${scrollbarWidth}px`;
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
`.${SCROLL_FIXED_CLASS}`,
);
const nodes = [...layoutFixedNodes];
target.dataset.scrollbarGutter =
target.style.getPropertyValue('scrollbar-gutter');
target.style.setProperty('scrollbar-gutter', 'stable');
hasScrollbarGutter = true;
}
function resetScrollbarGutter(target: HTMLElement) {
if (!hasScrollbarGutter) {
return;
}
const scrollbarGutter = target.dataset.scrollbarGutter;
if (scrollbarGutter) {
target.style.setProperty('scrollbar-gutter', scrollbarGutter);
} else {
target.style.removeProperty('scrollbar-gutter');
}
delete target.dataset.scrollbarGutter;
hasScrollbarGutter = false;
}
function applyScrollbarCompensation(target: HTMLElement) {
if (target !== document.body || !needsScrollbar()) {
return;
}
target.style.paddingRight = `${scrollbarWidth}px`;
const nodes = getLayoutFixedNodes();
if (nodes.length > 0) {
nodes.forEach((node) => {
node.dataset.transition = node.style.transition;
@@ -29,18 +75,15 @@ export function useScrollLock() {
node.style.paddingRight = `${scrollbarWidth}px`;
});
}
isLocked.value = true;
});
hasScrollbarCompensation = true;
}
tryOnBeforeUnmount(() => {
if (!needsScrollbar()) {
function resetScrollbarCompensation(target: HTMLElement) {
if (!hasScrollbarCompensation) {
return;
}
isLocked.value = false;
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
`.${SCROLL_FIXED_CLASS}`,
);
const nodes = [...layoutFixedNodes];
const nodes = getLayoutFixedNodes();
if (nodes.length > 0) {
nodes.forEach((node) => {
node.style.paddingRight = '';
@@ -49,6 +92,49 @@ export function useScrollLock() {
});
});
}
document.body.style.paddingRight = '';
target.style.paddingRight = '';
hasScrollbarCompensation = false;
}
const isLocked = computed({
get() {
return isTargetLocked.value;
},
set(value: boolean) {
const target = lockTarget.value ?? getScrollLockTarget();
lockTarget.value = target;
if (value) {
if (isTargetLocked.value) {
return;
}
if (needsScrollbar(target)) {
applyScrollbarGutter(target);
applyScrollbarCompensation(target);
}
isTargetLocked.value = true;
return;
}
isTargetLocked.value = false;
resetScrollbarCompensation(target);
resetScrollbarGutter(target);
},
});
tryOnMounted(async () => {
const target = getScrollLockTarget();
lockTarget.value = target;
await nextTick();
if (immediate && needsScrollbar(target)) {
isLocked.value = true;
}
});
tryOnBeforeUnmount(() => {
isLocked.value = false;
});
return isLocked;
}

View File

@@ -1,8 +1,10 @@
import type { Locale } from './messages';
import { computed, ref } from 'vue';
import { createSharedComposable } from '@vueuse/core';
import { getMessages, type Locale } from './messages';
import { getMessages } from './messages';
export const useSimpleLocale = createSharedComposable(() => {
const currentLocale = ref<Locale>('zh-CN');

View File

@@ -6,16 +6,20 @@ export const messages: Record<Locale, Record<string, string>> = {
collapse: 'Collapse',
confirm: 'Confirm',
expand: 'Expand',
prompt: 'Prompt',
reset: 'Reset',
submit: 'Submit',
confirmTitle: 'Please Confirm',
},
'zh-CN': {
cancel: '取消',
collapse: '收起',
confirm: '确认',
expand: '展开',
prompt: '提示',
reset: '重置',
submit: '提交',
confirmTitle: '请确认',
},
};

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
}),
});

View File

@@ -10,18 +10,30 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"colorWeakMode": false,
"compact": false,
"contentCompact": "wide",
"contentCompactWidth": 1200,
"contentPadding": 0,
"contentPaddingBottom": 0,
"contentPaddingLeft": 0,
"contentPaddingRight": 0,
"contentPaddingTop": 0,
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
"defaultHomePath": "/dashboard",
"dynamicTitle": true,
"enableCheckUpdates": true,
"enableCopyPreferences": true,
"enablePreferences": true,
"enableRefreshToken": false,
"enableStickyPreferencesNavigationBar": true,
"isMobile": false,
"layout": "sidebar-nav",
"locale": "zh-CN",
"loginExpiredMode": "page",
"name": "Vben Admin",
"preferencesButtonPosition": "auto",
"timezone": "Asia/Shanghai",
"watermark": false,
"watermarkContent": "",
"zIndex": 200,
},
"breadcrumb": {
"enable": true,
@@ -35,21 +47,27 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"companySiteLink": "https://www.vben.pro",
"date": "2024",
"enable": true,
"icp": "",
"icpLink": "",
"icp": "闽ICP备19024351号",
"icpLink": "https://beian.miit.gov.cn/",
"settingShow": true,
},
"footer": {
"enable": false,
"fixed": false,
"height": 32,
},
"header": {
"enable": true,
"height": 50,
"hidden": false,
"menuAlign": "start",
"mode": "fixed",
},
"logo": {
"enable": true,
"fit": "contain",
"logoMode": "icon",
"showText": true,
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
},
"navigation": {
@@ -59,6 +77,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
},
"shortcutKeys": {
"enable": true,
"globalEscape": false,
"globalLockScreen": true,
"globalLogout": true,
"globalPreferences": true,
@@ -66,12 +85,18 @@ exports[`defaultPreferences immutability test > should not modify the config obj
},
"sidebar": {
"autoActivateChild": false,
"collapseWidth": 60,
"collapsed": false,
"collapsedButton": true,
"collapsedShowTitle": false,
"draggable": true,
"enable": true,
"expandOnHover": true,
"extraCollapse": true,
"extraCollapse": false,
"extraCollapsedWidth": 60,
"fixedButton": true,
"hidden": false,
"mixedWidth": 80,
"width": 224,
},
"tabbar": {
@@ -79,11 +104,15 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"enable": true,
"height": 38,
"keepAlive": true,
"maxCount": 0,
"middleClickToClose": false,
"persist": true,
"showIcon": true,
"showMaximize": true,
"showMore": true,
"showRefresh": true,
"styleType": "chrome",
"visitHistory": true,
"wheelable": true,
},
"theme": {
@@ -92,10 +121,12 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"colorPrimary": "hsl(212 100% 45%)",
"colorSuccess": "hsl(144 57% 58%)",
"colorWarning": "hsl(42 84% 61%)",
"fontSize": 16,
"mode": "dark",
"radius": "0.5",
"semiDarkHeader": false,
"semiDarkSidebar": false,
"semiDarkSidebarSub": false,
},
"transition": {
"enable": true,
@@ -105,13 +136,35 @@ exports[`defaultPreferences immutability test > should not modify the config obj
},
"widget": {
"fullscreen": true,
"fullscreenButtonPosition": "header",
"globalSearch": true,
"globalSearchButtonPosition": "header",
"languageToggle": true,
"languageToggleButtonPosition": "header",
"lockScreen": true,
"lockScreenButtonPosition": "header",
"logoutButtonPosition": "header",
"notification": true,
"notificationButtonPosition": "header",
"order": [
"globalSearch",
"preferences",
"themeToggle",
"languageToggle",
"timezone",
"fullscreen",
"refresh",
"notification",
"lockScreenBtn",
"logoutBtn",
],
"refresh": true,
"refreshButtonPosition": "header",
"sidebarToggle": true,
"themeToggle": true,
"themeToggleButtonPosition": "header",
"timezone": true,
"timezoneButtonPosition": "header",
},
}
`;

View File

@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultPreferences } from '../src/config';
import { PreferenceManager } from '../src/preferences';
import { isDarkTheme } from '../src/update-css-variables';
describe('preferences', () => {
let preferenceManager: PreferenceManager;
let PreferenceManager: typeof import('../src/preferences').PreferenceManager;
let preferenceManager: InstanceType<
typeof import('../src/preferences').PreferenceManager
>;
// 模拟 window.matchMedia 方法
vi.stubGlobal(
@@ -21,7 +23,36 @@ describe('preferences', () => {
removeListener: vi.fn(), // Deprecated
})),
);
vi.stubGlobal('localStorage', {
clear: vi.fn(),
getItem: vi.fn(() => null),
key: vi.fn(() => null),
length: 0,
removeItem: vi.fn(),
setItem: vi.fn(),
});
vi.stubGlobal('sessionStorage', {
clear: vi.fn(),
getItem: vi.fn(() => null),
key: vi.fn(() => null),
length: 0,
removeItem: vi.fn(),
setItem: vi.fn(),
});
beforeAll(async () => {
({ PreferenceManager } = await import('../src/preferences'));
});
beforeEach(() => {
vi.mocked(localStorage.getItem).mockImplementation(() => null);
vi.mocked(localStorage.removeItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
vi.mocked(sessionStorage.getItem).mockImplementation(() => null);
vi.mocked(sessionStorage.removeItem).mockReset();
vi.mocked(sessionStorage.setItem).mockReset();
preferenceManager = new PreferenceManager();
});
@@ -74,7 +105,7 @@ describe('preferences', () => {
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
});
it('resets preferences to default', () => {
it('resets preferences to default', async () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
theme: {
@@ -83,7 +114,7 @@ describe('preferences', () => {
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
await preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
@@ -143,7 +174,7 @@ describe('preferences', () => {
);
});
it('resets preferences to default correctly', () => {
it('resets preferences to default correctly', async () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
app: { locale: 'en-US' },
@@ -154,7 +185,7 @@ describe('preferences', () => {
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
await preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
@@ -214,7 +245,10 @@ describe('preferences', () => {
},
};
await preferenceManager.initPreferences(overrides);
await preferenceManager.initPreferences({
namespace: 'apply-updates',
overrides,
});
preferenceManager.updatePreferences({
theme: { mode: 'light' },
@@ -222,6 +256,265 @@ describe('preferences', () => {
expect(preferenceManager.getPreferences().theme.mode).toBe('light');
});
it('initializes custom preferences extension with default values', async () => {
const extension = {
fields: [
{
component: 'switch',
defaultValue: true,
key: 'enableWorkbench',
label: '启用工作台',
},
{
component: 'select',
defaultValue: 'single',
key: 'tenantMode',
label: '租户模式',
options: [
{ label: '单租户', value: 'single' },
{ label: '多租户', value: 'multi' },
],
},
],
tabLabel: '扩展',
title: '业务偏好',
} as const;
await preferenceManager.initPreferences({
extension,
namespace: 'custom-defaults',
});
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
expect(preferenceManager.getCustomPreferences()).toEqual({
enableWorkbench: true,
tenantMode: 'single',
});
});
it('does not expose mutable custom preference baselines or extension schema', async () => {
const extension = {
fields: [
{
component: 'number',
componentProps: {
max: 10,
min: 2,
step: 2,
},
defaultValue: 4,
key: 'pageSize',
label: '分页大小',
},
],
tabLabel: '扩展',
title: '业务偏好',
} as const;
await preferenceManager.initPreferences({
extension,
namespace: 'custom-readonly',
});
const initialCustomPreferences =
preferenceManager.getInitialCustomPreferences<{
pageSize: number;
}>() as { pageSize: number };
const preferencesExtension = preferenceManager.getPreferencesExtension<{
pageSize: number;
}>() as {
fields: Array<{ componentProps?: { max?: number }; label: string }>;
};
const [firstField] = preferencesExtension.fields;
initialCustomPreferences.pageSize = 8;
expect(firstField).toBeDefined();
expect(firstField?.componentProps).toBeDefined();
if (!firstField || !firstField.componentProps) {
return;
}
firstField.label = '已修改';
firstField.componentProps.max = 20;
expect(preferenceManager.getInitialCustomPreferences()).toEqual({
pageSize: 4,
});
expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
});
it('updates and resets custom preferences correctly', async () => {
await preferenceManager.initPreferences({
extension: {
fields: [
{
component: 'number',
defaultValue: 20,
key: 'pageSize',
label: '分页大小',
},
{
component: 'input',
defaultValue: '日报',
key: 'reportTitle',
label: '报表标题',
},
],
tabLabel: '扩展',
},
namespace: 'custom-reset',
});
preferenceManager.updateCustomPreferences({
pageSize: 50,
reportTitle: '月报',
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 50,
reportTitle: '月报',
});
await preferenceManager.resetPreferences();
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 20,
reportTitle: '日报',
});
});
it('ignores invalid custom preferences updates', async () => {
await preferenceManager.initPreferences({
extension: {
fields: [
{
component: 'switch',
defaultValue: true,
key: 'enableWorkbench',
label: '启用工作台',
},
{
component: 'select',
defaultValue: 'single',
key: 'tenantMode',
label: '租户模式',
options: [
{ label: '单租户', value: 'single' },
{ label: '多租户', value: 'multi' },
],
},
],
tabLabel: '扩展',
},
namespace: 'custom-invalid',
});
const originalCustomPreferences = preferenceManager.getCustomPreferences();
preferenceManager.updateCustomPreferences({
enableWorkbench: 'true' as unknown as boolean,
tenantMode: 'unknown',
unknownField: 'value',
} as any);
expect(preferenceManager.getCustomPreferences()).toEqual(
originalCustomPreferences,
);
});
it('enforces custom number field min max and step constraints', async () => {
await preferenceManager.initPreferences({
extension: {
fields: [
{
component: 'number',
componentProps: {
max: 10,
min: 2,
step: 2,
},
defaultValue: 4,
key: 'pageSize',
label: '分页大小',
},
],
tabLabel: '扩展',
},
namespace: 'custom-number-constraints',
});
preferenceManager.updateCustomPreferences({
pageSize: 8,
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 8,
});
preferenceManager.updateCustomPreferences({
pageSize: 1,
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 8,
});
preferenceManager.updateCustomPreferences({
pageSize: 12,
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 8,
});
preferenceManager.updateCustomPreferences({
pageSize: 5,
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 8,
});
});
it('filters cached custom number values that violate field constraints', async () => {
vi.mocked(localStorage.getItem).mockImplementation((key) => {
if (key.endsWith('cache-preferences-custom')) {
return JSON.stringify({
value: {
pageSize: 5,
},
});
}
return null;
});
await preferenceManager.initPreferences({
extension: {
fields: [
{
component: 'number',
componentProps: {
max: 10,
min: 2,
step: 2,
},
defaultValue: 4,
key: 'pageSize',
label: '分页大小',
},
],
tabLabel: '扩展',
},
namespace: 'custom-number-cache',
});
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 4,
});
});
});
describe('isDarkTheme', () => {

View File

@@ -1,7 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/preferences",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,7 +11,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"#build": "pnpm unbuild"
"#build": "pnpm exec tsdown"
},
"files": [
"dist",
@@ -20,12 +20,23 @@
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./src/index.ts",
"#default": "./dist/index.mjs"
"production": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {

View File

@@ -9,19 +9,31 @@ const defaultPreferences: Preferences = {
colorWeakMode: false,
compact: false,
contentCompact: 'wide',
contentCompactWidth: 1200,
contentPadding: 0,
contentPaddingBottom: 0,
contentPaddingLeft: 0,
contentPaddingRight: 0,
contentPaddingTop: 0,
defaultAvatar:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
defaultHomePath: '/dashboard',
dynamicTitle: true,
enableCheckUpdates: true,
enableCopyPreferences: true,
enablePreferences: true,
enableRefreshToken: false,
enableStickyPreferencesNavigationBar: true,
isMobile: false,
layout: 'sidebar-nav',
locale: 'zh-CN',
loginExpiredMode: 'page',
name: 'Vben Admin',
preferencesButtonPosition: 'auto',
timezone: 'Asia/Shanghai',
watermark: false,
watermarkContent: '',
zIndex: 200,
},
breadcrumb: {
enable: true,
@@ -35,22 +47,29 @@ const defaultPreferences: Preferences = {
companySiteLink: 'https://www.vben.pro',
date: '2024',
enable: true,
icp: '',
icpLink: '',
icp: '闽ICP备19024351号',
icpLink: 'https://beian.miit.gov.cn/',
settingShow: true,
},
footer: {
enable: false,
fixed: false,
height: 32,
},
header: {
enable: true,
height: 50,
hidden: false,
menuAlign: 'start',
mode: 'fixed',
},
logo: {
enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
showText: true,
logoMode: 'icon',
},
navigation: {
accordion: true,
@@ -59,6 +78,7 @@ const defaultPreferences: Preferences = {
},
shortcutKeys: {
enable: true,
globalEscape: false,
globalLockScreen: true,
globalLogout: true,
globalPreferences: true,
@@ -67,11 +87,17 @@ const defaultPreferences: Preferences = {
sidebar: {
autoActivateChild: false,
collapsed: false,
collapsedButton: true,
collapsedShowTitle: false,
collapseWidth: 60,
draggable: true,
enable: true,
expandOnHover: true,
extraCollapse: true,
extraCollapse: false,
extraCollapsedWidth: 60,
fixedButton: true,
hidden: false,
mixedWidth: 80,
width: 224,
},
tabbar: {
@@ -79,11 +105,15 @@ const defaultPreferences: Preferences = {
enable: true,
height: 38,
keepAlive: true,
maxCount: 0,
middleClickToClose: false,
persist: true,
showIcon: true,
showMaximize: true,
showMore: true,
showRefresh: true,
styleType: 'chrome',
visitHistory: true,
wheelable: true,
},
theme: {
@@ -94,8 +124,10 @@ const defaultPreferences: Preferences = {
colorWarning: 'hsl(42 84% 61%)',
mode: 'dark',
radius: '0.5',
fontSize: 16,
semiDarkHeader: false,
semiDarkSidebar: false,
semiDarkSidebarSub: false,
},
transition: {
enable: true,
@@ -105,13 +137,35 @@ const defaultPreferences: Preferences = {
},
widget: {
fullscreen: true,
fullscreenButtonPosition: 'header',
globalSearch: true,
globalSearchButtonPosition: 'header',
languageToggle: true,
languageToggleButtonPosition: 'header',
lockScreen: true,
lockScreenButtonPosition: 'header',
logoutButtonPosition: 'header',
notification: true,
notificationButtonPosition: 'header',
refresh: true,
refreshButtonPosition: 'header',
sidebarToggle: true,
themeToggle: true,
themeToggleButtonPosition: 'header',
timezone: true,
timezoneButtonPosition: 'header',
order: [
'globalSearch',
'preferences',
'themeToggle',
'languageToggle',
'timezone',
'fullscreen',
'refresh',
'notification',
'lockScreenBtn',
'logoutBtn',
],
},
};

View File

@@ -1,4 +1,4 @@
import type { BuiltinThemeType } from '@vben-core/typings';
import type { BuiltinThemeType, TimezoneOption } from '@vben-core/typings';
interface BuiltinThemePreset {
color: string;
@@ -38,12 +38,10 @@ const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
primaryColor: 'hsl(240 5.9% 10%)',
type: 'zinc',
},
{
color: 'hsl(181 84% 32%)',
type: 'deep-green',
},
{
color: 'hsl(211 91% 39%)',
type: 'deep-blue',
@@ -56,7 +54,6 @@ const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
color: 'hsl(0 75% 42%)',
type: 'rose',
},
{
color: 'hsl(0 0% 25%)',
darkPrimaryColor: 'hsl(0 0% 98%)',
@@ -81,8 +78,39 @@ const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
},
];
/**
* 时区选项
*/
const DEFAULT_TIME_ZONE_OPTIONS: TimezoneOption[] = [
{
offset: -5,
timezone: 'America/New_York',
label: 'America/New_York(GMT-5)',
},
{
offset: 0,
timezone: 'Europe/London',
label: 'Europe/London(GMT0)',
},
{
offset: 8,
timezone: 'Asia/Shanghai',
label: 'Asia/Shanghai(GMT+8)',
},
{
offset: 9,
timezone: 'Asia/Tokyo',
label: 'Asia/Tokyo(GMT+9)',
},
{
offset: 9,
timezone: 'Asia/Seoul',
label: 'Asia/Seoul(GMT+9)',
},
];
export const COLOR_PRESETS = [...BUILT_IN_THEME_PRESETS].slice(0, 7);
export { BUILT_IN_THEME_PRESETS };
export { BUILT_IN_THEME_PRESETS, DEFAULT_TIME_ZONE_OPTIONS };
export type { BuiltinThemePreset };

View File

@@ -1,34 +1,23 @@
/* oxlint-disable unicorn/prefer-export-from */
import type { Preferences } from './types';
import { preferencesManager } from './preferences';
// 偏好设置(带有层级关系)
const preferences: Preferences =
preferencesManager.getPreferences.apply(preferencesManager);
// 更新偏好设置
const updatePreferences =
preferencesManager.updatePreferences.bind(preferencesManager);
// 重置偏好设置
const resetPreferences =
preferencesManager.resetPreferences.bind(preferencesManager);
const clearPreferencesCache =
preferencesManager.clearCache.bind(preferencesManager);
// 初始化偏好设置
const initPreferences =
preferencesManager.initPreferences.bind(preferencesManager);
export {
clearPreferencesCache,
initPreferences,
preferences,
preferencesManager,
resetPreferences,
export const {
getPreferences,
getCustomPreferences,
getInitialCustomPreferences,
getPreferencesExtension,
updatePreferences,
};
updateCustomPreferences,
resetPreferences,
clearCache,
initPreferences,
} = preferencesManager;
export const preferences: Preferences = getPreferences();
export { preferencesManager };
export * from './constants';
export type * from './types';

View File

@@ -1,11 +1,21 @@
import type { DeepPartial } from '@vben-core/typings';
import type { InitialOptions, Preferences } from './types';
import type {
CustomPreferencesField,
CustomPreferencesRecord,
InitialOptions,
Preferences,
PreferencesExtension,
} from './types';
import { markRaw, reactive, readonly, watch } from 'vue';
import { StorageManager } from '@vben-core/shared/cache';
import { isMacOs, merge } from '@vben-core/shared/utils';
import {
isMacOs,
merge,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import {
breakpointsTailwind,
@@ -16,81 +26,425 @@ import {
import { defaultPreferences } from './config';
import { updateCSSVariables } from './update-css-variables';
const STORAGE_KEY = 'preferences';
const STORAGE_KEY_LOCALE = `${STORAGE_KEY}-locale`;
const STORAGE_KEY_THEME = `${STORAGE_KEY}-theme`;
const STORAGE_KEYS = {
CUSTOM: 'preferences-custom',
MAIN: 'preferences',
LOCALE: 'preferences-locale',
THEME: 'preferences-theme',
} as const;
class PreferenceManager {
private cache: null | StorageManager = null;
// private flattenedState: Flatten<Preferences>;
private cache: StorageManager;
private customPreferencesExtension: null | PreferencesExtension<any> = null;
private customState = reactive<CustomPreferencesRecord>({});
private debouncedSave: () => void;
private initialCustomPreferences: CustomPreferencesRecord = {};
private initialPreferences: Preferences = defaultPreferences;
private isInitialized: boolean = false;
private savePreferences: (preference: Preferences) => void;
private state: Preferences = reactive<Preferences>({
...this.loadPreferences(),
});
private isInitialized = false;
private state: Preferences;
constructor() {
this.cache = new StorageManager();
// 避免频繁的操作缓存
this.savePreferences = useDebounceFn(
(preference: Preferences) => this._savePreferences(preference),
150,
);
// 构造函数不再同步读取缓存,使用默认值初始化
// 真正的缓存加载在 initPreferences 中完成(已经是 async)
this.state = reactive<Preferences>({ ...defaultPreferences });
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
}
/**
* 保存偏好设置
* @param {Preferences} preference - 需要保存的偏好设置
* 清除所有缓存的偏好设置
*/
private _savePreferences(preference: Preferences) {
this.cache?.setItem(STORAGE_KEY, preference);
this.cache?.setItem(STORAGE_KEY_LOCALE, preference.app.locale);
this.cache?.setItem(STORAGE_KEY_THEME, preference.theme.mode);
clearCache = async () => {
await Promise.all(
Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)),
);
};
/**
* 获取扩展偏好设置
*/
getCustomPreferences = <
TCustomPreferences extends object = CustomPreferencesRecord,
>() => {
return readonly(this.customState) as Readonly<TCustomPreferences>;
};
/**
* 获取初始化扩展偏好设置
*/
getInitialCustomPreferences = <
TCustomPreferences extends object = CustomPreferencesRecord,
>() => {
return this.cloneValue(
this.initialCustomPreferences,
) as Readonly<TCustomPreferences>;
};
/**
* 获取初始化偏好设置
*/
getInitialPreferences = () => {
return this.initialPreferences;
};
/**
* 获取当前偏好设置(只读)
*/
getPreferences = () => {
return readonly(this.state);
};
/**
* 获取扩展偏好设置配置
*/
getPreferencesExtension = <
TCustomPreferences extends object = CustomPreferencesRecord,
>() => {
return this.customPreferencesExtension
? (this.cloneValue(this.customPreferencesExtension) as Readonly<
PreferencesExtension<TCustomPreferences>
>)
: null;
};
/**
* 初始化偏好设置
* @param options - 初始化配置项
* @param options.namespace - 命名空间,用于隔离不同应用的配置
* @param options.overrides - 要覆盖的偏好设置
*/
initPreferences = async <
TCustomPreferences extends object = CustomPreferencesRecord,
>({
namespace,
overrides,
extension,
}: InitialOptions<TCustomPreferences>) => {
// 防止重复初始化
if (this.isInitialized) {
return;
}
// 使用命名空间初始化存储管理器
this.cache = new StorageManager({ prefix: namespace });
// 合并初始偏好设置:前面的对象优先,后面的对象仅补齐缺失字段
this.initialPreferences = merge({}, overrides, defaultPreferences);
this.customPreferencesExtension = extension ?? null;
this.initialCustomPreferences = this.resolveCustomPreferencesDefaults(
this.customPreferencesExtension,
);
// 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段
const cachedPreferences = (await this.loadFromCache()) || {};
// 修复历史脏数据:早期版本用 defu(merge) 合并数组,导致 widget.order
// 在每次 updatePreferences 时被 concat 追加,膨胀到几十/上百条重复 key。
// 这里在加载缓存后做一次去重,保留首次出现的 key。
this.sanitizeCachedArray(cachedPreferences, 'widget', 'order');
const mergedPreference = mergeWithArrayOverride(
{},
cachedPreferences, // 用户缓存的设置优先
this.initialPreferences, // 初始设置仅补齐缺失字段
);
// 更新偏好设置
this.updatePreferences(mergedPreference);
const cachedCustom = (await this.loadCustomFromCache()) || {};
this.replaceCustomPreferences(
mergeWithArrayOverride(
{},
this.sanitizeCustomPreferences(cachedCustom),
this.initialCustomPreferences,
),
);
await this.saveToCache();
// 设置监听器
this.setupWatcher();
// 初始化平台标识
this.initPlatform();
this.isInitialized = true;
};
/**
* 重置偏好设置到初始状态
*/
resetPreferences = async () => {
// 将状态重置为初始偏好设置
Object.assign(this.state, this.initialPreferences);
this.replaceCustomPreferences(this.initialCustomPreferences);
// 保存偏好设置至缓存
await this.saveToCache();
// 直接触发 UI 更新
this.handleUpdates(this.state);
};
/**
* 更新扩展偏好设置
* @param updates - 要更新的扩展偏好设置
*/
updateCustomPreferences = (updates: DeepPartial<object>) => {
if (!this.customPreferencesExtension) {
return;
}
const sanitizedUpdates = this.sanitizeCustomPreferences(
updates as DeepPartial<CustomPreferencesRecord>,
);
if (Object.keys(sanitizedUpdates).length === 0) {
return;
}
this.replaceCustomPreferences(
mergeWithArrayOverride({}, sanitizedUpdates, markRaw(this.customState)),
);
this.debouncedSave();
};
/**
* 更新偏好设置
* @param updates - 要更新的偏好设置
*/
updatePreferences = (updates: DeepPartial<Preferences>) => {
// 深度合并更新内容和当前状态
// 注意:必须用 mergeWithArrayOverride 而不是 merge。
// defu(merge) 对数组的语义是 concat 追加,会导致 widget.order 等
// 数组字段在每次 updatePreferences 时被重复追加,指数级膨胀。
const mergedState = mergeWithArrayOverride(
{},
updates,
markRaw(this.state),
);
Object.assign(this.state, mergedState);
// 根据更新的值执行更新
this.handleUpdates(updates);
// 保存到缓存(fire-and-forget,通过 debounce 控制频率)
this.debouncedSave();
};
private cloneValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => this.cloneValue(item)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(
([key, nestedValue]) => [key, this.cloneValue(nestedValue)],
),
) as T;
}
return value;
}
/**
* 处理更新的键值
* 根据更新的键值执行相应的操作。
* @param {DeepPartial<Preferences>} updates - 部分更新的偏好设置
* 处理更新
* @param updates - 更新的偏好设置
*/
private handleUpdates(updates: DeepPartial<Preferences>) {
const themeUpdates = updates.theme || {};
const appUpdates = updates.app || {};
if (themeUpdates && Object.keys(themeUpdates).length > 0) {
const { theme, app } = updates;
if (
theme &&
(Object.keys(theme).length > 0 || Reflect.has(theme, 'fontSize'))
) {
updateCSSVariables(this.state);
}
if (
Reflect.has(appUpdates, 'colorGrayMode') ||
Reflect.has(appUpdates, 'colorWeakMode')
app &&
(Reflect.has(app, 'colorGrayMode') || Reflect.has(app, 'colorWeakMode'))
) {
this.updateColorMode(this.state);
}
}
/**
* 初始化平台标识
*/
private initPlatform() {
const dom = document.documentElement;
dom.dataset.platform = isMacOs() ? 'macOs' : 'window';
document.documentElement.dataset.platform = isMacOs() ? 'macOs' : 'window';
}
private isAlmostInteger(value: number, epsilon = Number.EPSILON * 10) {
return Math.abs(value - Math.round(value)) < epsilon;
}
private isValidCustomPreferenceValue(
field: CustomPreferencesField,
value: unknown,
) {
switch (field.component) {
case 'number': {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return false;
}
const max = this.resolveNumericConstraint(field.componentProps?.max);
const min = this.resolveNumericConstraint(field.componentProps?.min);
const step = this.resolveNumericConstraint(field.componentProps?.step);
if (min !== undefined && value < min) {
return false;
}
if (max !== undefined && value > max) {
return false;
}
if (step !== undefined) {
if (step <= 0) {
return false;
}
const stepBase = min ?? 0;
const stepCount = (value - stepBase) / step;
if (!this.isAlmostInteger(stepCount)) {
return false;
}
}
return true;
}
case 'select': {
return (
typeof value === 'string' &&
field.options.some((option) => option.value === value)
);
}
case 'switch': {
return typeof value === 'boolean';
}
default: {
return typeof value === 'string';
}
}
}
/**
* 从缓存中加载偏好设置。如果缓存中没有找到对应的偏好设置,则返回默认偏好设置。
* 从缓存加载扩展偏好设置
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
*/
private loadCachedPreferences() {
return this.cache?.getItem<Preferences>(STORAGE_KEY);
private async loadCustomFromCache(): Promise<CustomPreferencesRecord | null> {
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
}
/**
* 加载偏好设置
* @returns {Preferences} 加载的偏好设置
* 从缓存加载偏好设置
* @returns 缓存的偏好设置,如果不存在则返回 null
*/
private loadPreferences(): Preferences {
return this.loadCachedPreferences() || { ...defaultPreferences };
private async loadFromCache(): Promise<null | Preferences> {
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
}
private replaceCustomPreferences(preferences: CustomPreferencesRecord) {
Object.keys(this.customState).forEach((key) => {
Reflect.deleteProperty(this.customState, key);
});
Object.assign(this.customState, preferences);
}
private resolveCustomPreferencesDefaults(
extension: null | PreferencesExtension<any>,
) {
if (!extension) {
return {};
}
const result: CustomPreferencesRecord = {};
for (const field of extension.fields) {
result[field.key] = field.defaultValue;
}
return result;
}
private resolveNumericConstraint(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
/**
* 监听状态和系统偏好设置的变化。
* 清理缓存中膨胀的数组字段(保留首次出现的元素)
* 用于修复早期版本 defu 合并数组时 concat 追加导致的脏数据
*/
private sanitizeCachedArray(
cached: Record<string, any>,
group: string,
field: string,
) {
const node = cached?.[group]?.[field];
if (!Array.isArray(node) || node.length <= 1) return;
const seen = new Set<unknown>();
const deduped = node.filter((item) => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
if (deduped.length !== node.length) {
cached[group][field] = deduped;
}
}
private sanitizeCustomPreferences(
updates: DeepPartial<CustomPreferencesRecord>,
) {
if (!this.customPreferencesExtension) {
return {};
}
const result: CustomPreferencesRecord = {};
for (const field of this.customPreferencesExtension.fields) {
const value = updates[field.key];
if (
value !== undefined &&
this.isValidCustomPreferenceValue(field, value)
) {
result[field.key] = value;
}
}
return result;
}
/**
* 保存偏好设置到缓存
*/
private async saveToCache() {
try {
await this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
await this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
await this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
if (this.customPreferencesExtension) {
await this.cache.setItem(STORAGE_KEYS.CUSTOM, {
...this.customState,
});
return;
}
await this.cache.removeItem(STORAGE_KEYS.CUSTOM);
} catch (error) {
console.error('Failed to save preferences to cache:', error);
}
}
/**
* 监听状态和系统偏好设置的变化
*/
private setupWatcher() {
if (this.isInitialized) {
@@ -100,6 +454,7 @@ class PreferenceManager {
// 监听断点,判断是否移动端
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
watch(
() => isMobile.value,
(val) => {
@@ -114,115 +469,33 @@ class PreferenceManager {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({ matches: isDark }) => {
this.updatePreferences({
theme: { mode: isDark ? 'dark' : 'light' },
});
// 仅在自动模式下跟随系统主题
if (this.state.theme.mode === 'auto') {
// 先应用实际的主题
this.updatePreferences({
theme: { mode: isDark ? 'dark' : 'light' },
});
// 再恢复为 auto 模式,保持跟随系统的状态
this.updatePreferences({
theme: { mode: 'auto' },
});
}
});
}
/**
* 更新页面颜色模式(灰色、色弱)
* @param preference
* @param preference - 偏好设置
*/
private updateColorMode(preference: Preferences) {
if (preference.app) {
const { colorGrayMode, colorWeakMode } = preference.app;
const dom = document.documentElement;
const COLOR_WEAK = 'invert-mode';
const COLOR_GRAY = 'grayscale-mode';
colorWeakMode
? dom.classList.add(COLOR_WEAK)
: dom.classList.remove(COLOR_WEAK);
colorGrayMode
? dom.classList.add(COLOR_GRAY)
: dom.classList.remove(COLOR_GRAY);
}
}
const { colorGrayMode, colorWeakMode } = preference.app;
const dom = document.documentElement;
clearCache() {
[STORAGE_KEY, STORAGE_KEY_LOCALE, STORAGE_KEY_THEME].forEach((key) => {
this.cache?.removeItem(key);
});
}
public getInitialPreferences() {
return this.initialPreferences;
}
public getPreferences() {
return readonly(this.state);
}
/**
* 覆盖偏好设置
* overrides 要覆盖的偏好设置
* namespace 命名空间
*/
public async initPreferences({ namespace, overrides }: InitialOptions) {
// 是否初始化过
if (this.isInitialized) {
return;
}
// 初始化存储管理器
this.cache = new StorageManager({ prefix: namespace });
// 合并初始偏好设置
this.initialPreferences = merge({}, overrides, defaultPreferences);
// 加载并合并当前存储的偏好设置
const mergedPreference = merge(
{},
// overrides,
this.loadCachedPreferences() || {},
this.initialPreferences,
);
// 更新偏好设置
this.updatePreferences(mergedPreference);
this.setupWatcher();
this.initPlatform();
// 标记为已初始化
this.isInitialized = true;
}
/**
* 重置偏好设置
* 偏好设置将被重置为初始值,并从 localStorage 中移除。
*
* @example
* 假设 initialPreferences 为 { theme: 'light', language: 'en' }
* 当前 state 为 { theme: 'dark', language: 'fr' }
* this.resetPreferences();
* 调用后,state 将被重置为 { theme: 'light', language: 'en' }
* 并且 localStorage 中的对应项将被移除
*/
resetPreferences() {
// 将状态重置为初始偏好设置
Object.assign(this.state, this.initialPreferences);
// 保存重置后的偏好设置
this.savePreferences(this.state);
// 从存储中移除偏好设置项
[STORAGE_KEY, STORAGE_KEY_THEME, STORAGE_KEY_LOCALE].forEach((key) => {
this.cache?.removeItem(key);
});
this.updatePreferences(this.state);
}
/**
* 更新偏好设置
* @param updates - 要更新的偏好设置
*/
public updatePreferences(updates: DeepPartial<Preferences>) {
const mergedState = merge({}, updates, markRaw(this.state));
Object.assign(this.state, mergedState);
// 根据更新的键值执行相应的操作
this.handleUpdates(updates);
this.savePreferences(this.state);
dom.classList.toggle('invert-mode', colorWeakMode);
dom.classList.toggle('grayscale-mode', colorGrayMode);
}
}
const preferencesManager = new PreferenceManager();
export { PreferenceManager, preferencesManager };

View File

@@ -5,6 +5,7 @@ import type {
BuiltinThemeType,
ContentCompactType,
DeepPartial,
LayoutHeaderMenuAlignType,
LayoutHeaderModeType,
LayoutType,
LoginExpiredModeType,
@@ -16,6 +17,84 @@ import type {
} from '@vben-core/typings';
type SupportedLanguagesType = 'en-US' | 'zh-CN';
type CustomPreferencesValue = boolean | number | string;
interface CustomPreferencesOption<TValue extends string = string> {
label: string;
value: TValue;
}
interface BaseCustomPreferencesField<
TKey extends string = string,
TValue extends CustomPreferencesValue = CustomPreferencesValue,
> {
componentProps?: Record<string, any>;
defaultValue: TValue;
disabled?: boolean;
key: TKey;
label: string;
placeholder?: string;
tip?: string;
}
interface CustomPreferencesInputField<
TKey extends string = string,
> extends BaseCustomPreferencesField<TKey, string> {
component: 'input';
}
interface CustomPreferencesNumberField<
TKey extends string = string,
> extends BaseCustomPreferencesField<TKey, number> {
component: 'number';
}
interface CustomPreferencesSelectField<
TKey extends string = string,
> extends BaseCustomPreferencesField<TKey, string> {
component: 'select';
options: CustomPreferencesOption[];
}
interface CustomPreferencesSwitchField<
TKey extends string = string,
> extends BaseCustomPreferencesField<TKey, boolean> {
component: 'switch';
}
type CustomPreferencesRecord = Record<string, CustomPreferencesValue>;
type AnyCustomPreferencesField =
| CustomPreferencesInputField
| CustomPreferencesNumberField
| CustomPreferencesSelectField
| CustomPreferencesSwitchField;
type CustomPreferencesField<
TCustomPreferences extends object = CustomPreferencesRecord,
> =
string extends Extract<keyof TCustomPreferences, string>
? AnyCustomPreferencesField
: {
[K in Extract<
keyof TCustomPreferences,
string
>]: TCustomPreferences[K] extends boolean
? CustomPreferencesSwitchField<K>
: TCustomPreferences[K] extends number
? CustomPreferencesNumberField<K>
: TCustomPreferences[K] extends string
? CustomPreferencesInputField<K> | CustomPreferencesSelectField<K>
: never;
}[Extract<keyof TCustomPreferences, string>];
interface PreferencesExtension<
TCustomPreferences extends object = CustomPreferencesRecord,
> {
fields: Array<CustomPreferencesField<TCustomPreferences>>;
tabLabel: string;
title?: string;
}
interface AppPreferences {
/** 权限模式 */
@@ -32,18 +111,38 @@ interface AppPreferences {
compact: boolean;
/** 是否开启内容紧凑模式 */
contentCompact: ContentCompactType;
// /** 应用默认头像 */
/** 内容紧凑宽度 */
contentCompactWidth: number;
/** 内容内边距 */
contentPadding: number;
/** 内容底部内边距 */
contentPaddingBottom: number;
/** 内容左侧内边距 */
contentPaddingLeft: number;
/** 内容右侧内边距 */
contentPaddingRight: number;
/** 内容顶部内边距 */
contentPaddingTop: number;
/** 应用默认头像 */
defaultAvatar: string;
// /** 开启动态标题 */
/** 默认首页地址 */
defaultHomePath: string;
/** 开启动态标题 */
dynamicTitle: boolean;
/** 是否开启检查更新 */
enableCheckUpdates: boolean;
/** 是否显示复制偏好设置按钮 */
enableCopyPreferences: boolean;
/** 是否显示偏好设置 */
enablePreferences: boolean;
/**
* @zh_CN 是否开启refreshToken
*/
enableRefreshToken: boolean;
/**
* @zh_CN 是否开启首选项导航栏吸顶效果
*/
enableStickyPreferencesNavigationBar: boolean;
/** 是否移动端 */
isMobile: boolean;
/** 布局方式 */
@@ -58,10 +157,20 @@ interface AppPreferences {
preferencesButtonPosition: PreferencesButtonPositionType;
/** 版本信息 */
version: string;
/**
* @zh_CN 应用时区
*/
timezone: string;
/**
* @zh_CN 是否开启水印
*/
watermark: boolean;
/**
* @zh_CN 水印文案
*/
watermarkContent: string;
/** z-index */
zIndex: number;
}
interface BreadcrumbPreferences {
@@ -99,13 +208,19 @@ interface FooterPreferences {
enable: boolean;
/** 底栏是否固定 */
fixed: boolean;
/** 底栏高度 */
height: number;
}
interface HeaderPreferences {
/** 顶栏是否启用 */
enable: boolean;
/** 顶栏高度 */
height: number;
/** 顶栏是否隐藏,css-隐藏 */
hidden: boolean;
/** 顶栏菜单位置 */
menuAlign: LayoutHeaderMenuAlignType;
/** header显示模式 */
mode: LayoutHeaderModeType;
}
@@ -113,8 +228,18 @@ interface HeaderPreferences {
interface LogoPreferences {
/** logo是否可见 */
enable: boolean;
/** logo图片适应方式 */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** logo高度, 只在 logoMode=full时生效 */
fullLogoHeight?: number | string;
/** logo 展示类型,icon 图标模式, full 铺满logo区域 */
logoMode: 'full' | 'icon';
/** logo text是否展示 */
showText: boolean;
/** logo地址 */
source: string;
/** 暗色主题logo地址 (可选,若不设置则使用 source) */
sourceDark?: string;
}
interface NavigationPreferences {
@@ -131,16 +256,28 @@ interface SidebarPreferences {
autoActivateChild: boolean;
/** 侧边栏是否折叠 */
collapsed: boolean;
/** 侧边栏折叠按钮是否可见 */
collapsedButton: boolean;
/** 侧边栏折叠时,是否显示title */
collapsedShowTitle: boolean;
/** 侧边栏折叠宽度 */
collapseWidth: number;
/** 侧边栏菜单拖拽 */
draggable: boolean;
/** 侧边栏是否可见 */
enable: boolean;
/** 菜单自动展开状态 */
expandOnHover: boolean;
/** 侧边栏扩展区域是否折叠 */
extraCollapse: boolean;
/** 侧边栏扩展区域折叠宽度 */
extraCollapsedWidth: number;
/** 侧边栏固定按钮是否可见 */
fixedButton: boolean;
/** 侧边栏是否隐藏 - css */
hidden: boolean;
/** 混合侧边栏宽度 */
mixedWidth: number;
/** 侧边栏宽度 */
width: number;
}
@@ -148,6 +285,8 @@ interface SidebarPreferences {
interface ShortcutKeyPreferences {
/** 是否启用快捷键-全局 */
enable: boolean;
/** 是否启用全局关闭窗口快捷键 */
globalEscape: boolean;
/** 是否启用全局锁屏快捷键 */
globalLockScreen: boolean;
/** 是否启用全局注销快捷键 */
@@ -167,6 +306,10 @@ interface TabbarPreferences {
height: number;
/** 开启标签页缓存功能 */
keepAlive: boolean;
/** 限制最大数量 */
maxCount: number;
/** 是否点击中键时关闭标签 */
middleClickToClose: boolean;
/** 是否持久化标签 */
persist: boolean;
/** 是否开启多标签页图标 */
@@ -175,8 +318,12 @@ interface TabbarPreferences {
showMaximize: boolean;
/** 显示更多按钮 */
showMore: boolean;
/** 显示刷新按钮 */
showRefresh: boolean;
/** 标签页风格 */
styleType: TabsStyleType;
/** 是否开启访问历史记录 */
visitHistory: boolean;
/** 是否开启鼠标滚轮响应 */
wheelable: boolean;
}
@@ -192,6 +339,8 @@ interface ThemePreferences {
colorSuccess: string;
/** 警告色 */
colorWarning: string;
/** 字体大小(单位:px) */
fontSize: number;
/** 当前主题 */
mode: ThemeModeType;
/** 圆角 */
@@ -200,6 +349,8 @@ interface ThemePreferences {
semiDarkHeader: boolean;
/** 是否开启半深色菜单(只在theme='light'时生效) */
semiDarkSidebar: boolean;
/** 是否开启半深色子菜单(只在theme='light'时生效) */
semiDarkSidebarSub: boolean;
}
interface TransitionPreferences {
@@ -216,20 +367,42 @@ interface TransitionPreferences {
interface WidgetPreferences {
/** 是否启用全屏部件 */
fullscreen: boolean;
/** 全屏按钮位置 */
fullscreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用全局搜索部件 */
globalSearch: boolean;
/** 全局搜索按钮位置 */
globalSearchButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否启用语言切换部件 */
languageToggle: boolean;
/** 语言切换按钮位置 */
languageToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否开启锁屏功能 */
lockScreen: boolean;
/** 锁屏按钮位置 */
lockScreenButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 退出登录按钮位置 */
logoutButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示通知部件 */
notification: boolean;
/** 通知按钮位置 */
notificationButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 小部件排序 */
order: readonly string[];
/** 显示刷新按钮 */
refresh: boolean;
/** 刷新按钮位置 */
refreshButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示侧边栏显示/隐藏部件 */
sidebarToggle: boolean;
/** 是否显示主题切换部件 */
themeToggle: boolean;
/** 主题切换按钮位置 */
themeToggleButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 是否显示时区部件 */
timezone: boolean;
/** 时区按钮位置 */
timezoneButtonPosition: 'header' | 'none' | 'user-dropdown';
}
interface Preferences {
@@ -263,19 +436,33 @@ interface Preferences {
type PreferencesKeys = keyof Preferences;
interface InitialOptions {
interface InitialOptions<
TCustomPreferences extends object = CustomPreferencesRecord,
> {
extension?: PreferencesExtension<TCustomPreferences>;
namespace: string;
overrides?: DeepPartial<Preferences>;
}
export type {
AnyCustomPreferencesField,
AppPreferences,
BaseCustomPreferencesField,
BreadcrumbPreferences,
CustomPreferencesField,
CustomPreferencesInputField,
CustomPreferencesNumberField,
CustomPreferencesOption,
CustomPreferencesRecord,
CustomPreferencesSelectField,
CustomPreferencesSwitchField,
CustomPreferencesValue,
FooterPreferences,
HeaderPreferences,
InitialOptions,
LogoPreferences,
NavigationPreferences,
Preferences,
PreferencesExtension,
PreferencesKeys,
ShortcutKeyPreferences,
SidebarPreferences,

View File

@@ -66,6 +66,19 @@ function updateCSSVariables(preferences: Preferences) {
if (Reflect.has(theme, 'radius')) {
document.documentElement.style.setProperty('--radius', `${radius}rem`);
}
// 更新字体大小
if (Reflect.has(theme, 'fontSize')) {
const fontSize = theme.fontSize;
document.documentElement.style.setProperty(
'--font-size-base',
`${fontSize}px`,
);
document.documentElement.style.setProperty(
'--menu-font-size',
`calc(${fontSize}px * 0.875)`,
);
}
}
/**

View File

@@ -1,18 +1,29 @@
import { computed } from 'vue';
import { diff } from '@vben-core/shared/utils';
import { diff, diffStrict } from '@vben-core/shared/utils';
import { preferencesManager } from './preferences';
import { isDarkTheme } from './update-css-variables';
function usePreferences() {
const preferences = preferencesManager.getPreferences();
const customPreferences = preferencesManager.getCustomPreferences();
const initialPreferences = preferencesManager.getInitialPreferences();
const initialCustomPreferences =
preferencesManager.getInitialCustomPreferences();
const preferencesExtension = computed(() =>
preferencesManager.getPreferencesExtension(),
);
/**
* @zh_CN 计算偏好设置的变化
* @zh_CN 使用 diffStrict:图标排序等数组字段需顺序敏感比较
*/
const diffPreference = computed(() => {
return diff(initialPreferences, preferences);
return diffStrict(initialPreferences, preferences);
});
const diffCustomPreference = computed(() => {
return diff(initialCustomPreferences, customPreferences);
});
const appPreferences = computed(() => preferences.app);
@@ -29,7 +40,7 @@ function usePreferences() {
});
const locale = computed(() => {
return preferences.app.locale;
return appPreferences.value.locale;
});
const isMobile = computed(() => {
@@ -82,6 +93,20 @@ function usePreferences() {
() => appPreferences.value.layout === 'header-nav',
);
/**
* @zh_CN 是否为头部混合导航模式
*/
const isHeaderMixedNav = computed(
() => appPreferences.value.layout === 'header-mixed-nav',
);
/**
* @zh_CN 是否为顶部通栏+侧边导航模式
*/
const isHeaderSidebarNav = computed(
() => appPreferences.value.layout === 'header-sidebar-nav',
);
/**
* @zh_CN 是否为混合导航模式
*/
@@ -93,7 +118,13 @@ function usePreferences() {
* @zh_CN 是否包含侧边导航模式
*/
const isSideMode = computed(() => {
return isMixedNav.value || isSideMixedNav.value || isSideNav.value;
return (
isMixedNav.value ||
isSideMixedNav.value ||
isSideNav.value ||
isHeaderMixedNav.value ||
isHeaderSidebarNav.value
);
});
const sidebarCollapsed = computed(() => {
@@ -116,7 +147,7 @@ function usePreferences() {
});
/**
* @zh_CN 登录注册页面布局是否为左侧
* @zh_CN 登录注册页面布局是否为右侧
*/
const authPanelRight = computed(() => {
return appPreferences.value.authPageLayout === 'panel-right';
@@ -155,6 +186,14 @@ function usePreferences() {
return enable && globalLogout;
});
/**
* @zh_CN 是否启用全局注销快捷键
*/
const globalEscapeShortcutKey = computed(() => {
const { enable, globalEscape } = shortcutKeysPreferences.value;
return enable && globalEscape;
});
const globalLockScreenShortcutKey = computed(() => {
const { enable, globalLockScreen } = shortcutKeysPreferences.value;
return enable && globalLockScreen;
@@ -165,12 +204,12 @@ function usePreferences() {
*/
const preferencesButtonPosition = computed(() => {
const { enablePreferences, preferencesButtonPosition } = preferences.app;
// 如果没有启用偏好设置按钮
if (!enablePreferences) {
return {
fixed: false,
header: false,
userDropdown: false,
};
}
@@ -181,12 +220,15 @@ function usePreferences() {
const contentIsMaximize = headerHidden && sidebarHidden;
const isHeaderPosition = preferencesButtonPosition === 'header';
const isUserDropdownPosition =
preferencesButtonPosition === 'user-dropdown';
// 如果设置了固定位置
if (preferencesButtonPosition !== 'auto') {
return {
fixed: preferencesButtonPosition === 'fixed',
header: isHeaderPosition,
userDropdown: isUserDropdownPosition,
};
}
@@ -200,6 +242,7 @@ function usePreferences() {
return {
fixed,
header: !fixed,
userDropdown: !fixed && isUserDropdownPosition,
};
});
@@ -208,13 +251,18 @@ function usePreferences() {
authPanelLeft,
authPanelRight,
contentIsMaximize,
customPreferences,
diffPreference,
diffCustomPreference,
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalEscapeShortcutKey,
globalSearchShortcutKey,
isDark,
isFullContent,
isHeaderMixedNav,
isHeaderNav,
isHeaderSidebarNav,
isMixedNav,
isMobile,
isSideMixedNav,
@@ -223,9 +271,11 @@ function usePreferences() {
keepAlive,
layout,
locale,
preferencesExtension,
preferencesButtonPosition,
sidebarCollapsed,
theme,
app: appPreferences.value,
};
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
outExtensions: () => ({
dts: '.d.ts',
}),
});

View File

@@ -0,0 +1,21 @@
import { defineComponent, h } from 'vue';
export const TestInput = defineComponent({
inheritAttrs: false,
emits: ['update:modelValue'],
setup(_props, { attrs, emit }) {
function handleInput(event: Event) {
const target = event.target;
if (target instanceof HTMLInputElement) {
emit('update:modelValue', target.value);
}
}
return () =>
h('input', {
...attrs,
onInput: handleInput,
value: attrs.modelValue ?? '',
});
},
});

View File

@@ -1,6 +1,9 @@
import type { BaseFormComponentType } from '../src/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormApi } from '../src/form-api';
import { FormCodecError } from '../src/form-codec';
describe('formApi', () => {
let formApi: FormApi;
@@ -44,6 +47,7 @@ describe('formApi', () => {
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should get values from form', async () => {
@@ -52,11 +56,272 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({ name: 'test' });
});
it('should set a field error through the public api', async () => {
const setFieldError = vi.fn();
const formActions: any = {
meta: {},
setFieldError,
values: {},
};
formApi.mount(formActions, new Map());
await formApi.setFieldError('password', 'Invalid password');
expect(setFieldError).toHaveBeenCalledWith('password', 'Invalid password');
});
it('should format schema values when getting values', async () => {
formApi.setState({
schema: [
{
component: 'range-picker',
fieldName: 'filters.range',
valueFormat: (value, setValue) => {
setValue('filters.startTime', value?.[0]);
setValue('filters.endTime', value?.[1]);
},
},
],
});
const formActions: any = {
meta: {},
values: {
filters: {
range: [1_710_000_000_000, 1_720_000_000_000],
},
},
};
const originalValuesSnapshot = structuredClone(formActions.values);
await formApi.mount(formActions, new Map());
expect(formApi.getLatestSubmissionValues()).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
const values = await formApi.getValues();
expect(values).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
expect(await formApi.getRawValues()).toEqual(originalValuesSnapshot);
expect(await formApi.getValueSnapshot()).toEqual({
rawValues: originalValuesSnapshot,
values,
});
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should encode submissions and decode complete values with a codec', async () => {
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const setValues = vi.fn();
const codecFormApi = new FormApi<
FilterFormValues,
BaseFormComponentType,
Record<never, never>,
FilterSubmitValues
>({
codec: {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags.split(','),
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
},
});
const formActions: any = {
meta: {},
setValues,
values: { period: [1, 2], tags: ['admin', 'user'] },
};
await codecFormApi.mount(formActions, new Map());
expect(await codecFormApi.getValues()).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(await codecFormApi.getValueSnapshot()).toEqual({
rawValues: { period: [1, 2], tags: ['admin', 'user'] },
values: { endTime: 2, startTime: 1, tags: 'admin,user' },
});
await codecFormApi.setSubmitValues(
{ endTime: 4, startTime: 3, tags: 'editor' },
false,
);
expect(setValues).toHaveBeenCalledWith(
{ period: [3, 4], tags: ['editor'] },
false,
);
});
it('should isolate codec results from live form values', async () => {
interface ProfileFormValues {
profile: { name: string };
tags: string[];
}
const values: ProfileFormValues = {
profile: { name: 'Ada' },
tags: ['admin'],
};
const codecFormApi = new FormApi<ProfileFormValues>({
codec: {
decode: (submitValues) => submitValues,
encode: (formValues) => ({
profile: formValues.profile,
tags: formValues.tags,
}),
},
});
const formActions: any = { meta: {}, values };
codecFormApi.mount(formActions, new Map());
const initialSubmissionValues = codecFormApi.getLatestSubmissionValues();
values.profile.name = 'Grace';
values.tags.push('user');
expect(initialSubmissionValues).toEqual({
profile: { name: 'Ada' },
tags: ['admin'],
});
const submissionValues = await codecFormApi.getValues();
expect(submissionValues.profile).not.toBe(values.profile);
expect(submissionValues.tags).not.toBe(values.tags);
});
it('should fall back to raw values when the initial codec encode fails', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const codecFormApi = new FormApi<
{ name?: string },
BaseFormComponentType,
Record<never, never>,
{ normalizedName: string }
>({
codec: {
decode: (values) => ({ name: values.normalizedName }),
encode() {
throw new Error('incomplete initial values');
},
},
});
const formActions: any = { meta: {}, values: { name: 'Ada' } };
expect(() => codecFormApi.mount(formActions, new Map())).not.toThrow();
expect(codecFormApi.isMounted).toBe(true);
expect(codecFormApi.getLatestSubmissionValues()).toEqual({ name: 'Ada' });
expect(warning).toHaveBeenCalledWith(
'[Vben Form] Failed to encode initial values. Falling back to raw form values.',
expect.objectContaining({ phase: 'encode' }),
);
await expect(codecFormApi.getValues()).rejects.toBeInstanceOf(
FormCodecError,
);
});
it('should scan deprecated schema transforms once for unchanged state', async () => {
const getChildren = vi.fn(() => []);
const schema = {
component: 'text',
fieldName: 'name',
get children() {
return getChildren();
},
} as any;
const codecFormApi = new FormApi({
codec: {
decode: (values) => values,
encode: (values) => values,
},
schema: [schema],
});
const formActions: any = {
meta: {},
values: { name: 'Ada' },
};
await codecFormApi.mount(formActions, new Map());
expect(getChildren).toHaveBeenCalledTimes(1);
await codecFormApi.getValues();
await codecFormApi.getValues();
expect(getChildren).toHaveBeenCalledTimes(1);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat: (
value: any,
setValue: any,
_values: any,
ctx: any,
) => {
setValue('normalizedName', value?.trim());
setValue('$root.firstRow', ctx?.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
} as any,
],
});
const formActions: any = {
meta: {},
values: {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
},
};
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
firstRow: 1,
});
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@@ -65,7 +330,7 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
@@ -74,24 +339,59 @@ describe('formApi', () => {
);
});
it('should reset form', async () => {
const resetFormMock = vi.fn();
it('should set only known fields without losing provided values', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [
{ component: 'text', fieldName: 'name' },
{ component: 'text', fieldName: 'profile.email' },
],
});
const formActions: any = {
meta: {},
resetForm: resetFormMock,
setValues: setValuesMock,
values: {},
};
await formApi.mount(formActions, new Map());
await formApi.setValues({
name: 'Ada',
profile: {
email: 'ada@example.com',
ignored: true,
},
unknown: 'ignored',
});
expect(setValuesMock).toHaveBeenCalledWith(
{
name: 'Ada',
profile: {
email: 'ada@example.com',
},
},
false,
);
});
it('should reset form', async () => {
const resetMock = vi.fn();
const formActions: any = {
meta: {},
reset: resetMock,
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
await formApi.mount(formActions, new Map());
await formApi.reset();
expect(resetMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submitForm: vi.fn().mockResolvedValue(true),
submit: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
@@ -100,11 +400,14 @@ describe('formApi', () => {
};
formApi.setState(state);
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
const result = await formApi.submit();
expect(formActions.submit).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith(
{ name: 'test' },
{ name: 'test' },
);
expect(result).toEqual({ name: 'test' });
});
@@ -113,6 +416,39 @@ describe('formApi', () => {
expect(formApi.isMounted).toBe(false);
});
it('should clear component refs on unmount before mounting again', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
values: { name: 'test' },
};
const staleMap = new Map<string, unknown>([
[
'name',
{
$: {
type: { name: 'MockComponent' },
},
$el: {},
},
],
]);
await formApi.mount(formActions, staleMap);
expect(formApi.getFieldComponentRef('name')).toEqual({
$: {
type: { name: 'MockComponent' },
},
$el: {},
});
formApi.unmount();
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
await formApi.mount(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
@@ -120,11 +456,53 @@ describe('formApi', () => {
validate: validateMock,
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);
});
it('should validate only once before submitting valid values', async () => {
const handleSubmit = vi.fn();
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
};
formApi.setState({ handleSubmit });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toEqual({ name: 'Ada' });
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).toHaveBeenCalledOnce();
});
it('should not submit invalid values', async () => {
const handleSubmit = vi.fn();
const errors = { name: 'Name is required' };
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors, valid: false }),
values: { name: '' },
};
const scrollToFirstError = vi
.spyOn(formApi as any, 'scrollToFirstError')
.mockImplementation(() => {});
formApi.setState({ handleSubmit, scrollToFirstError: true });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toBeUndefined();
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
expect(scrollToFirstError).toHaveBeenCalledOnce();
expect(scrollToFirstError).toHaveBeenCalledWith(errors);
});
});
describe('updateSchema', () => {
@@ -152,6 +530,35 @@ describe('updateSchema', () => {
expect(instance.state?.schema?.[1]?.label).toBe('Age');
});
it('should update child schema by parent path', () => {
instance.state = {
schema: [
{
children: [
{ component: 'text', fieldName: 'name', label: 'Name' },
{ component: 'text', fieldName: 'phone', label: 'Phone' },
],
fieldName: 'contacts',
type: 'array',
} as any,
],
};
instance.updateSchema([
{
fieldName: 'contacts.name',
label: 'Full Name',
},
]);
expect((instance.state?.schema?.[0] as any)?.children?.[0]?.label).toBe(
'Full Name',
);
expect((instance.state?.schema?.[0] as any)?.children?.[1]?.label).toBe(
'Phone',
);
});
it('should log an error if fieldName is missing in some items', () => {
const newSchema: any[] = [
{ component: 'textarea', fieldName: 'name' },

View File

@@ -0,0 +1,81 @@
import type { FormCodec } from '../src/types';
import { describe, expect, expectTypeOf, it } from 'vitest';
import {
decodeFormValues,
encodeFormValues,
FormCodecError,
} from '../src/form-codec';
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const filterCodec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags ? values.tags.split(',') : [],
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
};
describe('form codec', () => {
it('encodes and decodes complete form values', () => {
const submitValues = encodeFormValues(filterCodec, {
period: [1, 2],
tags: ['admin', 'user'],
});
expect(submitValues).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(decodeFormValues(filterCodec, submitValues)).toEqual({
period: [1, 2],
tags: ['admin', 'user'],
});
expectTypeOf(submitValues).toEqualTypeOf<FilterSubmitValues>();
});
it('reports the failed codec phase without mutating inputs', () => {
const values = Object.freeze({ period: [1, 2], tags: ['admin'] }) as {
period: [number, number];
tags: string[];
};
const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode: filterCodec.decode,
encode() {
throw new Error('broken encoder');
},
};
expect(() => encodeFormValues(codec, values)).toThrowError(FormCodecError);
let codecError: unknown;
try {
encodeFormValues(codec, values);
} catch (error) {
codecError = error;
}
expect(codecError).toBeInstanceOf(FormCodecError);
expect(codecError).toMatchObject({ phase: 'encode' });
expect(values).toEqual({ period: [1, 2], tags: ['admin'] });
});
});

View File

@@ -0,0 +1,180 @@
import type { BaseFormComponentType } from '../src/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupVbenForm } from '../src/config';
import {
resetDeprecationWarnings,
warnDeprecatedOnce,
} from '../src/deprecation';
import { FormApi } from '../src/form-api';
import { getFormRule } from '../src/rule-registry';
afterEach(() => {
resetDeprecationWarnings();
vi.restoreAllMocks();
});
describe('form api compatibility', () => {
it('keeps deprecated value transforms and warns once per API', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi({
arrayToStringFields: ['tags'],
fieldMappingTime: [['period', ['startTime', 'endTime'], null]],
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: (value: string) => value.trim(),
},
],
});
const form = {
meta: {},
values: {
name: ' Ada ',
period: [1, 2],
tags: ['admin', 'user'],
},
} as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({
endTime: 2,
name: 'Ada',
startTime: 1,
tags: 'admin,user',
});
expect(warning).toHaveBeenCalledTimes(3);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
});
it('prefers the codec when deprecated transforms are also configured', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi<
{ name: string },
BaseFormComponentType,
Record<never, never>,
{ normalizedName: string }
>({
codec: {
decode(values) {
return { name: values.normalizedName };
},
encode(values) {
return { normalizedName: values.name.toUpperCase() };
},
},
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: () => 'legacy',
},
],
});
const form = { meta: {}, values: { name: 'Ada' } } as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({ normalizedName: 'ADA' });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
});
it('forwards defineRules and warns only once in development', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error';
setupVbenForm({ defineRules: { legacy: legacyRule } });
setupVbenForm({ defineRules: { legacy: legacyRule } });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
const registeredRule = getFormRule('legacy');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'legacy' },
name: 'legacy',
}),
).toBe('legacy error');
});
it('prefers the new rules option when both APIs define the same rule', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
setupVbenForm({
defineRules: { required: () => 'legacy error' },
rules: { required: () => 'new error' },
});
const registeredRule = getFormRule('required');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'required' },
name: 'required',
}),
).toBe('new error');
});
it('does not emit deprecation warnings in production', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
warnDeprecatedOnce('legacy-api', 'deprecated', { production: true });
expect(warning).not.toHaveBeenCalled();
});
it('keeps legacy form methods and warns once for each name', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi();
const form = {
clearValidation: vi.fn(),
meta: {},
reset: vi.fn(),
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
} as any;
formApi.mount(form);
await formApi.resetForm();
await formApi.resetForm();
await formApi.resetValidate();
await formApi.submitForm();
await formApi.validateAndSubmitForm();
expect(form.reset).toHaveBeenCalledTimes(2);
expect(form.clearValidation).toHaveBeenCalledOnce();
expect(form.submit).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledTimes(4);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
});
});

View File

@@ -0,0 +1,190 @@
import type { FormSchema } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterAll, bench, describe } from 'vitest';
import { z } from 'zod';
import { setupVbenForm } from '../src/config';
import { useVbenForm } from '../src/use-vben-form';
import { TestInput } from './benchmark-fixtures';
const BENCHMARK_OPTIONS = { time: 750, warmupTime: 150 } as const;
const FIELD_COUNT = 100;
const MOUNT_FIELD_COUNT = 50;
function createFlatSchema(
fieldCount: number,
withRules: boolean = false,
): FormSchema[] {
const rule = withRules ? z.string().min(1) : undefined;
return Array.from({ length: fieldCount }, (_, index) => ({
component: TestInput,
defaultValue: `Value ${index}`,
fieldName: `field${index}`,
label: `Field ${index}`,
rules: rule,
}));
}
function createFlatValues(prefix: string) {
return Object.fromEntries(
Array.from({ length: FIELD_COUNT }, (_, index) => [
`field${index}`,
`${prefix} ${index}`,
]),
);
}
setupVbenForm({ config: {}, rules: {} });
const flatSchema = createFlatSchema(FIELD_COUNT);
const [FlatForm, flatFormApi] = useVbenForm<Record<string, string>>({
schema: flatSchema,
showDefaultActions: false,
});
const flatWrapper = mount(FlatForm);
const [ValidationForm, validationFormApi] = useVbenForm<Record<string, string>>(
{
schema: createFlatSchema(FIELD_COUNT, true),
showDefaultActions: false,
},
);
const validationWrapper = mount(ValidationForm);
const dependencySchema: FormSchema[] = [
{
component: TestInput,
defaultValue: 'editable',
fieldName: 'mode',
label: 'Mode',
},
...Array.from({ length: 50 }, (_, index) => ({
component: TestInput,
defaultValue: `Value ${index}`,
dependencies: {
resolve: ({ values }: { values: Record<string, string> }) => ({
disabled: values.mode === 'locked',
}),
triggerFields: ['mode'],
},
fieldName: `dependent${index}`,
label: `Dependent ${index}`,
})),
];
const [DependencyForm, dependencyFormApi] = useVbenForm<Record<string, string>>(
{
schema: dependencySchema,
showDefaultActions: false,
},
);
const dependencyWrapper = mount(DependencyForm);
await flushPromises();
const batchValues = [createFlatValues('Alpha'), createFlatValues('Beta')];
const schemaPatches = [false, true].map((disabled) =>
Array.from({ length: FIELD_COUNT }, (_, index) => ({
componentProps: { disabled },
fieldName: `field${index}`,
})),
);
let batchIteration = 0;
let dependencyIteration = 0;
let fieldIteration = 0;
let resetIteration = 0;
let schemaIteration = 0;
afterAll(() => {
dependencyWrapper.unmount();
flatWrapper.unmount();
validationWrapper.unmount();
});
describe('form render performance', () => {
bench(
'initialize, mount, and unmount 50 fields',
async () => {
const [Form] = useVbenForm<Record<string, string>>({
schema: createFlatSchema(MOUNT_FIELD_COUNT),
showDefaultActions: false,
});
const wrapper = mount(Form);
await flushPromises();
wrapper.unmount();
},
BENCHMARK_OPTIONS,
);
});
describe('form value performance', () => {
bench(
'update one field in a 100-field form',
async () => {
fieldIteration += 1;
await flatFormApi.setFieldValue('field50', `Value ${fieldIteration}`);
await nextTick();
},
BENCHMARK_OPTIONS,
);
bench(
'set 100 fields in one batch',
async () => {
batchIteration += 1;
await flatFormApi.setValues(batchValues[batchIteration % 2] ?? {});
await nextTick();
},
BENCHMARK_OPTIONS,
);
bench(
'reset 100 fields to alternate values',
async () => {
resetIteration += 1;
await flatFormApi.reset(
{ values: batchValues[resetIteration % 2] ?? {} },
{ force: true },
);
await nextTick();
},
BENCHMARK_OPTIONS,
);
});
describe('form validation performance', () => {
bench(
'validate 100 fields with zod rules',
async () => {
await validationFormApi.validate();
},
BENCHMARK_OPTIONS,
);
});
describe('form schema performance', () => {
bench(
'update 100 schema entries',
async () => {
schemaIteration += 1;
flatFormApi.updateSchema(schemaPatches[schemaIteration % 2] ?? []);
await nextTick();
},
BENCHMARK_OPTIONS,
);
bench(
'resolve 50 dependencies from one trigger',
async () => {
dependencyIteration += 1;
await dependencyFormApi.setFieldValue(
'mode',
dependencyIteration % 2 === 0 ? 'editable' : 'locked',
);
await flushPromises();
},
BENCHMARK_OPTIONS,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
import { flushPromises, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterAll, bench, describe } from 'vitest';
import { setupVbenForm } from '../src/config';
import { encodeFormValues } from '../src/form-codec';
import { useVbenForm } from '../src/use-vben-form';
import { TestInput } from './benchmark-fixtures';
interface ContactValues {
enabled: boolean;
metadata: {
permissions: string[];
team: string;
};
name: string;
phone: string;
tags: string[];
}
interface PerformanceFormValues extends Record<string, unknown> {
contacts: ContactValues[];
settings: {
alerts: boolean;
locale: string;
sections: string[];
};
}
const ROW_COUNT = 100;
function createFormValues(): PerformanceFormValues {
return {
contacts: Array.from({ length: ROW_COUNT }, (_, index) => ({
enabled: index % 2 === 0,
metadata: {
permissions: ['read', 'write', 'review'],
team: `team-${index % 10}`,
},
name: ` Contact ${index} `,
phone: `10086-${index}`,
tags: ['primary', 'on-call', `group-${index % 5}`],
})),
settings: {
alerts: true,
locale: 'zh-CN',
sections: ['profile', 'security', 'notifications'],
},
};
}
const codec = {
decode: (values: Readonly<PerformanceFormValues>) => ({ ...values }),
encode: (values: Readonly<PerformanceFormValues>) => ({
...values,
contacts: values.contacts.map((contact) => ({
...contact,
name: contact.name.trim(),
})),
}),
};
const formValues = createFormValues();
setupVbenForm({ config: {}, rules: {} });
const [CodecForm, codecFormApi] = useVbenForm<PerformanceFormValues>({
codec,
schema: [
{
component: TestInput,
defaultValue: formValues.contacts,
fieldName: 'contacts',
},
{
component: TestInput,
defaultValue: formValues.settings,
fieldName: 'settings',
},
],
showDefaultActions: false,
});
const codecWrapper = mount(CodecForm);
const [ArrayForm, arrayFormApi] = useVbenForm({
schema: [
{
children: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
},
],
defaultValue: Array.from({ length: ROW_COUNT }, (_, index) => ({
name: `Contact ${index}`,
})),
fieldName: 'contacts',
type: 'array',
},
],
});
const arrayWrapper = mount(ArrayForm);
await flushPromises();
const arraySchemaPatches = [false, true].map((disabled) => ({
componentProps: { disabled },
fieldName: 'contacts.name',
}));
let arrayEditIteration = 0;
let arraySchemaIteration = 0;
afterAll(() => {
arrayWrapper.unmount();
codecWrapper.unmount();
});
describe('form codec performance', () => {
bench(
'encode 100 nested rows without isolation',
() => {
encodeFormValues(codec, formValues);
},
{ time: 1000, warmupTime: 200 },
);
bench(
'encode 100 nested rows with isolated input',
() => {
codecFormApi.formatValues(formValues);
},
{ time: 1000, warmupTime: 200 },
);
bench(
'create submit snapshot for 100 nested rows',
async () => {
await codecFormApi.getValueSnapshot();
},
{ time: 1000, warmupTime: 200 },
);
});
describe('form array performance', () => {
bench(
'edit one field in a 100-row array',
async () => {
arrayEditIteration += 1;
await arrayFormApi.setFieldValue(
'contacts[50].name',
`Contact ${arrayEditIteration}`,
);
await nextTick();
},
{ time: 1000, warmupTime: 200 },
);
bench(
'append and remove one row from a 100-row array',
async () => {
arrayFormApi.form.pushFieldValue('contacts', { name: 'Temporary' });
await nextTick();
await arrayFormApi.form.removeFieldValue('contacts', ROW_COUNT);
await nextTick();
},
{ time: 1000, warmupTime: 200 },
);
bench(
'update one child schema across 100 rows',
async () => {
arraySchemaIteration += 1;
arrayFormApi.updateSchema([
arraySchemaPatches[arraySchemaIteration % 2] ?? {},
]);
await nextTick();
},
{ time: 1000, warmupTime: 200 },
);
});

View File

@@ -0,0 +1,236 @@
import type { FormActions } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick, watch } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useFormRuntime } from '../src/form-runtime';
const wrappers: ReturnType<typeof mount>[] = [];
function mountRuntime(
defaultValues: Record<string, any>,
validator?: (input: { value: any }) => Promise<string | undefined>,
) {
let form: FormActions | undefined;
const RuntimeHarness = defineComponent({
setup() {
const runtime = useFormRuntime(defaultValues);
form = runtime;
return () => {
if (!validator) {
return h('div');
}
return h(
runtime.fieldComponent,
{
name: 'name',
validators: {
onSubmitAsync: validator,
},
},
{
default: ({ field }: Record<string, any>) =>
h('input', {
name: 'name',
onBlur: field.handleBlur,
onInput: (event: Event) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
field.handleChange(target.value);
}
},
value: field.state.value,
}),
},
);
};
},
});
const wrapper = mount(RuntimeHarness);
wrappers.push(wrapper);
return { form, wrapper };
}
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
wrapper.unmount();
}
});
describe('form runtime', () => {
it('updates values and resets to defaults', async () => {
const { form } = mountRuntime({ name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
await form.setFieldValue('name', 'updated');
await nextTick();
expect(form.values).toEqual({ name: 'updated' });
await form.reset();
await nextTick();
expect(form.values).toEqual({ name: 'initial' });
});
it('preserves empty string field updates', async () => {
const { form, wrapper } = mountRuntime(
{ name: 'initial' },
async () => undefined,
);
expect(form).toBeDefined();
if (!form) return;
await wrapper.find('input').setValue('');
await nextTick();
expect(form.values).toEqual({ name: '' });
});
it('exposes reactive selectors', async () => {
const { form } = mountRuntime({ name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
const name = form.useSelector((state) => state.values.name);
await form.setFieldValue('name', 'updated');
await nextTick();
expect(name.value).toBe('updated');
});
it('updates only changed field value selectors', async () => {
const { form } = mountRuntime({ email: '', name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
const name = form.useFieldValue('name');
const selectedValues = form.useFieldValues(['name'] as const);
const onNameChange = vi.fn();
const stop = watch(name, onNameChange);
await form.setFieldValue('email', 'ada@example.com');
await nextTick();
expect(onNameChange).not.toHaveBeenCalled();
expect(selectedValues.value).toEqual(['initial']);
await form.setFieldValue('name', 'Ada');
await nextTick();
expect(onNameChange).toHaveBeenCalledOnce();
expect(name.value).toBe('Ada');
expect(selectedValues.value).toEqual(['Ada']);
stop();
});
it('exposes reactive field error selectors', async () => {
const { form } = mountRuntime({ email: '', name: '' });
expect(form).toBeDefined();
if (!form) return;
const nameError = form.useFieldError('name');
const onNameErrorChange = vi.fn();
const stop = watch(nameError, onNameErrorChange);
form.setFieldError('email', 'Email error');
await nextTick();
expect(onNameErrorChange).not.toHaveBeenCalled();
form.setFieldError('name', 'Name error');
await nextTick();
expect(nameError.value).toBe('Name error');
expect(onNameErrorChange).toHaveBeenCalledOnce();
stop();
});
it('validates mounted fields and clears stale errors', async () => {
const { form } = mountRuntime({ name: '' }, async ({ value }) => {
return value ? undefined : 'Name is required';
});
expect(form).toBeDefined();
if (!form) return;
expect(await form.validate()).toEqual({
errors: { name: 'Name is required' },
valid: false,
});
await form.setFieldValue('name', 'Ada');
await flushPromises();
expect(await form.validateField('name')).toEqual({
errors: {},
valid: true,
});
expect(form.isFieldValid('name')).toBe(true);
});
it('sets and clears manual field errors', async () => {
const { form } = mountRuntime({ name: '' }, async () => undefined);
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Server error');
await nextTick();
expect(form.getFieldError('name')).toBe('Server error');
expect(form.meta.valid).toBe(false);
form.setFieldError('name');
await nextTick();
expect(form.getFieldError('name')).toBeUndefined();
expect(form.meta.valid).toBe(true);
});
it('clears manual errors when resetting the form', async () => {
const { form } = mountRuntime({ name: '' });
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Server error');
await nextTick();
expect(form.errors).toEqual({ name: 'Server error' });
await form.reset();
await nextTick();
expect(form.errors).toEqual({});
expect(form.meta.valid).toBe(true);
});
it('invalidates in-flight async validation when clearing validation', async () => {
let resolveValidation: ((error: string | undefined) => void) | undefined;
let notifyValidationStarted: (() => void) | undefined;
const validationStarted = new Promise<void>((resolve) => {
notifyValidationStarted = resolve;
});
const validator = vi.fn(() => {
notifyValidationStarted?.();
return new Promise<string | undefined>((resolve) => {
resolveValidation = resolve;
});
});
const { form } = mountRuntime({ name: '' }, validator);
expect(form).toBeDefined();
if (!form) return;
const pendingValidation = form.validateField('name');
await validationStarted;
form.clearValidation();
resolveValidation?.('Name is already used');
await pendingValidation;
await flushPromises();
expect(form.errors).toEqual({});
expect(form.meta.validating).toBe(false);
});
it('clears only the requested field validation state', async () => {
const { form } = mountRuntime({ email: '', name: '' });
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Name error');
form.setFieldError('email', 'Email error');
await nextTick();
form.clearValidation('name');
await nextTick();
expect(form.errors).toEqual({ email: 'Email error' });
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import {
createArrayChildSchema,
createFormFieldSchema,
} from '../src/form-render/schema';
describe('form schema normalization', () => {
it('resolves common component props with the field context', () => {
const componentProps = vi.fn(({ fieldName }) => ({
placeholder: `Enter ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'name' });
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter name',
});
});
it('preserves common component props objects', () => {
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'name' },
{ commonConfig: { componentProps: { placeholder: 'Enter a name' } } },
);
expect(schema.commonComponentProps).toEqual({
placeholder: 'Enter a name',
});
});
it('resolves global common component props functions', () => {
const componentProps = vi.fn(({ fieldName }) => ({
title: `Global ${fieldName}`,
}));
const schema = createFormFieldSchema(
{ component: 'VbenInput', fieldName: 'email' },
{ globalCommonConfig: { componentProps } },
);
expect(componentProps).toHaveBeenCalledWith({ fieldName: 'email' });
expect(schema.commonComponentProps).toEqual({ title: 'Global email' });
});
it('resolves array common props with the row context', () => {
const componentProps = vi.fn(() => ({ placeholder: 'Contact name' }));
const schema = createArrayChildSchema(
{ component: 'VbenInput', fieldName: 'name' },
{
arrayField: 'contacts',
commonConfig: { componentProps },
index: 1,
},
);
expect(componentProps).toHaveBeenCalledWith({
arrayField: 'contacts',
fieldName: 'contacts[1].name',
originalFieldName: 'name',
rowIndex: 1,
rowPath: 'contacts[1]',
});
expect(schema.commonComponentProps).toEqual({
placeholder: 'Contact name',
});
});
});

View File

@@ -0,0 +1,275 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
FormValueSnapshot,
VbenFormAdapterOptions,
VbenFormProps,
} from '../src/types';
import { describe, expectTypeOf, it } from 'vitest';
import { useVbenForm } from '../src/use-vben-form';
interface AccountFormValues {
email: string;
profile: {
nickname: string;
};
roles: string[];
}
interface AccountSubmitValues {
email: string;
nickname: string;
roles: string;
}
describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
expectTypeOf<FormActions['setFieldValue']>()
.parameter(0)
.toMatchTypeOf<string>();
expectTypeOf<
FormActions['validate']
>().returns.resolves.toEqualTypeOf<FormValidationResult>();
expectTypeOf<Parameters<FormActions['validate']>>().toEqualTypeOf<[]>();
expectTypeOf<Parameters<FormActions['validateField']>>().toEqualTypeOf<
[fieldName: string]
>();
});
it('accepts both new and deprecated rule registration options', () => {
expectTypeOf<VbenFormAdapterOptions>().toMatchTypeOf<{
defineRules?: Record<string, unknown>;
rules?: Record<string, unknown>;
}>();
});
it('supports resolve and legacy dependency contracts', () => {
const resolveDependencies: FormItemDependencies<AccountFormValues> = {
resolve({ actions, controller, schema, values }) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(actions).toEqualTypeOf<FormActions<AccountFormValues>>();
expectTypeOf(controller).toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf(schema.fieldName).toEqualTypeOf<string | undefined>();
return { disabled: !values.email, rules: null };
},
triggerFields: ['email'],
};
const legacyDependencies: FormItemDependencies<AccountFormValues> = {
show(values) {
expectTypeOf(values).toEqualTypeOf<Partial<AccountFormValues>>();
return Boolean(values.email);
},
triggerFields: ['email'],
};
const fieldOptions: FormFieldOptions = {
asyncDebounceMs: 200,
validateOn: ['blur', 'change'],
};
expectTypeOf(resolveDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(legacyDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(fieldOptions).toMatchTypeOf<FormFieldOptions>();
});
it('propagates form value types through public APIs and callbacks', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues
> = {
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountFormValues>();
},
schema: [],
};
const [Form, formApi] = useVbenForm<AccountFormValues>(options);
expectTypeOf(formApi).toEqualTypeOf<ExtendedFormApi<AccountFormValues>>();
function assertContextApi(
contextApi: FormContextApi<AccountFormValues>,
typedFormApi: ExtendedFormApi<AccountFormValues>,
) {
expectTypeOf(
typedFormApi.getValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(
typedFormApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(typedFormApi.getValueSnapshot()).resolves.toEqualTypeOf<{
rawValues: Readonly<AccountFormValues>;
values: AccountFormValues;
}>();
expectTypeOf(typedFormApi.setValues)
.parameter(0)
.toEqualTypeOf<Partial<AccountFormValues>>();
expectTypeOf(typedFormApi.form.values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf<string>();
expectTypeOf(
contextApi.useSelector((state) => state.values.profile.nickname),
).toEqualTypeOf<Readonly<import('vue').Ref<string>>>();
}
expectTypeOf(assertContextApi).toBeFunction();
type FormSlots = InstanceType<typeof Form>['$slots'];
type EmailSlot = NonNullable<FormSlots['email']>;
type EmailSlotProps = Parameters<EmailSlot>[0];
type DefaultSlot = NonNullable<FormSlots['default']>;
type DefaultSlotProps = Parameters<DefaultSlot>[0];
expectTypeOf<
EmailSlotProps['field']['state']['value']
>().toEqualTypeOf<string>();
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>();
expectTypeOf<
EmailSlotProps['componentProps']['modelValue']
>().toEqualTypeOf<string | undefined>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf<
DefaultSlotProps['values']
>().toEqualTypeOf<AccountFormValues>();
const [WideForm] = useVbenForm<Record<string, unknown>>({ schema: [] });
type WideFormSlots = InstanceType<typeof WideForm>['$slots'];
type WideFieldSlot = NonNullable<WideFormSlots['dynamic-field']>;
type WideFieldSlotProps = Parameters<WideFieldSlot>[0];
expectTypeOf<WideFieldSlotProps>().not.toBeAny();
expectTypeOf<WideFieldSlotProps['modelValue']>().toEqualTypeOf<unknown>();
expectTypeOf<
WideFieldSlotProps['field']['state']['value']
>().toEqualTypeOf<unknown>();
expectTypeOf<
WideFieldSlotProps['componentField']['modelValue']
>().toEqualTypeOf<unknown>();
expectTypeOf<WideFieldSlotProps['name']>().toBeString();
expectTypeOf<WideFieldSlotProps['values']>().toEqualTypeOf<
Record<string, unknown>
>();
});
it('keeps form and submit values distinct with a codec', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues,
AccountSubmitValues
> = {
codec: {
decode(values) {
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
};
const [, formApi] = useVbenForm<
AccountFormValues,
BaseFormComponentType,
Record<never, never>,
AccountSubmitValues
>(options);
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(formApi.getValueSnapshot()).resolves.toEqualTypeOf<
FormValueSnapshot<AccountFormValues, AccountSubmitValues>
>();
expectTypeOf(formApi.setSubmitValues)
.parameter(0)
.toEqualTypeOf<AccountSubmitValues>();
});
it('infers submit values from an inline codec', () => {
const [, formApi] = useVbenForm({
codec: {
decode(values) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountSubmitValues>>();
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values: Readonly<AccountFormValues>) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
schema: [],
});
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
});
it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']
>();
expectTypeOf<FormContextApi['submit']>().toEqualTypeOf<
FormContextApi['submitForm']
>();
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
applyFormValueFormats,
formatFormValues,
transformRangeTimeValues,
} from '../src/form-value-transform';
describe('form value transforms', () => {
it('maps array and range fields without mutating input values', () => {
const input = {
period: [1_710_000_000_000, 1_720_000_000_000],
tags: ['admin', 'editor'],
};
const result = transformRangeTimeValues(
input,
[['period', ['startTime', 'endTime'], null]],
['tags'],
);
expect(result).toEqual({
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
tags: 'admin,editor',
});
expect(input).toEqual({
period: [1_710_000_000_000, 1_720_000_000_000],
tags: ['admin', 'editor'],
});
});
it('formats array children with row and root paths', () => {
const values = {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
};
const schema = [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat(value: string, setValue: any, _values: any, ctx: any) {
setValue('$row.normalizedName', value.trim());
setValue('$root.lastRow', ctx.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
},
] as any;
const result = applyFormValueFormats(values, schema);
expect(result).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
lastRow: 1,
});
expect(values).toEqual({
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
});
});
it('runs the unified formatting pipeline in a stable order', () => {
const result = formatFormValues(
{
period: [1, 2],
tags: ['admin', 'editor'],
title: ' Ada ',
},
[
{
component: 'text',
fieldName: 'title',
valueFormat: (value: string) => value.trim(),
},
],
[['period', ['startTime', 'endTime'], null]],
['tags'],
);
expect(result).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,editor',
title: 'Ada',
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { z, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
import {
getBaseRules,
getDefaultValueInZodStack,
} from '../src/form-render/helper';
describe('zod v4 schema helpers', () => {
it('unwraps optional and default schemas with public APIs', () => {
const schema = z.string().default('default value').optional();
expect(getBaseRules(schema)).toBeInstanceOf(ZodString);
expect(getDefaultValueInZodStack(schema)).toBe('default value');
});
it('unwraps the input side of a transform pipe', () => {
const schema = z.string().transform((value) => value.length);
expect(getBaseRules(schema)).toBeInstanceOf(ZodString);
});
it('returns undefined when a schema rejects undefined', () => {
expect(getDefaultValueInZodStack(z.string())).toBeUndefined();
});
it('does not throw for an asynchronous default pipeline', () => {
const schema = z
.string()
.default('default value')
.transform(async (value) => value.toUpperCase());
expect(getDefaultValueInZodStack(schema)).toBeUndefined();
});
it('uses zod v4 error callbacks for required and invalid inputs', () => {
const schema = z.number({
error: (issue) =>
issue.input === undefined ? 'required' : 'invalid number',
});
expect(schema.safeParse(undefined).error?.issues[0]?.message).toBe(
'required',
);
expect(schema.safeParse('1').error?.issues[0]?.message).toBe(
'invalid number',
);
});
it('extracts defaults from intersections without private schema access', () => {
const schema = z.intersection(
z.object({ enabled: z.boolean().default(true), name: z.string() }),
z.object({ count: z.number(), note: z.string().default('note') }),
);
expect(getDefaultsForSchema(schema)).toEqual({
count: 0,
enabled: true,
name: '',
note: 'note',
});
});
it('keeps nullable and coerce input semantics explicit', () => {
expect(z.string().nullable().safeParse(undefined).success).toBe(false);
expect(z.coerce.number().parse('42')).toBe(42);
});
});

View File

@@ -1,21 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -1,6 +1,6 @@
{
"name": "@vben-core/form-ui",
"version": "5.5.1",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -11,7 +11,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"build": "pnpm exec tsdown",
"prepublishOnly": "npm run build"
},
"files": [
@@ -22,30 +22,38 @@
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"production": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@tanstack/store": "catalog:",
"@tanstack/vue-form": "catalog:",
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"zod": "catalog:",
"zod-defaults": "catalog:"
},
"devDependencies": {
"@vue/test-utils": "catalog:",
"unplugin-vue": "catalog:"
}
}

View File

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

View File

@@ -3,12 +3,7 @@ import { computed, toRaw, unref, watch } from 'vue';
import { useSimpleLocale } from '@vben-core/composables';
import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
import {
cn,
formatDate,
isFunction,
triggerWindowResize,
} from '@vben-core/shared/utils';
import { cn, isFunction, triggerWindowResize } from '@vben-core/shared/utils';
import { COMPONENT_MAP } from '../config';
import { injectFormProps } from '../use-form-context';
@@ -35,31 +30,15 @@ const submitButtonOptions = computed(() => {
};
});
// const isQueryForm = computed(() => {
// return !!unref(rootProps).showCollapseButton;
// });
const queryFormStyle = computed(() => {
if (!unref(rootProps).actionWrapperClass) {
return {
'grid-column': `-2 / -1`,
marginLeft: 'auto',
};
}
return {};
});
async function handleSubmit(e: Event) {
e?.preventDefault();
e?.stopPropagation();
const { valid } = await form.validate();
if (!valid) {
const props = unref(rootProps);
if (!props.formApi) {
return;
}
const values = handleRangeTimeValue(toRaw(form.values));
await unref(rootProps).handleSubmit?.(values);
await props.formApi.validateAndSubmit();
}
async function handleReset(e: Event) {
@@ -67,59 +46,15 @@ async function handleReset(e: Event) {
e?.stopPropagation();
const props = unref(rootProps);
const values = toRaw(form.values);
// 清理时间字段
props.fieldMappingTime &&
props.fieldMappingTime.forEach(([_, [startTimeKey, endTimeKey]]) => {
delete values[startTimeKey];
delete values[endTimeKey];
});
const values = toRaw(await props.formApi?.getValues()) ?? {};
if (isFunction(props.handleReset)) {
await props.handleReset?.(values);
} else {
form.resetForm();
form.reset();
}
}
function handleRangeTimeValue(values: Record<string, any>) {
const fieldMappingTime = unref(rootProps).fieldMappingTime;
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return values;
}
fieldMappingTime.forEach(
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
if (startTimeKey && endTimeKey && values[field] === null) {
delete values[startTimeKey];
delete values[endTimeKey];
}
if (!values[field]) {
delete values[field];
return;
}
const [startTime, endTime] = values[field];
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
? format
: [format, format];
values[startTimeKey] = startTime
? formatDate(startTime, startTimeFormat)
: undefined;
values[endTimeKey] = endTime
? formatDate(endTime, endTimeFormat)
: undefined;
delete values[field];
},
);
return values;
}
watch(
() => collapsed.value,
() => {
@@ -130,22 +65,59 @@ watch(
},
);
const actionWrapperClass = computed(() => {
const props = unref(rootProps);
const actionLayout = props.actionLayout || 'rowEnd';
const actionPosition = props.actionPosition || 'right';
const cls = [
'flex',
'items-center',
'gap-3',
props.compact ? 'pb-2' : 'pb-4',
props.layout === 'vertical' ? 'self-end' : 'self-center',
props.layout === 'inline' ? '' : 'w-full',
props.actionWrapperClass,
];
switch (actionLayout) {
case 'newLine': {
cls.push('col-span-full');
break;
}
case 'rowEnd': {
cls.push('col-[-2/-1]');
break;
}
// 'inline' 不需要额外类名,保持默认
}
switch (actionPosition) {
case 'center': {
cls.push('justify-center');
break;
}
case 'left': {
cls.push('justify-start');
break;
}
default: {
// case 'right': 默认右对齐
cls.push('justify-end');
break;
}
}
return cls.join(' ');
});
defineExpose({
handleReset,
handleSubmit,
});
</script>
<template>
<div
:class="
cn(
'col-span-full w-full text-right',
rootProps.compact ? 'pb-2' : 'pb-6',
rootProps.actionWrapperClass,
)
"
:style="queryFormStyle"
>
<div :class="cn(actionWrapperClass)">
<template v-if="rootProps.actionButtonsReverse">
<!-- 提交按钮前 -->
<slot name="submit-before"></slot>
@@ -153,7 +125,6 @@ defineExpose({
<component
:is="COMPONENT_MAP.PrimaryButton"
v-if="submitButtonOptions.show"
class="ml-3"
type="button"
@click="handleSubmit"
v-bind="submitButtonOptions"
@@ -168,7 +139,6 @@ defineExpose({
<component
:is="COMPONENT_MAP.DefaultButton"
v-if="resetButtonOptions.show"
class="ml-3"
type="button"
@click="handleReset"
v-bind="resetButtonOptions"
@@ -183,7 +153,6 @@ defineExpose({
<component
:is="COMPONENT_MAP.PrimaryButton"
v-if="submitButtonOptions.show"
class="ml-3"
type="button"
@click="handleSubmit"
v-bind="submitButtonOptions"
@@ -196,9 +165,9 @@ defineExpose({
<slot name="expand-before"></slot>
<VbenExpandableArrow
class="ml-[-0.3em]"
v-if="rootProps.showCollapseButton"
v-model:model-value="collapsed"
class="ml-2"
>
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
</VbenExpandableArrow>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
// oxlint-disable unicorn/no-nested-ternary
import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue';
import { Plus, X } from '@vben-core/icons';
import {
VbenButton,
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn, get, set } from '@vben-core/shared/utils';
import { injectRenderFormProps } from '../form-render/context';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
defineOptions({ name: 'VbenFormFieldArray', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
/**
* 新增一行时生成的默认数据;缺省时按 schema 的 fieldName 生成空对象
*/
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
/** 最多行数 */
max?: number;
/** 最少行数 */
min?: number;
/** 字段路径,由外层 FormField 通过 componentField 透传 */
name?: string;
/**
* 列定义,每一列就是一个子字段(复用 FormSchema)
*/
schema?: FormSchema[];
/** 是否显示序号列 */
showIndex?: boolean;
}>(),
{
actionText: '操作',
addButtonText: '添加一行',
createRow: undefined,
disabled: false,
emptyText: '暂无数据',
commonConfig: () => ({}),
globalCommonConfig: () => ({}),
max: Number.POSITIVE_INFINITY,
min: 0,
name: '',
schema: () => [],
showIndex: true,
},
);
const arrayPath = computed(() => props.name);
const formRenderProps = injectRenderFormProps();
const form = formRenderProps.form;
if (!form) {
throw new Error('Form api is required in <VbenFormFieldArray />');
}
const formActions = form;
const arrayLength = formActions.useSelector((state) => {
const value = get(state.values, props.name);
return Array.isArray(value) ? value.length : 0;
});
const rowIndexes = computed(() =>
Array.from({ length: arrayLength.value }, (_, index) => index),
);
const canAdd = computed(() => arrayLength.value < props.max);
const canRemove = computed(() => arrayLength.value > props.min);
const gridStyle = computed(() => {
const columns = [
...(props.showIndex ? ['3rem'] : []),
...props.schema.map(() => 'minmax(0, 1fr)'),
'4rem',
];
return {
gridTemplateColumns: columns.join(' '),
};
});
function buildDefaultRow(): Record<string, any> {
if (props.createRow) {
return props.createRow();
}
const row: Record<string, any> = {};
props.schema.forEach((col) => {
let value: any = null;
if (Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined) {
value = col.defaultValue;
} else if ('type' in col && col.type === 'array') {
value = [];
}
set(row, col.fieldName, value);
});
return row;
}
function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
formActions.pushFieldValue(arrayPath.value, buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
void formActions.removeFieldValue(arrayPath.value, index);
}
function rowSchemas(index: number) {
return props.schema.map((col) =>
createArrayChildSchema(col as never, {
arrayField: arrayPath.value,
commonConfig: props.commonConfig,
disabled: props.disabled,
globalCommonConfig: props.globalCommonConfig,
index,
}),
);
}
const normalizedRowSchemas = computed(() =>
Array.from({ length: arrayLength.value }, (_, index) => rowSchemas(index)),
);
</script>
<template>
<div :class="cn('w-full', $attrs.class as string)">
<div class="border-border/70 overflow-hidden rounded-md border">
<div
class="bg-muted/30 border-border hidden border-b px-2 sm:grid"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
#
</div>
<div
v-for="col in schema"
:key="col.fieldName"
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
<VbenRenderContent :content="col.label" />
</div>
<div
class="text-muted-foreground px-2 py-2 text-left text-sm font-normal"
>
{{ actionText }}
</div>
</div>
<div
v-for="index in rowIndexes"
:key="`${arrayPath}-${index}`"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>
<div
v-if="showIndex"
class="text-muted-foreground mb-2 text-sm sm:mb-0 sm:px-4 sm:py-3"
>
<span class="sm:hidden">#</span>
{{ index + 1 }}
</div>
<template
v-for="(childSchema, childIndex) in normalizedRowSchemas[index]"
:key="childSchema.fieldName"
>
<div class="min-w-0 py-2 sm:px-2">
<div
class="text-muted-foreground mb-1 text-xs font-medium sm:hidden"
>
<VbenRenderContent :content="schema?.[childIndex]?.label" />
</div>
<FormField
v-bind="childSchema"
:class="childSchema.formItemClass"
/>
</div>
</template>
<div class="flex justify-end pt-1 sm:block sm:px-2 sm:py-3">
<VbenIconButton
type="button"
:disabled="disabled || !canRemove"
:on-click="() => removeRow(index)"
class="text-muted-foreground hover:text-destructive"
>
<X class="size-4" />
</VbenIconButton>
</div>
</div>
<div
v-if="arrayLength === 0"
class="text-muted-foreground py-6 text-center text-sm"
>
{{ emptyText }}
</div>
</div>
<VbenButton
variant="outline"
size="sm"
type="button"
:disabled="disabled || !canAdd"
class="mt-3 w-full border-dashed"
@click="addRow"
>
<Plus class="mr-1 size-4" />
{{ addButtonText }}
</VbenButton>
</div>
</template>

View File

@@ -1,10 +1,11 @@
import type { Component } from 'vue';
import type {
BaseFormComponentType,
FormCommonConfig,
VbenFormAdapterOptions,
} from './types';
import type { Component } from 'vue';
import { h } from 'vue';
import {
@@ -17,7 +18,9 @@ import {
} from '@vben-core/shadcn-ui';
import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
import VbenFormFieldArray from './components/form-field-array.vue';
import { warnDeprecatedOnce } from './deprecation';
import { registerFormRules } from './rule-registry';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
@@ -27,6 +30,7 @@ export const COMPONENT_MAP: Record<BaseFormComponentType, Component> = {
DefaultButton: h(VbenButton, { size: 'sm', variant: 'outline' }),
PrimaryButton: h(VbenButton, { size: 'sm', variant: 'default' }),
VbenCheckbox,
VbenFormFieldArray,
VbenInput,
VbenInputPassword,
VbenPinInput,
@@ -42,24 +46,25 @@ export const COMPONENT_BIND_EVENT_MAP: Partial<
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
const { config, defineRules } = options;
const { config, defineRules, rules } = options;
const {
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
} = (config || {}) as FormCommonConfig;
const { changeEventFallback = false, emptyStateValue = undefined } =
(config || {}) as FormCommonConfig;
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
disabledOnChangeListener,
disabledOnInputListener,
changeEventFallback,
emptyStateValue,
});
if (defineRules) {
for (const key of Object.keys(defineRules)) {
defineRule(key, defineRules[key as never]);
}
warnDeprecatedOnce(
'setup-vben-form-define-rules',
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
registerFormRules(defineRules);
}
if (rules) {
registerFormRules(rules);
}
const baseModelPropName =

View File

@@ -0,0 +1,18 @@
const warnedDeprecations = new Set<string>();
export function resetDeprecationWarnings() {
warnedDeprecations.clear();
}
export function warnDeprecatedOnce(
key: string,
message: string,
options: { production?: boolean } = {},
) {
const production = options.production ?? import.meta.env.PROD;
if (production || warnedDeprecations.has(key)) {
return;
}
warnedDeprecations.add(key);
console.warn(message);
}

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