更新最新代码

This commit is contained in:
luob
2025-12-24 23:48:38 +08:00
parent e728cf2c5e
commit 1fd17ef73a
1320 changed files with 83513 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# base
基础共享包,请勿引入 workspace 依赖
-

View File

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

View File

@@ -0,0 +1,59 @@
/* Make clicks pass-through */
#nprogress {
@apply pointer-events-none;
}
#nprogress .bar {
@apply bg-primary fixed left-0 top-0 z-[1031] h-[2px] w-full;
}
/* Fancy blur effect */
#nprogress .peg {
@apply absolute right-0 block h-full w-[100px];
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 right-4 top-4 z-[1031] block;
}
#nprogress .spinner-icon {
@apply border-t-primary border-l-primary size-4 rounded-full border-[2px] 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);
}
}

View File

@@ -0,0 +1,236 @@
.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: all 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;
}

View File

@@ -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%;
}

View File

@@ -0,0 +1,382 @@
: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;
/* =============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;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}
[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%;
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,5 @@
$namespace: 'vben' !default;
$common-separator: '-' !default;
$element-separator: '__' !default;
$modifier-separator: '--' !default;
$state-prefix: 'is' !default;

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig(async () => {
return {
vite: {
publicDir: 'src/scss-bem',
},
};
});

View File

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

View File

@@ -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 };

View File

@@ -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';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './storage-manager';

View File

@@ -0,0 +1,118 @@
type StorageType = 'localStorage' | 'sessionStorage';
interface StorageManagerOptions {
prefix?: string;
storageType?: StorageType;
}
interface StorageItem<T> {
expiry?: number;
value: T;
}
class StorageManager {
private prefix: string;
private storage: Storage;
constructor({
prefix = '',
storageType = 'localStorage',
}: StorageManagerOptions = {}) {
this.prefix = prefix;
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
/**
* 清除所有带前缀的存储项
*/
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
}
/**
* 清除所有过期的存储项
*/
clearExpiredItems(): void {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const shortKey = key.replace(this.prefix, '');
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
}
}
}
/**
* 获取存储项
* @param key 键
* @param defaultValue 当项不存在或已过期时返回的默认值
* @returns 值,如果项已过期或解析错误则返回默认值
*/
getItem<T>(key: string, defaultValue: null | T = null): null | T {
const fullKey = this.getFullKey(key);
const itemStr = this.storage.getItem(fullKey);
if (!itemStr) {
return defaultValue;
}
try {
const item: StorageItem<T> = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(fullKey);
return defaultValue;
}
return item.value;
} catch (error) {
console.error(`Error parsing item with key "${fullKey}":`, error);
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
return defaultValue;
}
}
/**
* 移除存储项
* @param key 键
*/
removeItem(key: string): void {
const fullKey = this.getFullKey(key);
this.storage.removeItem(fullKey);
}
/**
* 设置存储项
* @param key 键
* @param value 值
* @param ttl 存活时间(毫秒)
*/
setItem<T>(key: string, value: T, ttl?: number): void {
const fullKey = this.getFullKey(key);
const expiry = ttl ? Date.now() + ttl : undefined;
const item: StorageItem<T> = { expiry, value };
try {
this.storage.setItem(fullKey, JSON.stringify(item));
} catch (error) {
console.error(`Error setting item with key "${fullKey}":`, error);
}
}
/**
* 获取完整的存储键
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
}
}
export { StorageManager };

View File

@@ -0,0 +1,17 @@
type StorageType = 'localStorage' | 'sessionStorage';
interface StorageValue<T> {
data: T;
expiry: null | number;
}
interface IStorageCache {
clear(): void;
getItem<T>(key: string): null | T;
key(index: number): null | string;
length(): number;
removeItem(key: string): void;
setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
}
export type { IStorageCache, StorageType, StorageValue };

View File

@@ -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);
});
});

View File

@@ -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();
}

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
export * from './color';
export * from './convert';
export * from './generator';

View File

@@ -0,0 +1,16 @@
/** 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`;
/** 内容区域的组件ID */
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
/**
* @zh_CN 默认命名空间
*/
export const DEFAULT_NAMESPACE = 'vben';

View File

@@ -0,0 +1,2 @@
export * from './globals';
export * from './vben';

View File

@@ -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();

View File

@@ -0,0 +1 @@
export * from '@tanstack/vue-store';

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { diff } 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 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 });
});
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getElementVisibleRect } 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);
});
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: 800,
height: 0,
left: 1100,
right: 1000,
top: 900,
width: 0,
});
});
});

View File

@@ -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]);
});
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1,60 @@
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();
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);
});
});

View File

@@ -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' },
],
},
],
},
]);
});
});

View File

@@ -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' },
]);
});
});

View File

@@ -0,0 +1,30 @@
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);
});

View File

@@ -0,0 +1,156 @@
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 return the entire object if path is empty', () => {
expect(() => getNestedValue(data, '')()).toThrow();
});
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();
});
});

View File

@@ -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',
);
});
});

View File

@@ -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 };

View File

@@ -0,0 +1,96 @@
// 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 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];
}>;
function diff<T extends Record<string, any>>(obj1: T, obj2: T): DiffResult<T> {
function findDifferences(o1: any, o2: any): any {
if (Array.isArray(o1) && Array.isArray(o2)) {
if (!arraysEqual(o1, o2)) {
return o2;
}
return undefined;
}
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);
}
export { arraysEqual, diff };

View File

@@ -0,0 +1,95 @@
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);
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 needsScrollbar() {
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);
}

View File

@@ -0,0 +1,271 @@
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 });
}
/**
* 下载图片(允许跨域)
* @param url - 图片 URL
* @param canvasWidth - 画布宽度
* @param canvasHeight - 画布高度
* @param drawWithImageSize - 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的
* @returns
*/
export function downloadImageByCanvas({
url,
canvasWidth,
canvasHeight,
drawWithImageSize = true,
}: {
canvasHeight?: number;
canvasWidth?: number;
drawWithImageSize?: boolean;
url: string;
}) {
const image = new Image();
// image.setAttribute('crossOrigin', 'anonymous')
image.src = url;
image.addEventListener('load', () => {
const canvas = document.createElement('canvas');
canvas.width = canvasWidth || image.width;
canvas.height = canvasHeight || image.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx?.clearRect(0, 0, canvas.width, canvas.height);
if (drawWithImageSize) {
ctx.drawImage(image, 0, 0, image.width, image.height);
} else {
ctx.drawImage(image, 0, 0);
}
const url = canvas.toDataURL('image/png');
downloadFileFromImageUrl({ source: url, fileName: 'image.png' });
});
}
/**
* 通过 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);
}
/**
* @description: base64 to blob
*/
export function dataURLtoBlob(base64Buf: string): Blob {
const arr = base64Buf.split(',');
const typeItem = arr[0];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mime = typeItem!.match(/:(.*?);/)![1];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const bstr = window.atob(arr[1]!);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
u8arr[n] = bstr.codePointAt(n)!;
}
return new Blob([u8arr], { type: mime });
}
/**
* 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;
});
}
/**
* 将 Base64 字符串转换为文件对象
* @param base64 - Base64 字符串
* @param fileName - 文件名
* @returns File 对象
*/
export function base64ToFile(base64: string, fileName: string): File {
// 输入验证
if (!base64 || typeof base64 !== 'string') {
throw new Error('base64 参数必须是非空字符串');
}
// 将 base64 按照逗号进行分割,将前缀与后续内容分隔开
const data = base64.split(',');
if (data.length !== 2 || !data[0] || !data[1]) {
throw new Error('无效的 base64 格式');
}
// 利用正则表达式从前缀中获取类型信息image/png、image/jpeg、image/webp等
const typeMatch = data[0].match(/:(.*?);/);
if (!typeMatch || !typeMatch[1]) {
throw new Error('无法解析 base64 类型信息');
}
const type = typeMatch[1];
// 从类型信息中获取具体的文件格式后缀png、jpeg、webp
const typeParts = type.split('/');
if (typeParts.length !== 2 || !typeParts[1]) {
throw new Error('无效的 MIME 类型格式');
}
const suffix = typeParts[1];
try {
// 使用 atob() 对 base64 数据进行解码,结果是一个文件数据流以字符串的格式输出
const bstr = window.atob(data[1]);
// 获取解码结果字符串的长度
const n = bstr.length;
// 根据解码结果字符串的长度创建一个等长的整型数字数组
const u8arr = new Uint8Array(n);
// 优化的 Uint8Array 填充逻辑
for (let i = 0; i < n; i++) {
// 使用 charCodeAt() 获取字符对应的字节值Base64 解码后的字符串是字节级别的)
// eslint-disable-next-line unicorn/prefer-code-point
u8arr[i] = bstr.charCodeAt(i);
}
// 返回 File 文件对象
return new File([u8arr], `${fileName}.${suffix}`, { type });
} catch (error) {
throw new Error(
`Base64 解码失败: ${error instanceof Error ? error.message : '未知错误'}`,
);
}
}
/**
* 通用下载触发函数
* @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;
}

View File

@@ -0,0 +1,165 @@
// eslint-disable-next-line vue/prefer-import-from-vue
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,
};

View File

@@ -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,
};

View File

@@ -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;
}
});

View File

@@ -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 };

View File

@@ -0,0 +1,50 @@
export class StateHandler {
private condition: boolean = false;
private rejectCondition: (() => 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();
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;
}
}

View File

@@ -0,0 +1,299 @@
import dayjs from 'dayjs';
import { formatDate } from './date';
/**
* @param {Date | number | string} time 需要转换的时间
* @param {string} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss
*/
export function formatTime(time: Date | number | string, fmt: string) {
if (time) {
const date = new Date(time);
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
S: date.getMilliseconds(),
};
const yearMatch = fmt.match(/y+/);
if (yearMatch) {
fmt = fmt.replace(
yearMatch[0],
`${date.getFullYear()}`.slice(4 - yearMatch[0].length),
);
}
for (const k in o) {
const match = fmt.match(new RegExp(`(${k})`));
if (match) {
fmt = fmt.replace(
match[0],
match[0].length === 1
? (o[k as keyof typeof o] as any)
: `00${o[k as keyof typeof o]}`.slice(
`${o[k as keyof typeof o]}`.length,
),
);
}
}
return fmt;
} else {
return '';
}
}
/**
* 获取当前日期是第几周
* @param dateTime 当前传入的日期值
* @returns 返回第几周数字值
*/
export function getWeek(dateTime: Date): number {
const temptTime = new Date(dateTime);
// 周几
const weekday = temptTime.getDay() || 7;
// 周1+5天=周六
temptTime.setDate(temptTime.getDate() - weekday + 1 + 5);
let firstDay = new Date(temptTime.getFullYear(), 0, 1);
const dayOfWeek = firstDay.getDay();
let spendDay = 1;
if (dayOfWeek !== 0) spendDay = 7 - dayOfWeek + 1;
firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay);
const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86_400_000);
return Math.ceil(d / 7);
}
/**
* 将时间转换为 `几秒前`、`几分钟前`、`几小时前`、`几天前`
* @param param 当前时间new Date() 格式或者字符串时间格式
* @param format 需要转换的时间格式字符串
* @description param 10秒 10 * 1000
* @description param 1分 60 * 1000
* @description param 1小时 60 * 60 * 1000
* @description param 24小时60 * 60 * 24 * 1000
* @description param 3天 60 * 60* 24 * 1000 * 3
* @returns 返回拼接后的时间字符串
*/
export function formatPast(
param: Date | string,
format = 'YYYY-MM-DD HH:mm:ss',
): string {
// 传入格式处理、存储转换值
let s: number, t: any;
// 获取js 时间戳
let time: number = Date.now();
// 是否是对象
typeof param === 'string' || typeof param === 'object'
? (t = new Date(param).getTime())
: (t = param);
// 当前时间戳 - 传入时间戳
time = Number.parseInt(`${time - t}`);
if (time < 10_000) {
// 10秒内
return '刚刚';
} else if (time < 60_000 && time >= 10_000) {
// 超过10秒少于1分钟内
s = Math.floor(time / 1000);
return `${s}秒前`;
} else if (time < 3_600_000 && time >= 60_000) {
// 超过1分钟少于1小时
s = Math.floor(time / 60_000);
return `${s}分钟前`;
} else if (time < 86_400_000 && time >= 3_600_000) {
// 超过1小时少于24小时
s = Math.floor(time / 3_600_000);
return `${s}小时前`;
} else if (time < 259_200_000 && time >= 86_400_000) {
// 超过1天少于3天内
s = Math.floor(time / 86_400_000);
return `${s}天前`;
} else {
// 超过3天
const date =
typeof param === 'string' || typeof param === 'object'
? new Date(param)
: param;
return formatDate(date, format) as string;
}
}
/**
* 时间问候语
* @param param 当前时间new Date() 格式
* @description param 调用 `formatAxis(new Date())` 输出 `上午好`
* @returns 返回拼接后的时间字符串
*/
export function formatAxis(param: Date): string {
const hour: number = new Date(param).getHours();
if (hour < 6) return '凌晨好';
else if (hour < 9) return '早上好';
else if (hour < 12) return '上午好';
else if (hour < 14) return '中午好';
else if (hour < 17) return '下午好';
else if (hour < 19) return '傍晚好';
else if (hour < 22) return '晚上好';
else return '夜里好';
}
/**
* 将毫秒转换成时间字符串。例如说xx 分钟
*
* @param ms 毫秒
* @returns {string} 字符串
*/
export function formatPast2(ms: number): string {
const day = Math.floor(ms / (24 * 60 * 60 * 1000));
const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24);
const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60);
const second = Math.floor(
ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60,
);
if (day > 0) {
return `${day}${hour} 小时 ${minute} 分钟`;
}
if (hour > 0) {
return `${hour} 小时 ${minute} 分钟`;
}
if (minute > 0) {
return `${minute} 分钟`;
}
return second > 0 ? `${second}` : `${0}`;
}
/**
* 设置起始日期时间为00:00:00
* @param param 传入日期
* @returns 带时间00:00:00的日期
*/
export function beginOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
0,
0,
0,
);
}
/**
* 设置结束日期时间为23:59:59
* @param param 传入日期
* @returns 带时间23:59:59的日期
*/
export function endOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
23,
59,
59,
);
}
/**
* 计算两个日期间隔天数
* @param param1 日期1
* @param param2 日期2
*/
export function betweenDay(param1: Date, param2: Date): number {
param1 = convertDate(param1);
param2 = convertDate(param2);
// 计算差值
return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000));
}
/**
* 日期计算
* @param param1 日期
* @param param2 添加的时间
*/
export function addTime(param1: Date, param2: number): Date {
param1 = convertDate(param1);
return new Date(param1.getTime() + param2);
}
/**
* 日期转换
* @param param 日期
*/
export function convertDate(param: Date | string): Date {
if (typeof param === 'string') {
return new Date(param);
}
return param;
}
/**
* 指定的两个日期, 是否为同一天
* @param a 日期 A
* @param b 日期 B
*/
export function isSameDay(a: dayjs.ConfigType, b: dayjs.ConfigType): boolean {
if (!a || !b) return false;
const aa = dayjs(a);
const bb = dayjs(b);
return (
aa.year() === bb.year() &&
aa.month() === bb.month() &&
aa.day() === bb.day()
);
}
/**
* 获取一天的开始时间、截止时间
* @param date 日期
* @param days 天数
*/
export function getDayRange(
date: dayjs.ConfigType,
days: number,
): [dayjs.ConfigType, dayjs.ConfigType] {
const day = dayjs(date).add(days, 'd');
return getDateRange(day, day);
}
/**
* 获取最近7天的开始时间、截止时间
*/
export function getLast7Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastWeekDay = dayjs().subtract(7, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastWeekDay, yesterday);
}
/**
* 获取最近30天的开始时间、截止时间
*/
export function getLast30Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastMonthDay = dayjs().subtract(30, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastMonthDay, yesterday);
}
/**
* 获取最近1年的开始时间、截止时间
*/
export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastYearDay = dayjs().subtract(1, 'y');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastYearDay, yesterday);
}
/**
* 获取指定日期的开始时间、截止时间
* @param beginDate 开始日期
* @param endDate 截止日期
*/
export function getDateRange(
beginDate: dayjs.ConfigType,
endDate: dayjs.ConfigType,
): [string, string] {
return [
dayjs(beginDate).startOf('d').format('YYYY-MM-DD HH:mm:ss'),
dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss'),
];
}

View File

@@ -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];
}
}

View File

@@ -0,0 +1,211 @@
interface TreeConfigOptions {
// 子属性的名称,默认为'children'
childProps: string;
}
interface TreeNode {
[key: string]: any;
children?: TreeNode[];
}
/**
* @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) => V,
options?: TreeConfigOptions,
): V[] {
const { childProps } = options || {
childProps: 'children',
};
return tree.map((node) => {
const mapperNode: Record<string, any> = mapper(node);
if (mapperNode[childProps]) {
mapperNode[childProps] = mapTree(mapperNode[childProps], mapper, options);
}
return mapperNode as V;
});
}
/**
* 构造树型结构数据
*
* @param {*} data 数据源
* @param {*} id id字段 默认 'id'
* @param {*} parentId 父节点字段 默认 'parentId'
* @param {*} children 孩子节点字段 默认 'children'
*/
function handleTree(
data: TreeNode[],
id: string = 'id',
parentId: string = 'parentId',
children: string = 'children',
): TreeNode[] {
if (!Array.isArray(data)) {
console.warn('data must be an array');
return [];
}
const config = {
id,
parentId,
childrenList: children,
};
const childrenListMap: Record<number | string, TreeNode[]> = {};
const nodeIds: Record<number | string, TreeNode> = {};
const tree: TreeNode[] = [];
// 1. 数据预处理
// 1.1 第一次遍历,生成 childrenListMap 和 nodeIds 映射
for (const d of data) {
const pId = d[config.parentId];
if (childrenListMap[pId] === undefined) {
childrenListMap[pId] = [];
}
nodeIds[d[config.id]] = d;
childrenListMap[pId].push(d);
}
// 1.2 第二次遍历,找出根节点
for (const d of data) {
const pId = d[config.parentId];
if (nodeIds[pId] === undefined) {
tree.push(d);
}
}
// 2. 构建树结:递归构建子节点
const adaptToChildrenList = (node: TreeNode): void => {
const nodeId = node[config.id];
if (childrenListMap[nodeId]) {
node[config.childrenList] = childrenListMap[nodeId];
// 递归处理子节点
for (const child of node[config.childrenList]) {
adaptToChildrenList(child);
}
}
};
// 3. 从根节点开始构建完整树
for (const rootNode of tree) {
adaptToChildrenList(rootNode);
}
return tree;
}
/**
* 获取节点的完整结构
* @param tree 树数据
* @param nodeId 节点 id
*/
function treeToString(tree: any[], nodeId: number | string) {
if (tree === undefined || !Array.isArray(tree) || tree.length === 0) {
console.warn('tree must be an array');
return '';
}
// 校验是否是一级节点
const node = tree.find((item) => item.id === nodeId);
if (node !== undefined) {
return node.name;
}
let str = '';
function performAThoroughValidation(arr: any[]) {
if (arr === undefined || !Array.isArray(arr) || arr.length === 0) {
return false;
}
for (const item of arr) {
if (item.id === nodeId) {
str += ` / ${item.name}`;
return true;
} else if (item.children !== undefined && item.children.length > 0) {
str += ` / ${item.name}`;
if (performAThoroughValidation(item.children)) {
return true;
}
}
}
return false;
}
for (const item of tree) {
str = `${item.name}`;
if (performAThoroughValidation(item.children)) {
break;
}
}
return str;
}
export { filterTree, handleTree, mapTree, traverseTreeValues, treeToString };

View File

@@ -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 };

View File

@@ -0,0 +1,35 @@
/**
* 更新 CSS 变量的函数
* @param variables 要更新的 CSS 变量与其新值的映射
*/
function updateCSSVariables(
variables: { [key: string]: string },
id = '__vben-styles__',
): void {
// 获取或创建内联样式表元素
const styleElement =
document.querySelector(`#${id}`) || document.createElement('style');
styleElement.id = id;
// 构建要更新的 CSS 变量的样式文本
let cssText = ':root {';
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 };

View File

@@ -0,0 +1,39 @@
const hexList: string[] = [];
for (let i = 0; i <= 15; i++) {
hexList[i] = i.toString(16);
}
export function buildUUID(): string {
let uuid = '';
for (let i = 1; i <= 36; i++) {
switch (i) {
case 9:
case 14:
case 19:
case 24: {
uuid += '-';
break;
}
case 15: {
uuid += 4;
break;
}
case 20: {
uuid += hexList[(Math.random() * 4) | 8];
break;
}
default: {
uuid += hexList[Math.trunc(Math.random() * 16)];
}
}
}
return uuid.replaceAll('-', '');
}
let unique = 0;
export function buildShortUUID(prefix = ''): string {
const time = Date.now();
const random = Math.floor(Math.random() * 1_000_000_000);
unique++;
return `${prefix}_${random}${unique}${String(time)}`;
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

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

View File

@@ -0,0 +1,35 @@
interface BasicOption {
label: string;
value: string;
}
type SelectOption = BasicOption;
type TabOption = BasicOption;
interface BasicUserInfo {
/**
* 头像
*/
avatar: string;
/**
* 用户昵称
*/
nickname: string;
/**
* 用户角色
*/
roles?: string[];
/**
* 用户id
*/
userId: string;
/**
* 用户名
*/
username: string;
}
type ClassType = Array<object | string> | object | string;
export type { BasicOption, BasicUserInfo, ClassType, SelectOption, TabOption };

View File

@@ -0,0 +1,132 @@
import type { ComputedRef, MaybeRef } from 'vue';
/**
* 深层递归所有属性为可选
*/
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
/**
* 深层递归所有属性为只读
*/
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
/**
* 任意类型的异步函数
*/
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,
};

View File

@@ -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';

View File

@@ -0,0 +1,99 @@
import type { Component } from 'vue';
import type { RouteMeta, RouteRecordRaw } from 'vue-router';
/** 路由元信息 */
interface AppRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
children?: AppRouteRecordRaw[];
component?: any;
componentName?: string;
components?: any;
fullPath?: string;
icon?: string;
id?: any;
keepAlive?: boolean;
meta: RouteMeta;
name: string;
parentId?: number;
props?: any;
sort?: number;
visible?: boolean;
}
/**
* 扩展路由原始对象
*/
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;
/**
* 是否显示菜单
* @default true
*/
show?: boolean;
}
export type {
AppRouteRecordRaw,
ExRouteRecordRaw,
MenuRecordBadgeRaw,
MenuRecordRaw,
};

View File

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

View File

@@ -0,0 +1,153 @@
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;
/**
* 路由的完整路径作为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,
};

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

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