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