This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+5
View File
@@ -0,0 +1,5 @@
# base
基础共享包,请勿引入 workspace 依赖
-
@@ -0,0 +1,54 @@
{
"name": "@vben-core/design",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/base/design"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist",
"src"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"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"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@iconify-json/carbon": "catalog:",
"@iconify-json/fluent-mdl2": "catalog:",
"@iconify-json/ion": "catalog:",
"@iconify-json/logos": "catalog:",
"@iconify-json/lucide": "catalog:",
"@iconify-json/mdi": "catalog:"
}
}
@@ -0,0 +1 @@
@import '@vben/tailwind-config/theme';
@@ -0,0 +1,61 @@
@reference "@vben/tailwind-config/theme";
/* Make clicks pass-through */
#nprogress {
@apply pointer-events-none;
}
#nprogress .bar {
@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-25;
box-shadow:
0 0 10px hsl(var(--primary)),
0 0 5px hsl(var(--primary));
opacity: 1;
transform: rotate(3deg) translate(0, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
@apply fixed top-4 right-4 z-1031 block;
}
#nprogress .spinner-icon {
@apply border-t-primary border-l-primary size-4 rounded-full border-2 border-solid border-transparent;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
@apply relative overflow-hidden;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
@apply absolute;
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@@ -0,0 +1,238 @@
.slide-up-enter-active,
.slide-up-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-up-move {
transition: transform 0.3s;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(-15px);
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-down-move {
transition: transform 0.3s;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(15px);
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-left-move {
transition: transform 0.3s;
}
.slide-left-enter-from,
.slide-left-leave-to {
opacity: 0;
transform: translate(-15px);
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-right-move {
transition: transform 0.3s;
}
.slide-right-enter-from,
.slide-right-leave-to {
opacity: 0;
transform: translate(15px);
}
.fade-transition-enter-active,
.fade-transition-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-transition-enter-from,
.fade-transition-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-slide-leave-active,
.fade-slide-enter-active {
transition:
opacity 0.3s,
transform 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translate(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translate(30px);
}
.fade-down-enter-active,
.fade-down-leave-active {
transition:
opacity 0.25s,
transform 0.3s;
}
.fade-down-enter-from {
opacity: 0;
transform: translateY(-10%);
}
.fade-down-leave-to {
opacity: 0;
transform: translateY(10%);
}
.fade-scale-leave-active,
.fade-scale-enter-active {
transition: all 0.28s;
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(1.2);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
.fade-up-enter-active,
.fade-up-leave-active {
transition:
opacity 0.2s,
transform 0.25s;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(10%);
}
.fade-up-leave-to {
opacity: 0;
transform: translateY(-10%);
}
@keyframes fade-slide {
0% {
opacity: 0;
transform: translate(-30px);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(30px);
}
}
@keyframes fade {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-up {
0% {
opacity: 0;
transform: translateY(10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(-10%);
}
}
@keyframes fade-down {
0% {
opacity: 0;
transform: translateY(-10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(10%);
}
}
.fade-slow {
animation: fade 3s infinite;
}
.fade-slide-slow {
animation: fade-slide 3s infinite;
}
.fade-up-slow {
animation: fade-up 3s infinite;
}
.fade-down-slow {
animation: fade-down 3s infinite;
}
.collapse-transition {
transition:
0.2s height ease-in-out,
0.2s padding-top ease-in-out,
0.2s padding-bottom ease-in-out;
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition:
0.2s max-height ease-in-out,
0.2s padding-top ease-in-out,
0.2s margin-top ease-in-out;
}
@@ -0,0 +1,101 @@
.side-content {
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.side-content[data-side='top'] {
animation-name: slide-up;
}
.side-content[data-side='bottom'] {
animation-name: slide-down;
}
.side-content[data-side='left'] {
animation-name: slide-left;
}
.side-content[data-side='right'] {
animation-name: slide-right;
}
.breadcrumb-transition-enter-active {
transition:
transform 0.4s cubic-bezier(0.76, 0, 0.24, 1),
opacity 0.4s cubic-bezier(0.76, 0, 0.24, 1);
}
.breadcrumb-transition-leave-active {
display: none;
}
.breadcrumb-transition-enter-from {
opacity: 0;
transform: translateX(30px) skewX(-30deg);
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-left {
from {
opacity: 0;
transform: translateX(-50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-right {
from {
opacity: 0;
transform: translateX(50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
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);
}
}
@@ -0,0 +1,446 @@
.dark,
.dark[data-theme='custom'],
.dark[data-theme='default'] {
/* Default background color of <body />...etc */
--background: 222.34deg 10.43% 12.27%;
/* 主体区域背景色 */
--background-deep: 220deg 13.06% 9%;
--foreground: 0 0% 95%;
/* Background color for <Card /> */
--card: 222.34deg 10.43% 12.27%;
/* --card: 222.2 84% 4.9%; */
--card-foreground: 210 40% 98%;
/* Background color for popovers such as <DropdownMenu />, <HoverCard />, <Popover /> */
/* --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 /> */
/* --muted: 220deg 6.82% 17.25%; */
/* --muted-foreground: 215 20.2% 65.1%; */
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
/* 主题颜色 */
/* --primary: 245 82% 67%; */
--primary-foreground: 0 0% 98%;
/* Used for destructive actions such as <Button variant="destructive"> */
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 0% 98%;
/* Used for success actions such as <message> */
--info: 180, 1.54%, 12.75%;
--info-foreground: 220, 4%, 58%;
/* Used for success actions such as <message> */
--success: 144 57% 58%;
--success-foreground: 0 0% 98%;
/* Used for warning actions such as <message> */
--warning: 42 84% 61%;
--warning-foreground: 0 0% 98%;
/* 颜色次要 */
--secondary: 240 5% 17%;
--secondary-foreground: 0 0% 98%;
/* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */
--accent: 216 5% 19%;
--accent-dark: 240 0% 22%;
--accent-darker: 240 0% 26%;
--accent-lighter: 216 5% 12%;
--accent-hover: 216 5% 24%;
--accent-foreground: 0 0% 98%;
/* Darker color */
--heavy: 216 5% 24%;
--heavy-foreground: var(--accent-foreground);
/* Default border color */
--border: 240 3.7% 22%;
/* Border color for inputs such as <Input />, <Select />, <Textarea /> */
--input: 0deg 0% 100% / 10%;
--input-placeholder: 218deg 11% 65%;
--input-background: 0deg 0% 100% / 5%;
/* Used for focus ring */
--ring: 222.2 84% 4.9%;
/* 基本圆角大小 */
--radius: 0.5rem;
/* ============= Custom ============= */
/* 遮罩颜色 */
--overlay: 0deg 0% 0% / 40%;
--overlay-content: 0deg 0% 0% / 40%;
/* 基本文字大小 */
--font-size-base: 16px;
/* =============component & UI============= */
--sidebar: 222.34deg 10.43% 12.27%;
--sidebar-deep: 220deg 13.06% 9%;
--menu: var(--sidebar);
/* header */
--header: 222.34deg 10.43% 12.27%;
color-scheme: dark;
}
.dark[data-theme='violet'],
[data-theme='violet'] .dark {
--background: 224 71.4% 4.1%;
--background-deep: var(--background);
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
--sidebar: 224 71.4% 4.1%;
--sidebar-deep: 224 71.4% 4.1%;
--header: 224 71.4% 4.1%;
}
.dark[data-theme='pink'],
[data-theme='pink'] .dark {
--background: 20 14.3% 4.1%;
--background-deep: var(--background);
--foreground: 0 0% 95%;
--card: 0 0% 9%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 346.8 77.2% 49.8%;
--sidebar: 20 14.3% 4.1%;
--sidebar-deep: 20 14.3% 4.1%;
--header: 20 14.3% 4.1%;
}
.dark[data-theme='rose'],
[data-theme='rose'] .dark {
--background: 0 0% 3.9%;
--background-deep: var(--background);
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 85.7% 97.3%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 72.2% 50.6%;
--sidebar: 0 0% 3.9%;
--sidebar-deep: 0 0% 3.9%;
--header: 0 0% 3.9%;
}
.dark[data-theme='sky-blue'],
[data-theme='sky-blue'] .dark {
--background: 222.2 84% 4.9%;
--background-deep: var(--background);
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary-foreground: 210 20% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--sidebar: 222.2 84% 4.9%;
--sidebar-deep: 222.2 84% 4.9%;
--header: 222.2 84% 4.9%;
}
.dark[data-theme='deep-blue'],
[data-theme='deep-blue'] .dark {
--background: 222.2 84% 4.9%;
--background-deep: var(--background);
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary-foreground: 210 20% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--sidebar: 222.2 84% 4.9%;
--sidebar-deep: 222.2 84% 4.9%;
--header: 222.2 84% 4.9%;
}
.dark[data-theme='green'],
[data-theme='green'] .dark {
--background: 20 14.3% 4.1%;
--background-deep: var(--background);
--foreground: 0 0% 95%;
--card: 24 9.8% 6%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary-foreground: 210 20% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
--sidebar: 20 14.3% 4.1%;
--sidebar-deep: 20 14.3% 4.1%;
--header: 20 14.3% 4.1%;
}
.dark[data-theme='deep-green'],
[data-theme='deep-green'] .dark {
--background: 20 14.3% 4.1%;
--background-deep: var(--background);
--foreground: 0 0% 95%;
--card: 24 9.8% 6%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary-foreground: 210 20% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
--sidebar: 20 14.3% 4.1%;
--sidebar-deep: 20 14.3% 4.1%;
--header: 20 14.3% 4.1%;
}
.dark[data-theme='orange'],
[data-theme='orange'] .dark {
--background: 20 14.3% 4.1%;
--background-deep: var(--background);
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 20.5 90.2% 48.2%;
--sidebar: 20 14.3% 4.1%;
--sidebar-deep: 20 14.3% 4.1%;
--header: 20 14.3% 4.1%;
}
.dark[data-theme='yellow'],
[data-theme='yellow'] .dark {
--background: 20 14.3% 4.1%;
--background-deep: var(--background);
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary-foreground: 26 83.3% 14.1%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 35.5 91.7% 32.9%;
--sidebar: 20 14.3% 4.1%;
--sidebar-deep: 20 14.3% 4.1%;
--header: 20 14.3% 4.1%;
}
.dark[data-theme='zinc'],
[data-theme='zinc'] .dark {
--background: 240 10% 3.9%;
--background-deep: var(--background);
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--sidebar: 240 10% 3.9%;
--sidebar-deep: 240 10% 3.9%;
--header: 240 10% 3.9%;
}
.dark[data-theme='neutral'],
[data-theme='neutral'] .dark {
--background: 0 0% 3.9%;
--background-deep: var(--background);
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--sidebar: 0 0% 3.9%;
--sidebar-deep: 0 0% 3.9%;
--header: 0 0% 3.9%;
}
.dark[data-theme='slate'],
[data-theme='slate'] .dark {
--background: 222.2 84% 4.9%;
--background-deep: var(--background);
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9;
--sidebar: 222.2 84% 4.9%;
--sidebar-deep: 222.2 84% 4.9%;
--header: 222.2 84% 4.9%;
}
.dark[data-theme='gray'],
[data-theme='gray'] .dark {
--background: 224 71.4% 4.1%;
--background-deep: var(--background);
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 359.21 68.47% 56.47%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--sidebar: 224 71.4% 4.1%;
--sidebar-deep: 224 71.4% 4.1%;
--header: 224 71.4% 4.1%;
}
@@ -0,0 +1,491 @@
:root {
/** 弹出层的基础层级 **/
--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%;
/* 主体区域背景色 */
--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: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%; */
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
/* 主题颜色 */
--primary: 212 100% 45%;
--primary-foreground: 0 0% 98%;
/* Used for destructive actions such as <Button variant="destructive"> */
--destructive: 359.33 100% 65.1%;
--destructive-foreground: 0 0% 98%;
/* Used for success actions such as <message> */
--info: 240, 5%, 96%;
--info-foreground: 220, 4%, 58%;
/* Used for success actions such as <message> */
--success: 144 57% 58%;
--success-foreground: 0 0% 98%;
/* Used for warning actions such as <message> */
--warning: 42 84% 61%;
--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%;
/* Border radius for card, input and buttons */
--radius: 0.5rem;
/* ============= custom ============= */
/* 遮罩颜色 */
--overlay: 0 0% 0% / 45%;
--overlay-content: 0 0% 95% / 45%;
/* 基本文字大小 */
--font-size-base: 16px;
--menu-font-size: calc(var(--font-size-base) * 0.875);
/* =============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,
.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%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--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%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--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%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--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%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary-foreground: 26 83.3% 14.1%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.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%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--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%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
}
@@ -0,0 +1,2 @@
import './default.css';
import './dark.css';
@@ -0,0 +1,6 @@
import './design-tokens';
import './css/global.css';
import './css/transition.css';
import './css/nprogress.css';
import './css/ui.css';
@@ -0,0 +1,34 @@
@forward './constants';
@mixin b($block) {
$B: $namespace + '-' + $block !global;
.#{$B} {
@content;
}
}
@mixin e($name) {
@at-root {
&#{$element-separator}#{$name} {
@content;
}
}
}
@mixin m($name) {
@at-root {
&#{$modifier-separator}#{$name} {
@content;
}
}
}
// block__element.is-active {}
@mixin is($state, $prefix: $state-prefix) {
@at-root {
&.#{$prefix}-#{$state} {
@content;
}
}
}
@@ -0,0 +1,5 @@
$namespace: 'vben' !default;
$common-separator: '-' !default;
$element-separator: '__' !default;
$modifier-separator: '--' !default;
$state-prefix: 'is' !default;
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { defineConfig } from '@vben/vite-config';
export default defineConfig(async () => {
return {
vite: {
publicDir: 'src/scss-bem',
},
};
});
@@ -0,0 +1,42 @@
{
"name": "@vben-core/icons",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/base/icons"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm exec tsdown"
},
"files": [
"dist"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"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": {
"@iconify/vue": "catalog:",
"@lucide/vue": "catalog:",
"vue": "catalog:"
}
}
@@ -0,0 +1,14 @@
import { defineComponent, h } from 'vue';
import { Icon } from '@iconify/vue';
function createIconifyIcon(icon: string) {
return defineComponent({
name: `Icon-${icon}`,
setup(props, { attrs }) {
return () => h(Icon, { icon, ...props, ...attrs });
},
});
}
export { createIconifyIcon };
@@ -0,0 +1,11 @@
export * from './create-icon';
export * from './lucide';
export type { IconifyIcon as IconifyIconStructure } from '@iconify/vue';
export {
addCollection,
addIcon,
Icon as IconifyIcon,
listIcons,
} from '@iconify/vue';
@@ -0,0 +1,95 @@
export {
TextAlignCenter as AlignCenter,
TextAlignStart as AlignLeft,
TextAlignEnd as AlignRight,
ArrowDown,
ArrowLeft,
ArrowLeftToLine,
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,
Grid,
Grip,
GripVertical,
Heading1,
Heading2,
Highlighter,
Menu as IconDefault,
ImagePlus,
Inbox,
Info,
InspectionPanel,
Italic,
Languages,
LayoutGrid,
Link2,
List,
ListOrdered,
LoaderCircle,
LockKeyhole,
LogOut,
MailCheck,
Maximize,
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';
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -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,104 @@
{
"name": "@vben-core/shared",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/base/shared"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm exec tsdown",
"stub": "pnpm run build"
},
"files": [
"dist"
],
"sideEffects": false,
"exports": {
"./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": "./src/global-state.ts",
"development": "./src/global-state.ts",
"production": "./src/global-state.ts",
"default": "./dist/global-state.mjs"
}
},
"publishConfig": {
"exports": {
"./constants": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.mjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.mjs"
},
"./color": {
"types": "./dist/color/index.d.ts",
"default": "./dist/color/index.mjs"
},
"./cache": {
"types": "./dist/cache/index.d.ts",
"default": "./dist/cache/index.mjs"
},
"./store": {
"types": "./dist/store.d.ts",
"default": "./dist/store.mjs"
},
"./global-state": {
"types": "./dist/global-state.d.ts",
"default": "./dist/global-state.mjs"
}
}
},
"dependencies": {
"@ctrl/tinycolor": "catalog:",
"@tanstack/vue-store": "catalog:",
"@vue/shared": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"defu": "catalog:",
"es-toolkit": "catalog:",
"lodash.clonedeep": "catalog:",
"nprogress": "catalog:",
"tailwind-merge": "catalog:",
"theme-colors": "catalog:"
},
"devDependencies": {
"@types/lodash.clonedeep": "catalog:",
"@types/nprogress": "catalog:"
}
}
+418
View File
@@ -0,0 +1,418 @@
# Cache 模块
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API 接口。
## 架构设计
```shell
┌───────────────────────────────────────────────┐
│ StorageManager │
│ ┌─────────────┐ ┌───────────────────────┐ │
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │
│ └─────────────┘ └───────────────────────┘ │
├───────────────────────────────────────────────┤
│ IStorageDriver │
├──────────┬─────────────────┬──────────────────┤
│ Local │ IndexedDB │ Memory │
│ Storage │ Driver │ Driver │
│ Driver │ │ │
└──────────┴─────────────────┴──────────────────┘
```
**分层职责:**
| 层级 | 职责 |
| ---------------- | -------------------------------------------- |
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API |
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 |
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL |
---
## 快速开始
### 基本使用(默认 localStorage
```typescript
import { StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({ prefix: 'myapp' });
// 使用 IndexedDB
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' });
// 使用 sessionStorage
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' });
// 测试环境
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' });
// 存储数据
await cache.setItem('user', { name: '张三', age: 28 });
// 读取数据
const user = await cache.getItem('user');
// => { name: '张三', age: 28 }
// 带默认值读取
const settings = await cache.getItem('settings', { theme: 'light' });
// 如果不存在,返回 { theme: 'light' }
// 删除数据
await cache.removeItem('user');
// 清除当前前缀下所有数据
await cache.clear();
```
### 带 TTL 过期
```typescript
const cache = new StorageManager({ prefix: 'session' });
// 设置 5 分钟后过期(TTL 单位为毫秒)
await cache.setItem('token', 'abc123', 5 * 60 * 1000);
// 5 分钟内可以正常读取
const token = await cache.getItem('token');
// => 'abc123'
// 5 分钟后自动返回 null(惰性删除)
const expiredToken = await cache.getItem('token');
// => null
// 主动清理所有过期项
await cache.clearExpiredItems();
```
---
## 存储驱动
### LocalStorageDriver(默认)
基于浏览器 `localStorage``sessionStorage`,数据持久化存储。
```typescript
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache';
// 使用 localStorage(默认)
const cache = new StorageManager({
driver: new LocalStorageDriver(),
prefix: 'app',
});
// 使用 sessionStorage
const sessionCache = new StorageManager({
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }),
prefix: 'app',
});
```
**特点:**
- 同步 API 用 async 包装,保持接口统一
- 自动处理 JSON 序列化/反序列化
- 数据损坏时自动清除并返回 null
- 存储上限约 5-10MB(视浏览器而定)
**适用场景:** 用户偏好设置、小型配置数据、Token 存储
---
### IndexedDBDriver
基于浏览器 IndexedDB,支持大容量结构化数据存储。
```typescript
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new IndexedDBDriver({
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage'
dbVersion: 1, // 数据库版本,默认 1
storeName: 'cache-store', // 对象存储名称,默认 'kv-store'
}),
prefix: 'data',
});
// 存储大量数据
await cache.setItem('table-data', largeDataArray);
// 存储二进制友好的结构(IndexedDB 原生支持)
await cache.setItem('config', {
columns: [...],
filters: [...],
pagination: {page: 1, size: 20},
});
```
**特点:**
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()`
- 存储容量大(通常数百 MB 到 GB 级别)
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型)
- 天然异步,不阻塞主线程
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据
---
### MemoryStorageDriver
基于内存 Map,数据不持久化,页面刷新即丢失。
```typescript
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test',
});
```
**特点:**
- 读写速度最快
- 无浏览器 API 依赖
- 数据随页面生命周期销毁
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存
---
## API 参考
### StorageManager
#### 构造函数
```typescript
new StorageManager(options?: StorageManagerOptions)
```
| 参数 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 |
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 |
#### 方法
| 方法 | 签名 | 说明 |
| --- | --- | --- |
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 |
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) |
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 |
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 |
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 |
---
### IStorageDriver 接口
自定义驱动需要实现此接口:
```typescript
interface IStorageDriver {
clear(): Promise<void>;
getItem<T>(key: string): Promise<null | T>;
keys(): Promise<string[]>;
removeItem(key: string): Promise<void>;
setItem<T>(key: string, value: T): Promise<void>;
}
```
---
## 高级用法
### 自定义 Driver
```typescript
import type { IStorageDriver } from '@vben-core/shared/cache';
class CookieStorageDriver implements IStorageDriver {
async getItem<T>(key: string): Promise<null | T> {
const value = getCookie(key);
return value ? JSON.parse(value) : null;
}
async setItem<T>(key: string, value: T): Promise<void> {
setCookie(key, JSON.stringify(value));
}
async removeItem(key: string): Promise<void> {
deleteCookie(key);
}
async clear(): Promise<void> {
clearAllCookies();
}
async keys(): Promise<string[]> {
return getAllCookieNames();
}
}
// 使用自定义 Driver
const cache = new StorageManager({
driver: new CookieStorageDriver(),
prefix: 'ck',
});
```
### 根据环境动态选择 Driver
```typescript
import {
IndexedDBDriver,
LocalStorageDriver,
MemoryStorageDriver,
StorageManager,
} from '@vben-core/shared/cache';
function createStorageManager(prefix: string) {
// SSR 环境使用内存驱动
if (typeof window === 'undefined') {
return new StorageManager({
driver: new MemoryStorageDriver(),
prefix,
});
}
// 大数据场景使用 IndexedDB
if (needsLargeStorage()) {
return new StorageManager({
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
prefix,
});
}
// 默认使用 localStorage
return new StorageManager({ prefix });
}
```
### 命名空间隔离
```typescript
// 不同模块使用不同前缀,互不干扰
const userCache = new StorageManager({ prefix: 'user' });
const configCache = new StorageManager({ prefix: 'config' });
await userCache.setItem('profile', { name: '张三' });
await configCache.setItem('profile', { theme: 'dark' });
// 各自独立
await userCache.getItem('profile'); // => { name: '张三' }
await configCache.getItem('profile'); // => { theme: 'dark' }
// 只清除 user 前缀的数据
await userCache.clear();
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响)
```
### 定时清理过期数据
```typescript
const cache = new StorageManager({ prefix: 'app' });
// 应用启动时清理一次
await cache.clearExpiredItems();
// 或者定时清理(每 10 分钟)
setInterval(
async () => {
await cache.clearExpiredItems();
},
10 * 60 * 1000,
);
```
---
## 数据存储格式
`StorageManager` 在 Driver 层存储的数据结构为:
```typescript
interface StorageItem<T> {
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期
value: T; // 实际业务数据
}
```
实际存储的 key 格式为:`{prefix}-{key}`
例如 `prefix = 'app'``key = 'user'`,则实际存储键为 `app-user`
---
## 过期策略
采用**惰性删除 + 主动清理**双重策略:
| 策略 | 触发时机 | 说明 |
| --- | --- | --- |
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 |
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 |
---
## 各 Driver 对比
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
| ---------- | ------------------- | ---------------- | ------------------- |
| 持久化 | ✅ | ✅ | ❌ |
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 |
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 |
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 |
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 |
| 阻塞主线程 | 是 | 否 | 否 |
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR |
---
## 在项目中的使用
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置:
```typescript
// packages/@core/preferences/src/preferences.ts
class PreferenceManager {
private cache: StorageManager;
constructor() {
this.cache = new StorageManager();
this.state = reactive<Preferences>({ ...defaultPreferences });
}
initPreferences = async ({ namespace }) => {
// 用应用命名空间重新初始化
this.cache = new StorageManager({ prefix: namespace });
// 从缓存加载偏好设置
const cached = await this.cache.getItem<Preferences>('preferences');
// ...
};
}
```
---
## 注意事项
1. **所有方法都是异步的** — 即使底层是同步的 localStorageAPI 也返回 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。
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryStorageDriver } from '../memory-storage-driver';
import { StorageManager } from '../storage-manager';
describe('storageManager', () => {
let storageManager: StorageManager;
beforeEach(() => {
vi.useFakeTimers();
storageManager = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test_',
});
});
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', 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', 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', 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', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not clear non-expired items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
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', 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', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(5000);
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should remove expired items when accessed', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
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 = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
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); // 快进时间
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', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(10_000); // 快进时间
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
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();
});
});
@@ -0,0 +1,5 @@
export * from './indexeddb-driver';
export * from './local-storage-driver';
export * from './memory-storage-driver';
export * from './storage-manager';
export type * from './types';
@@ -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 };
@@ -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 };
@@ -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 };
@@ -0,0 +1,146 @@
import type {
IStorageDriver,
StorageItem,
StorageManagerOptions,
} from './types';
import { LocalStorageDriver } from './local-storage-driver';
import { MemoryStorageDriver } from './memory-storage-driver';
/**
* 存储管理器(策略模式)
* - prefix(命名空间隔离)在此层处理
* - TTL(过期机制)在此层处理
* - Driver 只负责纯粹的 KV 存取
*/
class StorageManager {
private driver: IStorageDriver;
private prefix: string;
constructor({ driver, prefix = '' }: StorageManagerOptions = {}) {
this.driver = driver || this.createDefaultDriver();
this.prefix = prefix;
if (!this.prefix && this.driver instanceof LocalStorageDriver) {
console.warn(
'[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.',
);
}
}
/**
* 清除所有带前缀的存储项
*/
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)));
}
/**
* 清除所有过期的存储项
*/
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);
}
}
}
/**
* 获取存储项
* @param key 键
* @param defaultValue 当项不存在或已过期时返回的默认值
* @returns 值,如果项已过期则返回默认值
*/
async getItem<T>(
key: string,
defaultValue: null | T = null,
): Promise<null | T> {
const fullKey = this.getFullKey(key);
const raw = await this.driver.getItem<StorageItem<T>>(fullKey);
if (!raw) {
return defaultValue;
}
// 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 键
*/
async removeItem(key: string): Promise<void> {
const fullKey = this.getFullKey(key);
await this.driver.removeItem(fullKey);
}
/**
* 设置存储项
* @param key 键
* @param value 值
* @param ttl 存活时间(毫秒)
*/
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<unknown> = { expiry, value };
await this.driver.setItem(fullKey, item);
}
/**
* 根据运行环境创建默认驱动:
* - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver
* - SSR / Node 环境 → MemoryStorageDriver
*/
private createDefaultDriver(): IStorageDriver {
try {
if (typeof window !== 'undefined' && window.localStorage) {
return new LocalStorageDriver();
}
} catch (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;
}
}
export { StorageManager };
+39
View File
@@ -0,0 +1,39 @@
/**
* 存储驱动接口(策略模式核心抽象)
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀
*/
interface IStorageDriver {
/** 清除所有存储项 */
clear(): Promise<void>;
/** 获取存储项 */
getItem<T>(key: string): Promise<null | T>;
/** 获取所有 key */
keys(): Promise<string[]>;
/** 移除存储项 */
removeItem(key: string): Promise<void>;
/** 设置存储项 */
setItem(key: string, value: unknown): Promise<void>;
}
/**
* 带 TTL 的存储项包装结构
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
*/
interface StorageItem<T> {
expiry?: number;
value: T;
}
interface StorageManagerOptions {
/** 存储驱动实例 */
driver?: IStorageDriver;
/** 键前缀,用于命名空间隔离 */
prefix?: string;
}
export type { IStorageDriver, StorageItem, StorageManagerOptions };
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
convertToHsl,
convertToHslCssVar,
convertToRgb,
isValidColor,
} from '../convert';
describe('color conversion functions', () => {
it('should correctly convert color to HSL format', () => {
const color = '#ff0000';
const expectedHsl = 'hsl(0 100% 50%)';
expect(convertToHsl(color)).toEqual(expectedHsl);
});
it('should correctly convert color with alpha to HSL format', () => {
const color = 'rgba(255, 0, 0, 0.5)';
const expectedHsl = 'hsl(0 100% 50%) 0.5';
expect(convertToHsl(color)).toEqual(expectedHsl);
});
it('should correctly convert color to HSL CSS variable format', () => {
const color = '#ff0000';
const expectedHsl = '0 100% 50%';
expect(convertToHslCssVar(color)).toEqual(expectedHsl);
});
it('should correctly convert color with alpha to HSL CSS variable format', () => {
const color = 'rgba(255, 0, 0, 0.5)';
const expectedHsl = '0 100% 50% / 0.5';
expect(convertToHslCssVar(color)).toEqual(expectedHsl);
});
it('should correctly convert color to RGB CSS variable format', () => {
const color = 'hsl(284, 100%, 50%)';
const expectedRgb = 'rgb(187, 0, 255)';
expect(convertToRgb(color)).toEqual(expectedRgb);
});
it('should correctly convert color with alpha to RGBA CSS variable format', () => {
const color = 'hsla(284, 100%, 50%, 0.92)';
const expectedRgba = 'rgba(187, 0, 255, 0.92)';
expect(convertToRgb(color)).toEqual(expectedRgba);
});
});
describe('isValidColor', () => {
it('isValidColor function', () => {
// 测试有效颜色
expect(isValidColor('blue')).toBe(true);
expect(isValidColor('#000000')).toBe(true);
// 测试无效颜色
expect(isValidColor('invalid color')).toBe(false);
expect(isValidColor()).toBe(false);
});
});
@@ -0,0 +1,9 @@
import { TinyColor } from '@ctrl/tinycolor';
export function isDarkColor(color: string) {
return new TinyColor(color).isDark();
}
export function isLightColor(color: string) {
return new TinyColor(color).isLight();
}
@@ -0,0 +1,62 @@
import { TinyColor } from '@ctrl/tinycolor';
/**
* 将颜色转换为HSL格式。
*
* HSL是一种颜色模型,包括色相(Hue)、饱和度(Saturation)和亮度(Lightness)三个部分。
*
* @param {string} color 输入的颜色。
* @returns {string} HSL格式的颜色字符串。
*/
function convertToHsl(color: string): string {
const { a, h, l, s } = new TinyColor(color).toHsl();
const hsl = `hsl(${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`;
return a < 1 ? `${hsl} ${a}` : hsl;
}
/**
* 将颜色转换为HSL CSS变量。
*
* 这个函数与convertToHsl函数类似,但是返回的字符串格式稍有不同,
* 以便可以作为CSS变量使用。
*
* @param {string} color 输入的颜色。
* @returns {string} 可以作为CSS变量使用的HSL格式的颜色字符串。
*/
function convertToHslCssVar(color: string): string {
const { a, h, l, s } = new TinyColor(color).toHsl();
const hsl = `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
return a < 1 ? `${hsl} / ${a}` : hsl;
}
/**
* 将颜色转换为RGB颜色字符串
* TinyColor无法处理hsl内包含'deg'、'grad'、'rad'或'turn'的字符串
* 比如 hsl(231deg 98% 65%)将被解析为rgb(0, 0, 0)
* 这里在转换之前先将这些单位去掉
* @param str 表示HLS颜色值的字符串
* @returns 如果颜色值有效,则返回对应的RGB颜色字符串;如果无效,则返回rgb(0, 0, 0)
*/
function convertToRgb(str: string): string {
return new TinyColor(str.replaceAll(/deg|grad|rad|turn/g, '')).toRgbString();
}
/**
* 检查颜色是否有效
* @param {string} color - 待检查的颜色
* 如果颜色有效返回true,否则返回false
*/
function isValidColor(color?: string) {
if (!color) {
return false;
}
return new TinyColor(color).isValid;
}
export {
convertToHsl,
convertToHslCssVar,
convertToRgb,
isValidColor,
TinyColor,
};
@@ -0,0 +1,45 @@
import { getColors } from 'theme-colors';
import { convertToHslCssVar, TinyColor } from './convert';
interface ColorItem {
alias?: string;
color: string;
name: string;
}
function generatorColorVariables(colorItems: ColorItem[]) {
const colorVariables: Record<string, string> = {};
colorItems.forEach(({ alias, color, name }) => {
if (color) {
const colorsMap = getColors(new TinyColor(color).toHexString());
let mainColor = colorsMap['500'];
const colorKeys = Object.keys(colorsMap);
colorKeys.forEach((key) => {
const colorValue = colorsMap[key];
if (colorValue) {
const hslColor = convertToHslCssVar(colorValue);
colorVariables[`--${name}-${key}`] = hslColor;
if (alias) {
colorVariables[`--${alias}-${key}`] = hslColor;
}
if (key === '500') {
mainColor = hslColor;
}
}
});
if (alias && mainColor) {
colorVariables[`--${alias}`] = mainColor;
}
}
});
return colorVariables;
}
export { generatorColorVariables };
@@ -0,0 +1,3 @@
export * from './color';
export * from './convert';
export * from './generator';
@@ -0,0 +1,20 @@
/** layout content 组件的高度 */
export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`;
/** layout content 组件的宽度 */
export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`;
/** layout header 组件的高度 */
export const CSS_VARIABLE_LAYOUT_HEADER_HEIGHT = `--vben-header-height`;
/** layout footer 组件的高度 */
export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`;
/** layout overlay 使用的视口高度,CSS 按 100vh → 100dvh 降级 */
export const CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT = `--vben-viewport-height`;
/** 内容区域的组件ID */
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
/** layout 滚动容器ID */
export const ELEMENT_ID_LAYOUT_SCROLL = `__vben_layout_scroll`;
/**
* @zh_CN 默认命名空间
*/
export const DEFAULT_NAMESPACE = 'vben';
@@ -0,0 +1,2 @@
export * from './globals';
export * from './vben';
@@ -0,0 +1,30 @@
/**
* @zh_CN GITHUB 仓库地址
*/
export const VBEN_GITHUB_URL = 'https://github.com/vbenjs/vue-vben-admin';
/**
* @zh_CN 文档地址
*/
export const VBEN_DOC_URL = 'https://doc.vben.pro';
/**
* @zh_CN Vben Logo
*/
export const VBEN_LOGO_URL =
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp';
/**
* @zh_CN Vben Admin 首页地址
*/
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';
@@ -0,0 +1,45 @@
/**
* 全局复用的变量、组件、配置,各个模块之间共享
* 通过单例模式实现,单例必须注意不受请求影响,例如用户信息这些需要根据请求获取的。后续如果有ssr需求,也不会影响
*/
interface ComponentsState {
[key: string]: any;
}
interface MessageState {
copyPreferencesSuccess?: (title: string, content?: string) => void;
}
export interface IGlobalSharedState {
components: ComponentsState;
message: MessageState;
}
class GlobalShareState {
#components: ComponentsState = {};
#message: MessageState = {};
/**
* 定义框架内部各个场景的消息提示
*/
public defineMessage({ copyPreferencesSuccess }: MessageState) {
this.#message = {
copyPreferencesSuccess,
};
}
public getComponents(): ComponentsState {
return this.#components;
}
public getMessage(): MessageState {
return this.#message;
}
public setComponents(value: ComponentsState) {
this.#components = value;
}
}
export const globalShareState = new GlobalShareState();
@@ -0,0 +1 @@
export * from '@tanstack/vue-store';
@@ -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');
});
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { diff, diffStrict } from '../diff';
describe('diff function', () => {
it('should return an empty object when comparing identical objects', () => {
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
expect(diff(obj1, obj2)).toEqual(undefined);
});
it('should detect simple changes in primitive values', () => {
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 3 };
expect(diff(obj1, obj2)).toEqual({ b: 3 });
});
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(diff(obj1, obj2)).toEqual({ b: { c: 3 } });
});
it('should handle array changes', () => {
const obj1 = { a: [1, 2, 3], b: 2 };
const obj2 = { a: [1, 2, 4], b: 2 };
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 };
expect(diff(obj1, obj2)).toEqual({ b: 2 });
});
it('should handle removed keys', () => {
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1 };
expect(diff(obj1, obj2)).toEqual(undefined);
});
it('should handle boolean value changes', () => {
const obj1 = { a: true, b: false };
const obj2 = { a: true, b: true };
expect(diff(obj1, obj2)).toEqual({ b: true });
});
it('should handle null and undefined values', () => {
const obj1 = { a: null, b: undefined };
const obj2: any = { a: 1, b: undefined };
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 } });
});
});
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ELEMENT_ID_LAYOUT_SCROLL } from '../../constants';
import {
getElementVisibleRect,
getLayoutScrollElement,
needsScrollbar,
} from '../dom';
describe('getElementVisibleRect', () => {
// 设置浏览器视口尺寸的 mock
beforeEach(() => {
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(
800,
);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(
1000,
);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return default rect if element is undefined', () => {
expect(getElementVisibleRect()).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
it('should return default rect if element is null', () => {
expect(getElementVisibleRect(null)).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
it('should return correct visible rect when element is fully visible', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the top', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 200,
height: 250,
left: 100,
right: 500,
top: -50,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 200,
height: 200,
left: 100,
right: 500,
top: 0,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the right', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 800,
right: 1200,
top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 800,
right: 1000,
top: 100,
width: 200,
});
});
it('should return all zeros when element is completely off-screen', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 1200,
height: 300,
left: 1100,
right: 1400,
top: 900,
width: 300,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 0,
height: 0,
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,183 @@
import { describe, expect, it } from 'vitest';
import {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isHttpUrl,
isObject,
isUndefined,
isWindow,
} from '../inference';
describe('isHttpUrl', () => {
it("should return true when given 'http://example.com'", () => {
expect(isHttpUrl('http://example.com')).toBe(true);
});
it("should return true when given 'https://example.com'", () => {
expect(isHttpUrl('https://example.com')).toBe(true);
});
it("should return false when given 'ftp://example.com'", () => {
expect(isHttpUrl('ftp://example.com')).toBe(false);
});
it("should return false when given 'example.com'", () => {
expect(isHttpUrl('example.com')).toBe(false);
});
});
describe('isUndefined', () => {
it('isUndefined should return true for undefined values', () => {
expect(isUndefined()).toBe(true);
});
it('isUndefined should return false for null values', () => {
expect(isUndefined(null)).toBe(false);
});
it('isUndefined should return false for defined values', () => {
expect(isUndefined(0)).toBe(false);
expect(isUndefined('')).toBe(false);
expect(isUndefined(false)).toBe(false);
});
it('isUndefined should return false for objects and arrays', () => {
expect(isUndefined({})).toBe(false);
expect(isUndefined([])).toBe(false);
});
});
describe('isEmpty', () => {
it('should return true for empty string', () => {
expect(isEmpty('')).toBe(true);
});
it('should return true for empty array', () => {
expect(isEmpty([])).toBe(true);
});
it('should return true for empty object', () => {
expect(isEmpty({})).toBe(true);
});
it('should return false for non-empty string', () => {
expect(isEmpty('hello')).toBe(false);
});
it('should return false for non-empty array', () => {
expect(isEmpty([1, 2, 3])).toBe(false);
});
it('should return false for non-empty object', () => {
expect(isEmpty({ a: 1 })).toBe(false);
});
it('should return true for null or undefined', () => {
expect(isEmpty(null)).toBe(true);
expect(isEmpty()).toBe(true);
});
it('should return false for number or boolean', () => {
expect(isEmpty(0)).toBe(false);
expect(isEmpty(true)).toBe(false);
});
});
describe('isWindow', () => {
it('should return true for the window object', () => {
expect(isWindow(window)).toBe(true);
});
it('should return false for other objects', () => {
expect(isWindow({})).toBe(false);
expect(isWindow([])).toBe(false);
expect(isWindow(null)).toBe(false);
});
});
describe('isBoolean', () => {
it('should return true for boolean values', () => {
expect(isBoolean(true)).toBe(true);
expect(isBoolean(false)).toBe(true);
});
it('should return false for non-boolean values', () => {
expect(isBoolean(null)).toBe(false);
expect(isBoolean(42)).toBe(false);
expect(isBoolean('string')).toBe(false);
expect(isBoolean({})).toBe(false);
expect(isBoolean([])).toBe(false);
});
});
describe('isObject', () => {
it('should return true for objects', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it('should return false for non-objects', () => {
expect(isObject(null)).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject('string')).toBe(false);
expect(isObject(true)).toBe(false);
expect(isObject([1, 2, 3])).toBe(true);
expect(isObject(new Date())).toBe(true);
expect(isObject(/regex/)).toBe(true);
});
});
describe('getFirstNonNullOrUndefined', () => {
describe('getFirstNonNullOrUndefined', () => {
it('should return the first non-null and non-undefined value for a number array', () => {
expect(getFirstNonNullOrUndefined<number>(undefined, null, 0, 42)).toBe(
0,
);
expect(getFirstNonNullOrUndefined<number>(null, undefined, 42, 123)).toBe(
42,
);
});
it('should return the first non-null and non-undefined value for a string array', () => {
expect(
getFirstNonNullOrUndefined<string>(undefined, null, '', 'hello'),
).toBe('');
expect(
getFirstNonNullOrUndefined<string>(null, undefined, 'test', 'world'),
).toBe('test');
});
it('should return undefined if all values are null or undefined', () => {
expect(getFirstNonNullOrUndefined(undefined, null)).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should work with a single value', () => {
expect(getFirstNonNullOrUndefined(42)).toBe(42);
expect(getFirstNonNullOrUndefined()).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should handle mixed types correctly', () => {
expect(
getFirstNonNullOrUndefined<number | object | string>(
undefined,
null,
'test',
123,
{ key: 'value' },
),
).toBe('test');
expect(
getFirstNonNullOrUndefined<number | object | string>(
null,
undefined,
[1, 2, 3],
'string',
),
).toEqual([1, 2, 3]);
});
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
} from '../letter';
describe('capitalizeFirstLetter', () => {
it('should capitalize the first letter of a string', () => {
expect(capitalizeFirstLetter('hello')).toBe('Hello');
expect(capitalizeFirstLetter('world')).toBe('World');
});
it('should handle empty strings', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('should handle single character strings', () => {
expect(capitalizeFirstLetter('a')).toBe('A');
expect(capitalizeFirstLetter('b')).toBe('B');
});
it('should not change the case of other characters', () => {
expect(capitalizeFirstLetter('hElLo')).toBe('HElLo');
});
});
describe('toLowerCaseFirstLetter', () => {
it('should convert the first letter to lowercase', () => {
expect(toLowerCaseFirstLetter('CommonAppName')).toBe('commonAppName');
expect(toLowerCaseFirstLetter('AnotherKeyExample')).toBe(
'anotherKeyExample',
);
});
it('should return the same string if the first letter is already lowercase', () => {
expect(toLowerCaseFirstLetter('alreadyLowerCase')).toBe('alreadyLowerCase');
});
it('should handle empty strings', () => {
expect(toLowerCaseFirstLetter('')).toBe('');
});
it('should handle single character strings', () => {
expect(toLowerCaseFirstLetter('A')).toBe('a');
expect(toLowerCaseFirstLetter('a')).toBe('a');
});
it('should handle strings with only one uppercase letter', () => {
expect(toLowerCaseFirstLetter('A')).toBe('a');
});
it('should handle strings with special characters', () => {
expect(toLowerCaseFirstLetter('!Special')).toBe('!Special');
expect(toLowerCaseFirstLetter('123Number')).toBe('123Number');
});
});
describe('toCamelCase', () => {
it('should return the key if parentKey is empty', () => {
expect(toCamelCase('child', '')).toBe('child');
});
it('should combine parentKey and key in camel case', () => {
expect(toCamelCase('child', 'parent')).toBe('parentChild');
});
it('should handle empty key and parentKey', () => {
expect(toCamelCase('', '')).toBe('');
});
it('should handle key with capital letters', () => {
expect(toCamelCase('Child', 'parent')).toBe('parentChild');
expect(toCamelCase('Child', 'Parent')).toBe('ParentChild');
});
});
describe('kebabToCamelCase', () => {
it('should convert kebab-case to camelCase correctly', () => {
expect(kebabToCamelCase('my-component-name')).toBe('myComponentName');
});
it('should handle multiple consecutive hyphens', () => {
expect(kebabToCamelCase('my--component--name')).toBe('myComponentName');
});
it('should trim leading and trailing hyphens', () => {
expect(kebabToCamelCase('-my-component-name-')).toBe('myComponentName');
});
it('should preserve the case of the first word', () => {
expect(kebabToCamelCase('My-component-name')).toBe('MyComponentName');
});
it('should convert a single word correctly', () => {
expect(kebabToCamelCase('component')).toBe('component');
});
it('should return an empty string if input is empty', () => {
expect(kebabToCamelCase('')).toBe('');
});
it('should handle strings with no hyphens', () => {
expect(kebabToCamelCase('mycomponentname')).toBe('mycomponentname');
});
it('should handle strings with only hyphens', () => {
expect(kebabToCamelCase('---')).toBe('');
});
it('should handle mixed case inputs', () => {
expect(kebabToCamelCase('my-Component-Name')).toBe('myComponentName');
});
});
@@ -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);
});
});
@@ -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]);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { StateHandler } from '../state-handler';
describe('stateHandler', () => {
it('should resolve when condition is set to true', async () => {
const handler = new StateHandler();
// 模拟异步设置 condition 为 true
setTimeout(() => {
handler.setConditionTrue(); // 明确触发 condition 为 true
}, 10);
// 等待条件被设置为 true
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
it('should resolve immediately if condition is already true', async () => {
const handler = new StateHandler();
handler.setConditionTrue(); // 提前设置为 true
// 立即 resolve,因为 condition 已经是 true
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
it('should reject when condition is set to false after waiting', async () => {
const handler = new StateHandler();
// 模拟异步设置 condition 为 false
setTimeout(() => {
handler.setConditionFalse(); // 明确触发 condition 为 false
}, 10);
// 等待过程中,期望 Promise 被 reject
await expect(handler.waitForCondition()).rejects.toThrow(
'Condition was set to false',
);
expect(handler.isConditionTrue()).toBe(false);
});
it('should reset condition to false', () => {
const handler = new StateHandler();
handler.setConditionTrue(); // 设置为 true
handler.reset(); // 重置为 false
expect(handler.isConditionTrue()).toBe(false);
});
it('should resolve when condition is set to true after reset', async () => {
const handler = new StateHandler();
handler.reset(); // 确保初始为 false
setTimeout(() => {
handler.setConditionTrue(); // 重置后设置为 true
}, 10);
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
});
@@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest';
import { filterTree, mapTree, traverseTreeValues } from '../tree';
describe('traverseTreeValues', () => {
interface Node {
children?: Node[];
name: string;
}
type NodeValue = string;
const sampleTree: Node[] = [
{
name: 'A',
children: [
{ name: 'B' },
{
name: 'C',
children: [{ name: 'D' }, { name: 'E' }],
},
],
},
{
name: 'F',
children: [
{ name: 'G' },
{
name: 'H',
children: [{ name: 'I' }],
},
],
},
];
it('traverses tree and returns all node values', () => {
const values = traverseTreeValues<Node, NodeValue>(
sampleTree,
(node) => node.name,
{
childProps: 'children',
},
);
expect(values).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']);
});
it('handles empty tree', () => {
const values = traverseTreeValues<Node, NodeValue>([], (node) => node.name);
expect(values).toEqual([]);
});
it('handles tree with only root node', () => {
const rootNode = { name: 'A' };
const values = traverseTreeValues<Node, NodeValue>(
[rootNode],
(node) => node.name,
);
expect(values).toEqual(['A']);
});
it('handles tree with only leaf nodes', () => {
const leafNodes = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
const values = traverseTreeValues<Node, NodeValue>(
leafNodes,
(node) => node.name,
);
expect(values).toEqual(['A', 'B', 'C']);
});
});
describe('filterTree', () => {
const tree = [
{
id: 1,
children: [
{ id: 2 },
{ id: 3, children: [{ id: 4 }, { id: 5 }, { id: 6 }] },
{ id: 7 },
],
},
{ id: 8, children: [{ id: 9 }, { id: 10 }] },
{ id: 11 },
];
it('should return all nodes when condition is always true', () => {
const result = filterTree(tree, () => true, { childProps: 'children' });
expect(result).toEqual(tree);
});
it('should return only root nodes when condition is always false', () => {
const result = filterTree(tree, () => false);
expect(result).toEqual([]);
});
it('should return nodes with even id values', () => {
const result = filterTree(tree, (node) => node.id % 2 === 0);
expect(result).toEqual([{ id: 8, children: [{ id: 10 }] }]);
});
it('should return nodes with odd id values and their ancestors', () => {
const result = filterTree(tree, (node) => node.id % 2 === 1);
expect(result).toEqual([
{
id: 1,
children: [{ id: 3, children: [{ id: 5 }] }, { id: 7 }],
},
{ id: 11 },
]);
});
it('should return nodes with "leaf" in their name', () => {
const tree = [
{
name: 'root',
children: [
{ name: 'leaf 1' },
{
name: 'branch',
children: [{ name: 'leaf 2' }, { name: 'leaf 3' }],
},
{ name: 'leaf 4' },
],
},
];
const result = filterTree(
tree,
(node) => node.name.includes('leaf') || node.name === 'root',
);
expect(result).toEqual([
{
name: 'root',
children: [{ name: 'leaf 1' }, { name: 'leaf 4' }],
},
]);
});
});
describe('mapTree', () => {
it('map infinite depth tree using mapTree', () => {
const tree = [
{
id: 1,
name: 'node1',
children: [
{ id: 2, name: 'node2' },
{ id: 3, name: 'node3' },
{
id: 4,
name: 'node4',
children: [
{
id: 5,
name: 'node5',
children: [
{ id: 6, name: 'node6' },
{ id: 7, name: 'node7' },
],
},
{ id: 8, name: 'node8' },
],
},
],
},
];
const newTree = mapTree(tree, (node) => ({
...node,
name: `${node.name}-new`,
}));
expect(newTree).toEqual([
{
id: 1,
name: 'node1-new',
children: [
{ id: 2, name: 'node2-new' },
{ id: 3, name: 'node3-new' },
{
id: 4,
name: 'node4-new',
children: [
{
id: 5,
name: 'node5-new',
children: [
{ id: 6, name: 'node6-new' },
{ id: 7, name: 'node7-new' },
],
},
{ id: 8, name: 'node8-new' },
],
},
],
},
]);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { uniqueByField } from '../unique';
describe('uniqueByField', () => {
it('should return an array with unique items based on id field', () => {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
expect(uniqueItems).toHaveLength(3);
expect(uniqueItems).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
]);
});
it('should return an empty array when input array is empty', () => {
const items: any[] = []; // Empty array
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toEqual([]);
});
it('should handle arrays with only one item correctly', () => {
const items = [{ id: 1, name: 'Item 1' }];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toHaveLength(1);
expect(uniqueItems).toEqual([{ id: 1, name: 'Item 1' }]);
});
it('should preserve the order of the first occurrence of each item', () => {
const items = [
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results (order of first occurrences preserved)
expect(uniqueItems).toEqual([
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
]);
});
});
@@ -0,0 +1,48 @@
import { expect, it } from 'vitest';
import { updateCSSVariables } from '../update-css-variables';
it('updateCSSVariables should update CSS variables in :root selector', () => {
// 模拟初始的内联样式表内容
const initialStyleContent = ':root { --primaryColor: red; }';
document.head.innerHTML = `<style id="custom-styles">${initialStyleContent}</style>`;
// 要更新的CSS变量和它们的新值
const updatedVariables = {
fontSize: '16px',
primaryColor: 'blue',
secondaryColor: 'green',
};
// 调用函数来更新CSS变量
updateCSSVariables(updatedVariables, 'custom-styles');
// 获取更新后的样式内容
const styleElement = document.querySelector('#custom-styles');
const updatedStyleContent = styleElement ? styleElement.textContent : '';
// 检查更新后的样式内容是否包含正确的更新值
expect(
updatedStyleContent?.includes('primaryColor: blue;') &&
updatedStyleContent?.includes('secondaryColor: green;') &&
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);
});
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest';
import { bindMethods, getNestedValue } from '../util';
class TestClass {
public value: string;
constructor(value: string) {
this.value = value;
bindMethods(this); // 调用通用方法
}
getValue() {
return this.value;
}
setValue(newValue: string) {
this.value = newValue;
}
}
describe('bindMethods', () => {
it('should bind methods to the instance correctly', () => {
const instance = new TestClass('initial');
// 解构方法
const { getValue } = instance;
// 检查 getValue 是否能正确调用,并且 this 绑定了 instance
expect(getValue()).toBe('initial');
});
it('should bind multiple methods', () => {
const instance = new TestClass('initial');
const { getValue, setValue } = instance;
// 检查 getValue 和 setValue 方法是否正确绑定了 this
setValue('newValue');
expect(getValue()).toBe('newValue');
});
it('should not bind non-function properties', () => {
const instance = new TestClass('initial');
// 检查普通属性是否保持原样
expect(instance.value).toBe('initial');
});
it('should not bind constructor method', () => {
const instance = new TestClass('test');
// 检查 constructor 是否没有被绑定
expect(instance.constructor.name).toBe('TestClass');
});
it('should not bind getter/setter properties', () => {
class TestWithGetterSetter {
get value() {
return this._value;
}
set value(newValue: string) {
this._value = newValue;
}
private _value: string = 'test';
constructor() {
bindMethods(this);
}
}
const instance = new TestWithGetterSetter();
const { value } = instance;
// Getter 和 setter 不应被绑定
expect(value).toBe('test');
});
});
describe('getNestedValue', () => {
interface UserProfile {
age: number;
name: string;
}
interface UserSettings {
theme: string;
}
interface Data {
user: {
profile: UserProfile;
settings: UserSettings;
};
}
const data: Data = {
user: {
profile: {
age: 25,
name: 'Alice',
},
settings: {
theme: 'dark',
},
},
};
it('should get a nested value when the path is valid', () => {
const result = getNestedValue(data, 'user.profile.name');
expect(result).toBe('Alice');
});
it('should return undefined for non-existent property', () => {
const result = getNestedValue(data, 'user.profile.gender');
expect(result).toBeUndefined();
});
it('should return undefined when accessing a non-existent deep path', () => {
const result = getNestedValue(data, 'user.nonexistent.field');
expect(result).toBeUndefined();
});
it('should return undefined if a middle level is undefined', () => {
const result = getNestedValue({ user: undefined }, 'user.profile.name');
expect(result).toBeUndefined();
});
it('should return the correct value for a nested setting', () => {
const result = getNestedValue(data, 'user.settings.theme');
expect(result).toBe('dark');
});
it('should work for a single-level path', () => {
const result = getNestedValue({ a: 1, b: 2 }, 'b');
expect(result).toBe(2);
});
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', () => {
const complexData = { list: [{ name: 'Item1' }, { name: 'Item2' }] };
const result = getNestedValue(complexData, 'list.1.name');
expect(result).toBe('Item2');
});
it('should return undefined when accessing an out-of-bounds array index', () => {
const complexData = { list: [{ name: 'Item1' }] };
const result = getNestedValue(complexData, 'list.2.name');
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { openWindow } from '../window';
describe('openWindow', () => {
// 保存原始的 window.open 函数
let originalOpen: typeof window.open;
beforeEach(() => {
originalOpen = window.open;
});
afterEach(() => {
window.open = originalOpen;
});
it('should call window.open with correct arguments', () => {
const url = 'https://example.com';
const options = { noopener: true, noreferrer: true, target: '_blank' };
window.open = vi.fn();
// 调用函数
openWindow(url, options);
// 验证 window.open 是否被正确地调用
expect(window.open).toHaveBeenCalledWith(
url,
options.target,
'noopener=yes,noreferrer=yes',
);
});
});
@@ -0,0 +1,10 @@
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export { cn };
@@ -0,0 +1,78 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
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.isDayjs(time) ? time : dayjs(time);
if (!date.isValid()) {
throw new Error('Invalid date');
}
return date.tz().format(format);
} catch (error) {
console.error(`Error formatting date: ${error}`);
return String(time ?? '');
}
}
export function formatDateTime(time?: FormatDate) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}
export function isDate(value: any): value is Date {
return value instanceof 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;
};
@@ -0,0 +1,114 @@
// type Diff<T = any> = T;
// 比较两个数组是否相等(忽略顺序)
function arraysEqual<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false;
const counter = new Map<T, number>();
for (const value of a) {
counter.set(value, (counter.get(value) || 0) + 1);
}
for (const value of b) {
const count = counter.get(value);
if (count === undefined || count === 0) {
return false;
}
counter.set(value, count - 1);
}
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 (
// typeof oldVal === 'object' &&
// oldVal !== null &&
// typeof newVal === 'object' &&
// newVal !== null
// ) {
// return Array.isArray(oldVal) && Array.isArray(newVal)
// ? arraysEqual(oldVal, newVal)
// : diff(oldVal as any, newVal as any) === null;
// } else {
// return oldVal === newVal;
// }
// }
// // diff 函数
// function diff<T extends object>(
// oldObj: T,
// newObj: T,
// ignoreFields: (keyof T)[] = [],
// ): { [K in keyof T]?: Diff<T[K]> } | null {
// const difference: { [K in keyof T]?: Diff<T[K]> } = {};
// for (const key in oldObj) {
// if (ignoreFields.includes(key)) continue;
// const oldValue = oldObj[key];
// const newValue = newObj[key];
// if (!deepEqual(oldValue, newValue)) {
// difference[key] = newValue;
// }
// }
// return Object.keys(difference).length === 0 ? null : difference;
// }
type DiffResult<T> = Partial<{
[K in keyof T]: T[K] extends object ? DiffResult<T[K]> : T[K];
}>;
type ArrayComparator = (a: any[], b: any[]) => boolean;
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;
}
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 findDifferences(obj1, obj2);
};
}
// 数组比较(不含顺序)
const diff = createDiff(arraysEqual);
// 数组比较(含顺序)
const diffStrict = createDiff(arraysStrictEqual);
export { arraysEqual, arraysStrictEqual, diff, diffStrict };
@@ -0,0 +1,126 @@
import { ELEMENT_ID_LAYOUT_SCROLL } from '../constants/globals';
export interface VisibleDomRect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
}
/**
* 获取元素可见信息
* @param element
*/
export function getElementVisibleRect(
element?: HTMLElement | null | undefined,
): VisibleDomRect {
if (!element) {
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
}
const rect = element.getBoundingClientRect();
const viewHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight,
);
const top = Math.max(rect.top, 0);
const bottom = Math.min(rect.bottom, viewHeight);
const viewWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth,
);
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),
left,
right,
top,
width: Math.max(0, right - left),
};
}
export function getScrollbarWidth() {
const scrollDiv = document.createElement('div');
scrollDiv.style.visibility = 'hidden';
scrollDiv.style.overflow = 'scroll';
scrollDiv.style.position = 'absolute';
scrollDiv.style.top = '-9999px';
document.body.append(scrollDiv);
const innerDiv = document.createElement('div');
scrollDiv.append(innerDiv);
const scrollbarWidth = scrollDiv.offsetWidth - innerDiv.offsetWidth;
scrollDiv.remove();
return scrollbarWidth;
}
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;
// 检查 body 的 overflow-y 样式
const overflowY = window.getComputedStyle(body).overflowY;
// 如果明确设置了需要滚动条的样式
if (overflowY === 'scroll' || overflowY === 'auto') {
return doc.scrollHeight > window.innerHeight;
}
// 在其他情况下,根据 scrollHeight 和 innerHeight 比较判断
return doc.scrollHeight > window.innerHeight;
}
export function triggerWindowResize(): void {
// 创建一个新的 resize 事件
const resizeEvent = new Event('resize');
// 触发 window 的 resize 事件
window.dispatchEvent(resizeEvent);
}
@@ -0,0 +1,157 @@
import { openWindow } from './window';
interface DownloadOptions<T = string> {
fileName?: string;
source: T;
target?: string;
}
const DEFAULT_FILENAME = 'downloaded_file';
/**
* 通过 URL 下载文件,支持跨域
* @throws {Error} - 当下载失败时抛出错误
*/
export async function downloadFileFromUrl({
fileName,
source,
target = '_blank',
}: DownloadOptions): Promise<void> {
if (!source || typeof source !== 'string') {
throw new Error('Invalid URL.');
}
const isChrome = window.navigator.userAgent.toLowerCase().includes('chrome');
const isSafari = window.navigator.userAgent.toLowerCase().includes('safari');
if (/iP/.test(window.navigator.userAgent)) {
console.error('Your browser does not support download!');
return;
}
if (isChrome || isSafari) {
triggerDownload(source, resolveFileName(source, fileName));
return;
}
if (!source.includes('?')) {
source += '?download';
}
openWindow(source, { target });
}
/**
* 通过 Base64 下载文件
*/
export function downloadFileFromBase64({ fileName, source }: DownloadOptions) {
if (!source || typeof source !== 'string') {
throw new Error('Invalid Base64 data.');
}
const resolvedFileName = fileName || DEFAULT_FILENAME;
triggerDownload(source, resolvedFileName);
}
/**
* 通过图片 URL 下载图片文件
*/
export async function downloadFileFromImageUrl({
fileName,
source,
}: DownloadOptions) {
const base64 = await urlToBase64(source);
downloadFileFromBase64({ fileName, source: base64 });
}
/**
* 通过 Blob 下载文件
*/
export function downloadFileFromBlob({
fileName = DEFAULT_FILENAME,
source,
}: DownloadOptions<Blob>): void {
if (!(source instanceof Blob)) {
throw new TypeError('Invalid Blob data.');
}
const url = URL.createObjectURL(source);
triggerDownload(url, fileName);
}
/**
* 下载文件,支持 Blob、字符串和其他 BlobPart 类型
*/
export function downloadFileFromBlobPart({
fileName = DEFAULT_FILENAME,
source,
}: DownloadOptions<BlobPart>): void {
// 如果 data 不是 Blob,则转换为 Blob
const blob =
source instanceof Blob
? source
: new Blob([source], { type: 'application/octet-stream' });
// 创建对象 URL 并触发下载
const url = URL.createObjectURL(blob);
triggerDownload(url, fileName);
}
/**
* img url to base64
* @param url
*/
export function urlToBase64(url: string, mineType?: string): Promise<string> {
return new Promise((resolve, reject) => {
let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null;
const ctx = canvas?.getContext('2d');
const img = new Image();
img.crossOrigin = '';
img.addEventListener('load', () => {
if (!canvas || !ctx) {
return reject(new Error('Failed to create canvas.'));
}
canvas.height = img.height;
canvas.width = img.width;
ctx.drawImage(img, 0, 0);
const dataURL = canvas.toDataURL(mineType || 'image/png');
canvas = null;
resolve(dataURL);
});
img.src = url;
});
}
/**
* 通用下载触发函数
* @param href - 文件下载的 URL
* @param fileName - 下载文件的名称,如果未提供则自动识别
* @param revokeDelay - 清理 URL 的延迟时间 (毫秒)
*/
export function triggerDownload(
href: string,
fileName: string | undefined,
revokeDelay: number = 100,
): void {
const defaultFileName = 'downloaded_file';
const finalFileName = fileName || defaultFileName;
const link = document.createElement('a');
link.href = href;
link.download = finalFileName;
link.style.display = 'none';
if (link.download === undefined) {
link.setAttribute('target', '_blank');
}
document.body.append(link);
link.click();
link.remove();
// 清理临时 URL 以释放内存
setTimeout(() => URL.revokeObjectURL(href), revokeDelay);
}
function resolveFileName(url: string, fileName?: string): string {
return fileName || url.slice(url.lastIndexOf('/') + 1) || DEFAULT_FILENAME;
}
@@ -0,0 +1,21 @@
export * from './cn';
export * from './date';
export * from './diff';
export * from './dom';
export * from './download';
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';
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';
@@ -0,0 +1,164 @@
import { isFunction, isObject, isString } from '@vue/shared';
/**
* 检查传入的值是否为undefined。
*
* @param {unknown} value 要检查的值。
* @returns {boolean} 如果值是undefined,返回true,否则返回false。
*/
function isUndefined(value?: unknown): value is undefined {
return value === undefined;
}
/**
* 检查传入的值是否为boolean
* @param value
* @returns 如果值是布尔值,返回true,否则返回false。
*/
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
/**
* 检查传入的值是否为空。
*
* 以下情况将被认为是空:
* - 值为null。
* - 值为undefined。
* - 值为一个空字符串。
* - 值为一个长度为0的数组。
* - 值为一个没有元素的Map或Set。
* - 值为一个没有属性的对象。
*
* @param {T} value 要检查的值。
* @returns {boolean} 如果值为空,返回true,否则返回false。
*/
function isEmpty<T = unknown>(value?: T): value is T {
if (value === null || value === undefined) {
return true;
}
if (Array.isArray(value) || isString(value)) {
return value.length === 0;
}
if (value instanceof Map || value instanceof Set) {
return value.size === 0;
}
if (isObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
/**
* 检查传入的字符串是否为有效的HTTP或HTTPS URL。
*
* @param {string} url 要检查的字符串。
* @return {boolean} 如果字符串是有效的HTTP或HTTPS URL,返回true,否则返回false。
*/
function isHttpUrl(url?: string): boolean {
if (!url) {
return false;
}
// 使用正则表达式测试URL是否以http:// 或 https:// 开头
const httpRegex = /^https?:\/\/.*$/;
return httpRegex.test(url);
}
/**
* 检查传入的值是否为window对象。
*
* @param {any} value 要检查的值。
* @returns {boolean} 如果值是window对象,返回true,否则返回false。
*/
function isWindow(value: any): value is Window {
return (
typeof window !== 'undefined' && value !== null && value === value.window
);
}
/**
* 检查当前运行环境是否为Mac OS。
*
* 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
* 如果userAgent字符串中包含"macintosh"或"mac os x"(不区分大小写),则认为当前环境是Mac OS。
*
* @returns {boolean} 如果当前环境是Mac OS,返回true,否则返回false。
*/
function isMacOs(): boolean {
const macRegex = /macintosh|mac os x/i;
return macRegex.test(navigator.userAgent);
}
/**
* 检查当前运行环境是否为Windows OS。
*
* 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
* 如果userAgent字符串中包含"windows"或"win32"(不区分大小写),则认为当前环境是Windows OS。
*
* @returns {boolean} 如果当前环境是Windows OS,返回true,否则返回false。
*/
function isWindowsOs(): boolean {
const windowsRegex = /windows|win32/i;
return windowsRegex.test(navigator.userAgent);
}
/**
* 检查传入的值是否为数字
* @param value
*/
function isNumber(value: any): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
* Returns the first value in the provided list that is neither `null` nor `undefined`.
*
* This function iterates over the input values and returns the first one that is
* not strictly equal to `null` or `undefined`. If all values are either `null` or
* `undefined`, it returns `undefined`.
*
* @template T - The type of the input values.
* @param {...(T | null | undefined)[]} values - A list of values to evaluate.
* @returns {T | undefined} - The first value that is not `null` or `undefined`, or `undefined` if none are found.
*
* @example
* // Returns 42 because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(undefined, null, 42, 'hello'); // 42
*
* @example
* // Returns 'hello' because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(null, undefined, 'hello', 123); // 'hello'
*
* @example
* // Returns undefined because all values are either null or undefined.
* getFirstNonNullOrUndefined(undefined, null); // undefined
*/
function getFirstNonNullOrUndefined<T>(
...values: (null | T | undefined)[]
): T | undefined {
for (const value of values) {
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
}
export {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isFunction,
isHttpUrl,
isMacOs,
isNumber,
isObject,
isString,
isUndefined,
isWindow,
isWindowsOs,
};
@@ -0,0 +1,47 @@
/**
* 将字符串的首字母大写
* @param string
*/
function capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* 将字符串的首字母转换为小写。
*
* @param str 要转换的字符串
* @returns 首字母小写的字符串
*/
function toLowerCaseFirstLetter(str: string): string {
if (!str) return str; // 如果字符串为空,直接返回
return str.charAt(0).toLowerCase() + str.slice(1);
}
/**
* 生成驼峰命名法的键名
* @param key
* @param parentKey
*/
function toCamelCase(key: string, parentKey: string): string {
if (!parentKey) {
return key;
}
return parentKey + key.charAt(0).toUpperCase() + key.slice(1);
}
function kebabToCamelCase(str: string): string {
return str
.split('-')
.filter(Boolean)
.map((word, index) =>
index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1),
)
.join('');
}
export {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
};
@@ -0,0 +1,10 @@
import { createDefu } from 'defu';
export { createDefu as createMerge, defu as merge } from 'defu';
export const mergeWithArrayOverride = createDefu((originObj, key, updates) => {
if (Array.isArray(originObj[key]) && Array.isArray(updates)) {
originObj[key] = updates;
return true;
}
});
@@ -0,0 +1,43 @@
import type NProgress from 'nprogress';
// 创建一个NProgress实例的变量,初始值为null
let nProgressInstance: null | typeof NProgress = null;
/**
* 动态加载NProgress库,并进行配置。
* 此函数首先检查是否已经加载过NProgress库,如果已经加载过,则直接返回NProgress实例。
* 否则,动态导入NProgress库,进行配置,然后返回NProgress实例。
*
* @returns NProgress实例的Promise对象。
*/
async function loadNprogress() {
if (nProgressInstance) {
return nProgressInstance;
}
nProgressInstance = await import('nprogress');
nProgressInstance.configure({
showSpinner: true,
speed: 300,
});
return nProgressInstance;
}
/**
* 开始显示进度条。
* 此函数首先加载NProgress库,然后调用NProgress的start方法开始显示进度条。
*/
async function startProgress() {
const nprogress = await loadNprogress();
nprogress?.start();
}
/**
* 停止显示进度条,并隐藏进度条。
* 此函数首先加载NProgress库,然后调用NProgress的done方法停止并隐藏进度条。
*/
async function stopProgress() {
const nprogress = await loadNprogress();
nprogress?.done();
}
export { startProgress, stopProgress };
@@ -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 };
@@ -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);
@@ -0,0 +1,50 @@
export class StateHandler {
private condition: boolean = false;
private rejectCondition: ((reason?: Error) => void) | null = null;
private resolveCondition: (() => void) | null = null;
isConditionTrue(): boolean {
return this.condition;
}
reset() {
this.condition = false;
this.clearPromises();
}
// 触发状态为 false 时,reject
setConditionFalse() {
this.condition = false;
if (this.rejectCondition) {
this.rejectCondition(new Error('Condition was set to false'));
this.clearPromises();
}
}
// 触发状态为 true 时,resolve
setConditionTrue() {
this.condition = true;
if (this.resolveCondition) {
this.resolveCondition();
this.clearPromises();
}
}
// 返回一个 Promise,等待 condition 变为 true
waitForCondition(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.condition) {
resolve(); // 如果 condition 已经为 true,立即 resolve
} else {
this.resolveCondition = resolve;
this.rejectCondition = reject;
}
});
}
// 清理 resolve/reject 函数
private clearPromises() {
this.resolveCondition = null;
this.rejectCondition = null;
}
}
@@ -0,0 +1,21 @@
/**
* @param { Readonly<Promise> } promise
* @param {object=} errorExt - Additional Information you can pass to the err object
* @return { Promise }
*/
export async function to<T, U = Error>(
promise: Readonly<Promise<T>>,
errorExt?: object,
): Promise<[null, T] | [U, undefined]> {
try {
const data = await promise;
const result: [null, T] = [null, data];
return result;
} catch (error) {
if (errorExt) {
const parsedError = Object.assign({}, error, errorExt);
return [parsedError as U, undefined];
}
return [error as U, undefined];
}
}
@@ -0,0 +1,131 @@
interface TreeConfigOptions {
// 子属性的名称,默认为'children'
childProps: string;
}
/**
* @zh_CN 遍历树形结构,并返回所有节点中指定的值。
* @param tree 树形结构数组
* @param getValue 获取节点值的函数
* @param options 作为子节点数组的可选属性名称。
* @returns 所有节点中指定的值的数组
*/
function traverseTreeValues<T, V>(
tree: T[],
getValue: (node: T) => V,
options?: TreeConfigOptions,
): V[] {
const result: V[] = [];
const { childProps } = options || {
childProps: 'children',
};
const dfs = (treeNode: T) => {
const value = getValue(treeNode);
result.push(value);
const children = (treeNode as Record<string, any>)?.[childProps];
if (!children) {
return;
}
if (children.length > 0) {
for (const child of children) {
dfs(child);
}
}
};
for (const treeNode of tree) {
dfs(treeNode);
}
return result.filter(Boolean);
}
/**
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
* @param tree 要过滤的树结构的根节点数组。
* @param filter 用于匹配每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
* @returns 包含所有匹配节点的数组。
*/
function filterTree<T extends Record<string, any>>(
tree: T[],
filter: (node: T) => boolean,
options?: TreeConfigOptions,
): T[] {
const { childProps } = options || {
childProps: 'children',
};
const _filterTree = (nodes: T[]): T[] => {
return nodes.filter((node: Record<string, any>) => {
if (filter(node as T)) {
if (node[childProps]) {
node[childProps] = _filterTree(node[childProps]);
}
return true;
}
return false;
});
};
return _filterTree(tree);
}
/**
* 根据条件重新映射给定树结构的节
* @param tree 要过滤的树结构的根节点数组。
* @param mapper 用于map每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
*/
function mapTree<T, V extends Record<string, any>>(
tree: T[],
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, parent as null | V);
if (mapperNode[childProps]) {
mapperNode[childProps] = mapTree(
mapperNode[childProps],
mapper,
options,
mapperNode as V,
);
}
return mapperNode as V;
});
}
/**
* 对树形结构数据进行递归排序
* @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 };
@@ -0,0 +1,15 @@
/**
* 根据指定字段对对象数组进行去重
* @param arr 要去重的对象数组
* @param key 去重依据的字段名
* @returns 去重后的对象数组
*/
function uniqueByField<T>(arr: T[], key: keyof T): T[] {
const seen = new Map<any, T>();
return arr.filter((item) => {
const value = item[key];
return seen.has(value) ? false : (seen.set(value, item), true);
});
}
export { uniqueByField };
@@ -0,0 +1,40 @@
/**
* 更新 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 =
document.querySelector(`#${id}`) || document.createElement('style');
styleElement.id = id;
// 构建要更新的 CSS 变量的样式文本
let cssText = `${selector} {`;
for (const key in variables) {
if (Object.prototype.hasOwnProperty.call(variables, key)) {
cssText += `${key}: ${variables[key]};`;
}
}
cssText += '}';
// 将样式文本赋值给内联样式表
styleElement.textContent = cssText;
// 将内联样式表添加到文档头部
if (!document.querySelector(`#${id}`)) {
setTimeout(() => {
document.head.append(styleElement);
});
}
}
export { updateCSSVariables };
@@ -0,0 +1,44 @@
export function bindMethods<T extends object>(instance: T): void {
const prototype = Object.getPrototypeOf(instance);
const propertyNames = Object.getOwnPropertyNames(prototype);
propertyNames.forEach((propertyName) => {
const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
const propertyValue = instance[propertyName as keyof T];
if (
typeof propertyValue === 'function' &&
propertyName !== 'constructor' &&
descriptor &&
!descriptor.get &&
!descriptor.set
) {
instance[propertyName as keyof T] = propertyValue.bind(instance);
}
});
}
/**
* 获取嵌套对象的字段值
* @param obj - 要查找的对象
* @param path - 用于查找字段的路径,使用小数点分隔
* @returns 字段值,或者未找到时返回 undefined
*/
export function getNestedValue<T>(obj: T, path: string): any {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Path must be a non-empty string');
}
// 把路径字符串按 "." 分割成数组
const keys = path.split('.') as (number | string)[];
let current: any = obj;
for (const key of keys) {
if (current === null || current === undefined) {
return undefined;
}
current = current[key as keyof typeof current];
}
return current;
}
@@ -0,0 +1,37 @@
interface OpenWindowOptions {
noopener?: boolean;
noreferrer?: boolean;
target?: '_blank' | '_parent' | '_self' | '_top' | string;
}
/**
* 新窗口打开URL。
*
* @param url - 需要打开的网址。
* @param options - 打开窗口的选项。
*/
function openWindow(url: string, options: OpenWindowOptions = {}): void {
// 解构并设置默认值
const { noopener = true, noreferrer = true, target = '_blank' } = options;
// 基于选项创建特性字符串
const features = [noopener && 'noopener=yes', noreferrer && 'noreferrer=yes']
.filter(Boolean)
.join(',');
// 打开窗口
window.open(url, target, features);
}
/**
* 在新窗口中打开路由。
* @param path
*/
function openRouteInNewWindow(path: string) {
const { hash, origin } = location;
const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' });
}
export { openRouteInNewWindow, openWindow };
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -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',
}),
});
@@ -0,0 +1,46 @@
{
"name": "@vben-core/typings",
"version": "5.7.0",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/base/typings"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm exec tsdown"
},
"files": [
"dist",
"vue-router.d.ts"
],
"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"
},
"./vue-router": {
"types": "./vue-router.d.ts"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"vue": "catalog:",
"vue-router": "catalog:"
}
}
+128
View File
@@ -0,0 +1,128 @@
type LayoutType =
| 'full-content'
| 'header-mixed-nav'
| 'header-nav'
| 'header-sidebar-nav'
| 'mixed-nav'
| 'sidebar-mixed-nav'
| 'sidebar-nav';
type ThemeModeType = 'auto' | 'dark' | 'light';
/**
* 偏好设置按钮位置
* auto 自动(按布局上下文在 header/fixed 间切换)
* fixed 固定在屏幕右边缘
* header 顶栏
* user-dropdown 用户的下拉弹出框中
* none 不显示
*/
type PreferencesButtonPositionType =
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
type BuiltinThemeType =
| 'custom'
| 'deep-blue'
| 'deep-green'
| 'default'
| 'gray'
| 'green'
| 'neutral'
| 'orange'
| 'pink'
| 'red'
| 'rose'
| 'sky-blue'
| 'slate'
| 'stone'
| 'violet'
| 'yellow'
| 'zinc'
| (Record<never, never> & string);
type ContentCompactType = 'compact' | 'wide';
type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static';
type LayoutHeaderMenuAlignType = 'center' | 'end' | 'start';
/**
* 登录过期模式
* modal 弹窗模式
* page 页面模式
*/
type LoginExpiredModeType = 'modal' | 'page';
/**
* 面包屑样式
* background 背景
* normal 默认
*/
type BreadcrumbStyleType = 'background' | 'normal';
/**
* 权限模式
* backend 后端权限模式
* frontend 前端权限模式
* mixed 混合权限模式
*/
type AccessModeType = 'backend' | 'frontend' | 'mixed';
/**
* 导航风格
* plain 朴素
* rounded 圆润
*/
type NavigationStyleType = 'plain' | 'rounded';
/**
* 标签栏风格
* brisk 轻快
* card 卡片
* chrome 谷歌
* plain 朴素
*/
type TabsStyleType = 'brisk' | 'card' | 'chrome' | 'plain';
/**
* 页面切换动画
*/
type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
/**
* 页面切换动画
* panel-center 居中布局
* panel-left 居左布局
* panel-right 居右布局
*/
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,
NavigationStyleType,
PageTransitionType,
PreferencesButtonPositionType,
TabsStyleType,
ThemeModeType,
TimezoneOption,
};
+42
View File
@@ -0,0 +1,42 @@
interface BasicOption {
label: string;
value: string;
}
type SelectOption = BasicOption;
type TabOption = BasicOption;
interface BasicUserInfo {
[key: string]: any;
/**
* 头像
*/
avatar: string;
/**
* 用户昵称
*/
realName: string;
/**
* 用户角色
*/
roles?: string[];
/**
* 用户id
*/
userId: string;
/**
* 用户名
*/
username: string;
}
type ClassType =
| Array<ClassType>
| boolean
| null
| object
| string
| undefined;
export type { BasicOption, BasicUserInfo, ClassType, SelectOption, TabOption };
+150
View File
@@ -0,0 +1,150 @@
import type { ComputedRef, MaybeRef } from 'vue';
/**
* 类型级递归中增加深度计数
*/
type Increment<A extends unknown[]> = [...A, unknown];
/**
* 深层递归所有属性为可选
*/
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,
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;
/**
* 任意类型的异步函数
*/
type AnyPromiseFunction<T extends any[] = any[], R = void> = (
...arg: T
) => PromiseLike<R>;
/**
* 任意类型的普通函数
*/
type AnyNormalFunction<T extends any[] = any[], R = void> = (...arg: T) => R;
/**
* 任意类型的函数
*/
type AnyFunction<T extends any[] = any[], R = void> =
| AnyNormalFunction<T, R>
| AnyPromiseFunction<T, R>;
/**
* T | null 包装
*/
type Nullable<T> = null | T;
/**
* T | Not null 包装
*/
type NonNullable<T> = T extends null | undefined ? never : T;
/**
* 字符串类型对象
*/
type Recordable<T> = Record<string, T>;
/**
* 字符串类型对象(只读)
*/
interface ReadonlyRecordable<T = any> {
readonly [key: string]: T;
}
/**
* setTimeout 返回值类型
*/
type TimeoutHandle = ReturnType<typeof setTimeout>;
/**
* setInterval 返回值类型
*/
type IntervalHandle = ReturnType<typeof setInterval>;
/**
* 也许它是一个计算的 ref,或者一个 getter 函数
*
*/
type MaybeReadonlyRef<T> = (() => T) | ComputedRef<T>;
/**
* 也许它是一个 ref,或者一个普通值,或者一个 getter 函数
*
*/
type MaybeComputedRef<T> = MaybeReadonlyRef<T> | MaybeRef<T>;
type Merge<O extends object, T extends object> = {
[K in keyof O | keyof T]: K extends keyof T
? T[K]
: K extends keyof O
? O[K]
: never;
};
/**
* T = [
* { name: string; age: number; },
* { sex: 'male' | 'female'; age: string }
* ]
* =>
* MergeAll<T> = {
* name: string;
* sex: 'male' | 'female';
* age: string
* }
*/
type MergeAll<
T extends object[],
R extends object = Record<string, any>,
> = T extends [infer F extends object, ...infer Rest extends object[]]
? MergeAll<Rest, Merge<R, F>>
: R;
type EmitType = (name: Name, ...args: any[]) => void;
type MaybePromise<T> = Promise<T> | T;
export type {
AnyFunction,
AnyNormalFunction,
AnyPromiseFunction,
DeepPartial,
DeepReadonly,
EmitType,
IntervalHandle,
MaybeComputedRef,
MaybePromise,
MaybeReadonlyRef,
Merge,
MergeAll,
NonNullable,
Nullable,
ReadonlyRecordable,
Recordable,
TimeoutHandle,
};
@@ -0,0 +1,6 @@
export type * from './app';
export type * from './basic';
export type * from './helper';
export type * from './menu-record';
export type * from './tabs';
export type * from './vue-router';
@@ -0,0 +1,82 @@
import type { Component } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import type { Recordable } from './helper';
/**
* 扩展路由原始对象
*/
type ExRouteRecordRaw = RouteRecordRaw & {
parent?: string;
parents?: string[];
path?: any;
};
interface MenuRecordBadgeRaw {
/**
* 徽标
*/
badge?: string;
/**
* 徽标类型
*/
badgeType?: 'dot' | 'normal';
/**
* 徽标颜色
*/
badgeVariants?: 'destructive' | 'primary' | string;
}
/**
* 菜单原始对象
*/
interface MenuRecordRaw extends MenuRecordBadgeRaw {
/**
* 激活时的图标名
*/
activeIcon?: string;
/**
* 子菜单
*/
children?: MenuRecordRaw[];
/**
* 是否禁用菜单
* @default false
*/
disabled?: boolean;
/**
* 图标名
*/
icon?: Component | string;
/**
* 菜单名
*/
name: string;
/**
* 排序号
*/
order?: number;
/**
* 父级路径
*/
parent?: string;
/**
* 所有父级路径
*/
parents?: string[];
/**
* 菜单路径,唯一,可当作key
*/
path: string;
/**
* 菜单参数
*/
query?: Recordable<any>;
/**
* 是否显示菜单
* @default true
*/
show?: boolean;
}
export type { ExRouteRecordRaw, MenuRecordBadgeRaw, MenuRecordRaw };
@@ -0,0 +1,8 @@
import type { RouteLocationNormalized } from 'vue-router';
export interface TabDefinition extends RouteLocationNormalized {
/**
* 标签页的key
*/
key?: string;
}
@@ -0,0 +1,157 @@
import type { Component } from 'vue';
import type { Router, RouteRecordRaw } from 'vue-router';
interface RouteMeta {
/**
* 激活图标(菜单/tab
*/
activeIcon?: string;
/**
* 当前激活的菜单,有时候不想激活现有菜单,需要激活父级菜单时使用
*/
activePath?: string;
/**
* 是否固定标签页
* @default false
*/
affixTab?: boolean;
/**
* 固定标签页的顺序
* @default 0
*/
affixTabOrder?: number;
/**
* 需要特定的角色标识才可以访问
* @default []
*/
authority?: string[];
/**
* 徽标
*/
badge?: string;
/**
* 徽标类型
*/
badgeType?: 'dot' | 'normal';
/**
* 徽标颜色
*/
badgeVariants?:
| 'default'
| 'destructive'
| 'primary'
| 'success'
| 'warning'
| string;
/**
* 路由对应dom是否缓存起来
*/
domCached?: boolean;
/**
* 路由的完整路径作为key(默认true)
*/
fullPathKey?: boolean;
/**
* 当前路由的子级在菜单中不展现
* @default false
*/
hideChildrenInMenu?: boolean;
/**
* 当前路由在面包屑中不展现
* @default false
*/
hideInBreadcrumb?: boolean;
/**
* 当前路由在菜单中不展现
* @default false
*/
hideInMenu?: boolean;
/**
* 当前路由在标签页不展现
* @default false
*/
hideInTab?: boolean;
/**
* 图标(菜单/tab
*/
icon?: Component | string;
/**
* iframe 地址
*/
iframeSrc?: string;
/**
* 忽略权限,直接可以访问
* @default false
*/
ignoreAccess?: boolean;
/**
* 开启KeepAlive缓存
*/
keepAlive?: boolean;
/**
* 外链-跳转路径
*/
link?: string;
/**
* 路由是否已经加载过
*/
loaded?: boolean;
/**
* 标签页最大打开数量
* @default -1
*/
maxNumOfOpenTab?: number;
/**
* 菜单可以看到,但是访问会被重定向到403
*/
menuVisibleWithForbidden?: boolean;
/**
* 不使用基础布局(仅在顶级生效)
*/
noBasicLayout?: boolean;
/**
* 在新窗口打开
*/
openInNewWindow?: boolean;
/**
* 用于路由->菜单排序
*/
order?: number;
/**
* 菜单所携带的参数
*/
query?: Recordable;
/**
* 标题名称
*/
title: string;
}
// 定义递归类型以将 RouteRecordRaw 的 component 属性更改为 string
type RouteRecordStringComponent<T = string> = Omit<
RouteRecordRaw,
'children' | 'component'
> & {
children?: RouteRecordStringComponent<T>[];
component: T;
};
type ComponentRecordType = Record<string, () => Promise<Component>>;
interface GenerateMenuAndRoutesOptions {
fetchMenuListAsync?: () => Promise<RouteRecordStringComponent[]>;
forbiddenComponent?: RouteRecordRaw['component'];
layoutMap?: ComponentRecordType;
pageMap?: ComponentRecordType;
roles?: string[];
router: Router;
routes: RouteRecordRaw[];
}
export type {
ComponentRecordType,
GenerateMenuAndRoutesOptions,
RouteMeta,
RouteRecordRaw,
RouteRecordStringComponent,
};
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -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',
}),
});
+8
View File
@@ -0,0 +1,8 @@
import type { RouteMeta as IRouteMeta } from './dist/index.d.mts';
import 'vue-router';
declare module 'vue-router' {
// oxlint-disable-next-line typescript/no-empty-object-type
interface RouteMeta extends IRouteMeta {}
}