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:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 /> */
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
import './default.css';
|
||||
import './dark.css';
|
||||
|
||||
export {};
|
||||
|
||||
@@ -4,5 +4,3 @@ import './css/global.css';
|
||||
import './css/transition.css';
|
||||
import './css/nprogress.css';
|
||||
import './css/ui.css';
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
export default defineConfig(async () => {
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
11
packages/@core/base/icons/tsdown.config.ts
Normal file
11
packages/@core/base/icons/tsdown.config.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
418
packages/@core/base/shared/src/cache/README.md
vendored
Normal file
418
packages/@core/base/shared/src/cache/README.md
vendored
Normal 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。
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
137
packages/@core/base/shared/src/cache/indexeddb-driver.ts
vendored
Normal file
137
packages/@core/base/shared/src/cache/indexeddb-driver.ts
vendored
Normal 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 };
|
||||
70
packages/@core/base/shared/src/cache/local-storage-driver.ts
vendored
Normal file
70
packages/@core/base/shared/src/cache/local-storage-driver.ts
vendored
Normal 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 };
|
||||
32
packages/@core/base/shared/src/cache/memory-storage-driver.ts
vendored
Normal file
32
packages/@core/base/shared/src/cache/memory-storage-driver.ts
vendored
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
packages/@core/base/shared/src/cache/types.ts
vendored
46
packages/@core/base/shared/src/cache/types.ts
vendored
@@ -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 };
|
||||
|
||||
@@ -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 默认命名空间
|
||||
|
||||
@@ -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';
|
||||
|
||||
143
packages/@core/base/shared/src/utils/__tests__/date.test.ts
Normal file
143
packages/@core/base/shared/src/utils/__tests__/date.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
107
packages/@core/base/shared/src/utils/__tests__/stack.test.ts
Normal file
107
packages/@core/base/shared/src/utils/__tests__/stack.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function downloadFileFromUrl({
|
||||
|
||||
if (isChrome || isSafari) {
|
||||
triggerDownload(source, resolveFileName(source, fileName));
|
||||
return;
|
||||
}
|
||||
if (!source.includes('?')) {
|
||||
source += '?download';
|
||||
|
||||
@@ -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';
|
||||
|
||||
21
packages/@core/base/shared/src/utils/resources.ts
Normal file
21
packages/@core/base/shared/src/utils/resources.ts
Normal 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 };
|
||||
103
packages/@core/base/shared/src/utils/stack.ts
Normal file
103
packages/@core/base/shared/src/utils/stack.ts
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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]};`;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
18
packages/@core/base/shared/tsdown.config.ts
Normal file
18
packages/@core/base/shared/tsdown.config.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
30
packages/@core/base/typings/src/app.d.ts
vendored
30
packages/@core/base/typings/src/app.d.ts
vendored
@@ -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,
|
||||
};
|
||||
|
||||
9
packages/@core/base/typings/src/basic.d.ts
vendored
9
packages/@core/base/typings/src/basic.d.ts
vendored
@@ -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 };
|
||||
|
||||
39
packages/@core/base/typings/src/helper.d.ts
vendored
39
packages/@core/base/typings/src/helper.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
export type TabDefinition = RouteLocationNormalized;
|
||||
export interface TabDefinition extends RouteLocationNormalized {
|
||||
/**
|
||||
* 标签页的key
|
||||
*/
|
||||
key?: string;
|
||||
}
|
||||
|
||||
22
packages/@core/base/typings/src/vue-router.d.ts
vendored
22
packages/@core/base/typings/src/vue-router.d.ts
vendored
@@ -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>>;
|
||||
|
||||
|
||||
11
packages/@core/base/typings/tsdown.config.ts
Normal file
11
packages/@core/base/typings/tsdown.config.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
5
packages/@core/base/typings/vue-router.d.ts
vendored
5
packages/@core/base/typings/vue-router.d.ts
vendored
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -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:"
|
||||
},
|
||||
|
||||
129
packages/@core/composables/src/__tests__/use-scroll-lock.test.ts
Normal file
129
packages/@core/composables/src/__tests__/use-scroll-lock.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -10,4 +10,4 @@ export {
|
||||
useForwardExpose,
|
||||
useForwardProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue';
|
||||
} from 'reka-ui';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { computed, getCurrentInstance, unref, useAttrs, useSlots } from 'vue';
|
||||
|
||||
import {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '请确认',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
11
packages/@core/composables/tsdown.config.ts
Normal file
11
packages/@core/composables/tsdown.config.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
11
packages/@core/preferences/tsdown.config.ts
Normal file
11
packages/@core/preferences/tsdown.config.ts
Normal 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',
|
||||
}),
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
|
||||
81
packages/@core/ui-kit/form-ui/__tests__/form-codec.test.ts
Normal file
81
packages/@core/ui-kit/form-ui/__tests__/form-codec.test.ts
Normal 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'] });
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
1137
packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
Normal file
1137
packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||
);
|
||||
});
|
||||
236
packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts
Normal file
236
packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
73
packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts
Normal file
73
packages/@core/ui-kit/form-ui/__tests__/form-schema.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
Normal file
275
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
Normal 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']
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 =
|
||||
|
||||
18
packages/@core/ui-kit/form-ui/src/deprecation.ts
Normal file
18
packages/@core/ui-kit/form-ui/src/deprecation.ts
Normal 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
Reference in New Issue
Block a user