mirror of
https://github.com/vbenjs/vue-vben-admin.git
synced 2025-08-25 08:06:30 +08:00
chore: init project
This commit is contained in:
6
packages/@vben-core/shared/README.md
Normal file
6
packages/@vben-core/shared/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# shared
|
||||
|
||||
全局共享包,请勿引入 workspace 依赖
|
||||
|
||||
- typings 共享类型
|
||||
- toolkit 共享工具类
|
3
packages/@vben-core/shared/design-tokens/README.md
Normal file
3
packages/@vben-core/shared/design-tokens/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# @vben-core/design-tokens
|
||||
|
||||
用于维护全局所有的 css 变量,它由 vite 插件在全局注入,不需要手动引入
|
45
packages/@vben-core/shared/design-tokens/package.json
Normal file
45
packages/@vben-core/shared/design-tokens/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@vben-core/design-tokens",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/shared/design-tokens"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm vite build",
|
||||
"dts": "vue-tsc --declaration --emitDeclarationOnly --declarationDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.css",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.css"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
95
packages/@vben-core/shared/design-tokens/src/dark/index.scss
Normal file
95
packages/@vben-core/shared/design-tokens/src/dark/index.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
.dark {
|
||||
/* 基础背景颜色颜色 */
|
||||
|
||||
/* --color-background: 240 6% 18%; */
|
||||
// --color-body: 220deg 13.04% 8%;
|
||||
--color-body: 220deg 13.04% 8%;
|
||||
--color-background: 220deg 13.04% 8%;
|
||||
|
||||
/* --color-background: 219 42% 11%; */
|
||||
|
||||
/* 基础文本颜色 */
|
||||
--color-foreground: 220 13% 91%;
|
||||
|
||||
/* 主题颜色 */
|
||||
--color-primary: 211 91% 39%;
|
||||
|
||||
/* 前景色,如按钮文本颜色 */
|
||||
--color-primary-foreground: 0 0 98%;
|
||||
|
||||
/* 颜色次要 */
|
||||
--color-secondary: 240 5% 17%;
|
||||
|
||||
/* 前景色,如按钮文本颜色 */
|
||||
--color-secondary-foreground: 0 0 98%;
|
||||
|
||||
/* 次要文本颜色 */
|
||||
--color-secondary-desc: 210 12.16% 70.98%;
|
||||
|
||||
/* 普通颜色 */
|
||||
|
||||
/* --color-accent: 240 3.7% 15.9%; */
|
||||
|
||||
/* --color-accent: 220deg 7.32% 16.08%; */
|
||||
--color-accent: 0deg 0% 100% / 8%;
|
||||
--color-accent-hover: 0deg 0% 100% / 12%;
|
||||
|
||||
/* 普通颜色前景色,如按钮文本颜色 */
|
||||
--color-accent-foreground: 0 0 98%;
|
||||
|
||||
/* 破坏性颜色 */
|
||||
--color-destructive: 0 63% 31%;
|
||||
|
||||
/* 破坏性颜色 */
|
||||
--color-destructive-foreground: 0 86% 97%;
|
||||
--color-muted: 220deg 6.82% 17.25%;
|
||||
--color-muted-foreground: 215 20.2% 65.1%;
|
||||
--color-heavy: 0deg 0% 100% / 12%;
|
||||
--color-heavy-foreground: var(--color-accent-foreground);
|
||||
|
||||
/* 基础边框色 */
|
||||
--color-border: 0deg 0% 100% / 10%;
|
||||
|
||||
/* --color-popover: 240 4% 29%; */
|
||||
--color-popover: 222.86deg 8.43% 16.27%;
|
||||
--color-popover-foreground: 210 40% 98%;
|
||||
--color-card: 222.2 84% 4.9%;
|
||||
--color-card-foreground: 210 40% 98%;
|
||||
|
||||
/* 基础文本边框色 */
|
||||
--color-input: 0deg 0% 100% / 10%;
|
||||
|
||||
/* input placeholder 颜色 */
|
||||
--color-input-placeholder: 218deg 11% 65%;
|
||||
|
||||
/* 基础文本背景色 */
|
||||
|
||||
/* --color-input-background: 216deg 5.38% 18.24%; */
|
||||
--color-input-background: 0deg 0% 100% / 5%;
|
||||
|
||||
/* 遮罩颜色 */
|
||||
--color-overlay: 0deg 0% 0% / 40%;
|
||||
--color-ring: 222.2 84% 4.9%;
|
||||
|
||||
/* 基本文字大小 */
|
||||
--font-size-base: 16px;
|
||||
|
||||
/* 基本圆角大小 */
|
||||
--radius-base: 0.5rem;
|
||||
|
||||
/* ======================================== */
|
||||
|
||||
/* =============component & UI============= */
|
||||
|
||||
/* ======================================== */
|
||||
|
||||
/* --color-login-background: 219 42% 11%; */
|
||||
|
||||
/* 图标颜色 */
|
||||
|
||||
/* authentication */
|
||||
--color-authentication-from: hsl(240deg 11% 6%);
|
||||
--color-authentication-to: hsl(240deg 11% 6%);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
103
packages/@vben-core/shared/design-tokens/src/default/index.scss
Normal file
103
packages/@vben-core/shared/design-tokens/src/default/index.scss
Normal file
@@ -0,0 +1,103 @@
|
||||
/* https://gavin-yyc.github.io/colorconvert/ */
|
||||
:root {
|
||||
--font-geist-sans: 'geist-sans', -apple-system, blinkmacsystemfont, 'Segoe UI',
|
||||
roboto, helvetica, arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
|
||||
/* 基础背景颜色颜色 */
|
||||
|
||||
/* --color-background: 210deg 25% 96.86%; */
|
||||
--color-body: 210deg 25% 96.86%;
|
||||
--color-background: 0 0 100%;
|
||||
// --color-darken-background: 220deg 13.04% 8%;
|
||||
|
||||
/* --color-background: 220 14% 95%; */
|
||||
|
||||
/* 基础文本颜色 */
|
||||
--color-foreground: 210 6% 21%;
|
||||
|
||||
/* 主题颜色 */
|
||||
--color-primary: 211 91% 39%;
|
||||
|
||||
/* 前景色,如按钮文本颜色 */
|
||||
--color-primary-foreground: 0 0 98%;
|
||||
|
||||
/* 颜色次要 */
|
||||
--color-secondary: 240 5% 96%;
|
||||
|
||||
/* 前景色,如按钮文本颜色 */
|
||||
--color-secondary-foreground: 240 6% 10%;
|
||||
|
||||
/* 次要文本颜色 */
|
||||
--color-secondary-desc: 216.4 16.09% 34.12%;
|
||||
|
||||
/* 普通颜色 */
|
||||
--color-accent: 240 5% 96%;
|
||||
--color-accent-hover: 200deg 10% 90%;
|
||||
|
||||
/* 普通颜色前景色,如按钮文本颜色 */
|
||||
--color-accent-foreground: 240 6% 10%;
|
||||
|
||||
/* 破坏性颜色 */
|
||||
--color-destructive: 0 77.78% 68.24%;
|
||||
|
||||
/* 破坏性颜色 */
|
||||
--color-destructive-foreground: 0 0 98%;
|
||||
--color-muted: 210 40% 96.1%;
|
||||
--color-muted-foreground: 215.4 16.3% 46.9%;
|
||||
--color-heavy: 192deg 9.43% 89.61%;
|
||||
--color-heavy-foreground: var(--color-accent-foreground);
|
||||
--color-popover: 0 0% 100%;
|
||||
--color-popover-foreground: 222.2 84% 4.9%;
|
||||
--color-card: 0 0% 100%;
|
||||
--color-card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
/* 基础边框色 */
|
||||
--color-border: 240 6% 90%;
|
||||
|
||||
/* 基础文本边框色 */
|
||||
--color-input: 240deg 5.88% 90%;
|
||||
|
||||
/* input placeholder 颜色 */
|
||||
--color-input-placeholder: 217 10.6% 65%;
|
||||
|
||||
/* 基础文本背景色 */
|
||||
--color-input-background: 0 0 100%;
|
||||
--color-ring: 222.2 84% 4.9%;
|
||||
|
||||
/* 遮罩颜色 */
|
||||
--color-overlay: 0deg 0% 0% / 40%;
|
||||
|
||||
/* dark */
|
||||
--color-dark-foreground: 220 13% 91%;
|
||||
--color-dark-border: 0deg 0% 100% / 10%;
|
||||
--color-dark-accent: 0deg 0% 100% / 8%;
|
||||
--color-dark-accent-hover: 0deg 0% 100% / 12%;
|
||||
|
||||
/* 基本文字大小 */
|
||||
--font-size-base: 16px;
|
||||
|
||||
/* 基本圆角大小 */
|
||||
--radius-base: 0.5rem;
|
||||
|
||||
/* ======================================== */
|
||||
|
||||
/* =============component & UI============= */
|
||||
|
||||
/* ======================================== */
|
||||
|
||||
/* --color-login-background: 0 0 100%; */
|
||||
|
||||
/* menu */
|
||||
--color-menu-dark: 225deg 12% 13%;
|
||||
--color-menu-dark-darken: 223deg 11% 10%;
|
||||
// --color-menu-darken: var(--color-background);
|
||||
// --color-menu-opened-dark: 225deg 12.12% 11%;
|
||||
--color-menu: 0deg 0% 100%;
|
||||
--color-menu-darken: 0deg 0% 95%;
|
||||
// --color-menu-opened: 0deg 0% 100%;
|
||||
|
||||
/* authentication */
|
||||
--color-authentication-from: hsl(231deg 61% 44%);
|
||||
--color-authentication-to: hsl(218deg 70% 42%);
|
||||
}
|
4
packages/@vben-core/shared/design-tokens/src/index.ts
Normal file
4
packages/@vben-core/shared/design-tokens/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './default/index.scss';
|
||||
import './dark/index.scss';
|
||||
|
||||
export {};
|
5
packages/@vben-core/shared/design-tokens/tsconfig.json
Normal file
5
packages/@vben-core/shared/design-tokens/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/library.json",
|
||||
"include": ["src"]
|
||||
}
|
3
packages/@vben-core/shared/design-tokens/vite.config.mts
Normal file
3
packages/@vben-core/shared/design-tokens/vite.config.mts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
export default defineConfig();
|
22
packages/@vben-core/shared/design/build.config.ts
Normal file
22
packages/@vben-core/shared/design/build.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['sass'],
|
||||
outDir: './dist',
|
||||
pattern: ['index.scss'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
// loaders: ['postcss'],
|
||||
outDir: './dist',
|
||||
pattern: ['tailwind.css'],
|
||||
},
|
||||
],
|
||||
});
|
43
packages/@vben-core/shared/design/package.json
Normal file
43
packages/@vben-core/shared/design/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@vben-core/design",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/shared/design"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/scss/index.scss",
|
||||
"default": "./dist/index.css"
|
||||
},
|
||||
"./tailwind": {
|
||||
"development": "./src/tailwind.css",
|
||||
"default": "./dist/tailwind.css"
|
||||
},
|
||||
"./global": {
|
||||
"default": "./src/scss/global.scss"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"modern-normalize": "^2.0.0"
|
||||
}
|
||||
}
|
1
packages/@vben-core/shared/design/src/index.scss
Normal file
1
packages/@vben-core/shared/design/src/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import './scss/index';
|
96
packages/@vben-core/shared/design/src/scss/common/base.scss
Normal file
96
packages/@vben-core/shared/design/src/scss/common/base.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
#app,
|
||||
.ant-app,
|
||||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before {
|
||||
box-sizing: border-box;
|
||||
border-color: hsl(var(--color-border));
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
body.invert {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
body.grayscale {
|
||||
--tw-grayscale: grayscale(100%);
|
||||
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast)
|
||||
var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate)
|
||||
var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size-base);
|
||||
font-variation-settings: normal;
|
||||
color: hsl(var(--color-foreground));
|
||||
background-color: hsl(var(--color-background));
|
||||
text-size-adjust: 100%;
|
||||
font-synthesis-weight: none;
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-tap-highlight-color: rgb(128 128 128 / 50%);
|
||||
}
|
||||
|
||||
a,
|
||||
a:active,
|
||||
a:hover,
|
||||
a:link,
|
||||
a:visited {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::view-transition-new(root),
|
||||
::view-transition-old(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
html.dark::view-transition-old(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
html.dark::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: hsl(var(--color-input-placeholder)) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// .collapse-transition {
|
||||
// transition:
|
||||
// height 0.2s ease-in-out,
|
||||
// padding-top 0.2s ease-in-out,
|
||||
// padding-bottom 0.2s ease-in-out;
|
||||
// }
|
||||
|
||||
input:-webkit-autofill {
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1000px transparent inset;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
$namespace: 'vben' !default;
|
||||
$common-separator: '-' !default;
|
||||
$element-separator: '__' !default;
|
||||
$modifier-separator: '--' !default;
|
||||
$state-prefix: 'is' !default;
|
51
packages/@vben-core/shared/design/src/scss/common/entry.scss
Normal file
51
packages/@vben-core/shared/design/src/scss/common/entry.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
$max-child: 5;
|
||||
|
||||
@for $i from 1 through $max-child {
|
||||
* > .enter-x:nth-child(#{$i}) {
|
||||
transform: translateX(50px);
|
||||
}
|
||||
|
||||
* > .-enter-x:nth-child(#{$i}) {
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
* > .enter-x:nth-child(#{$i}),
|
||||
* > .-enter-x:nth-child(#{$i}) {
|
||||
// z-index: 10 - $i;
|
||||
opacity: 0;
|
||||
animation: enter-x-animation 0.3s ease-in-out 0.2s;
|
||||
animation-delay: 0.1s * $i;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
* > .enter-y:nth-child(#{$i}) {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
* > .-enter-y:nth-child(#{$i}) {
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
* > .enter-y:nth-child(#{$i}),
|
||||
* > .-enter-y:nth-child(#{$i}) {
|
||||
// z-index: 10 - $i;
|
||||
opacity: 0;
|
||||
animation: enter-y-animation 0.3s ease-in-out 0.2s;
|
||||
animation-delay: 0.1s * $i;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter-x-animation {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter-y-animation {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
34
packages/@vben-core/shared/design/src/scss/global.scss
Normal file
34
packages/@vben-core/shared/design/src/scss/global.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@forward './common/constants.scss';
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
8
packages/@vben-core/shared/design/src/scss/index.scss
Normal file
8
packages/@vben-core/shared/design/src/scss/index.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
/** css 样式重置 */
|
||||
@import 'modern-normalize/modern-normalize.css';
|
||||
|
||||
/** 元素进入动画 */
|
||||
@import './common/entry';
|
||||
@import './common/base';
|
||||
@import './transition';
|
||||
@import './nprogress';
|
78
packages/@vben-core/shared/design/src/scss/nprogress.scss
Normal file
78
packages/@vben-core/shared/design/src/scss/nprogress.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
/* Make clicks pass-through */
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1031;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
#nprogress .peg {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow:
|
||||
0 0 10px hsl(var(--color-primary)),
|
||||
0 0 5px hsl(var(--color-primary));
|
||||
opacity: 1;
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
}
|
||||
|
||||
/* Remove these to get rid of the spinner */
|
||||
#nprogress .spinner {
|
||||
position: fixed;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 1031;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
box-sizing: border-box;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: solid 2px transparent;
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-left-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .spinner,
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
@keyframes fade-slide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(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-fast {
|
||||
// animation: fade-slide 0.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;
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
.collapse-transition {
|
||||
transition:
|
||||
0.2s height ease-in-out,
|
||||
0.2s padding-top ease-in-out,
|
||||
0.2s padding-bottom ease-in-out;
|
||||
}
|
||||
|
||||
.collapse-transition-leave-active,
|
||||
.collapse-transition-enter-active {
|
||||
transition:
|
||||
0.2s max-height ease-in-out,
|
||||
0.2s padding-top ease-in-out,
|
||||
0.2s margin-top ease-in-out;
|
||||
}
|
@@ -0,0 +1,97 @@
|
||||
.fade-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-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 */
|
||||
.fade-slide-leave-active,
|
||||
.fade-slide-enter-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////
|
||||
// Fade down
|
||||
// ///////////////////////////////////////////////
|
||||
|
||||
// Speed: 1x
|
||||
.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
|
||||
.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 Top
|
||||
// ///////////////////////////////////////////////
|
||||
|
||||
// Speed: 1x
|
||||
.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%);
|
||||
}
|
@@ -0,0 +1,4 @@
|
||||
@import './slide';
|
||||
@import './fade';
|
||||
@import './animation';
|
||||
@import './collapse';
|
@@ -0,0 +1,10 @@
|
||||
@mixin transition() {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
|
||||
}
|
||||
|
||||
&-move {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
@use 'mixin.scss' as *;
|
||||
|
||||
.slide-up {
|
||||
@include transition;
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-down {
|
||||
@include transition;
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(15px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-left {
|
||||
@include transition;
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-right {
|
||||
@include transition;
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(15px);
|
||||
}
|
||||
}
|
34
packages/@vben-core/shared/design/src/tailwind.css
Normal file
34
packages/@vben-core/shared/design/src/tailwind.css
Normal file
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@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;
|
||||
|
||||
&: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-active {
|
||||
@apply outline-primary outline outline-2;
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.outline-box-active):hover:after {
|
||||
@apply outline-primary left-0 top-0 h-full w-full p-1 opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
5
packages/@vben-core/shared/design/tsconfig.json
Normal file
5
packages/@vben-core/shared/design/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/library.json",
|
||||
"include": ["src"]
|
||||
}
|
32
packages/@vben-core/shared/iconify/package.json
Normal file
32
packages/@vben-core/shared/iconify/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@vben-core/iconify",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/iconify"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"vue": "^3.4.27"
|
||||
}
|
||||
}
|
12
packages/@vben-core/shared/iconify/src/factory.ts
Normal file
12
packages/@vben-core/shared/iconify/src/factory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
function createIcon(name: string) {
|
||||
return defineComponent({
|
||||
setup(props, { attrs }) {
|
||||
return () => h(Icon, { icon: name, ...props, ...attrs });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { createIcon };
|
5
packages/@vben-core/shared/iconify/src/index.ts
Normal file
5
packages/@vben-core/shared/iconify/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './factory';
|
||||
export * from './material';
|
||||
export * from './mdi';
|
||||
|
||||
export * from '@iconify/vue';
|
75
packages/@vben-core/shared/iconify/src/material.ts
Normal file
75
packages/@vben-core/shared/iconify/src/material.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createIcon } from './factory';
|
||||
|
||||
export const IconDefault = createIcon('ic:round-auto-awesome');
|
||||
|
||||
export const IcRoundKeyboardArrowDown = createIcon(
|
||||
'ic:round-keyboard-arrow-down',
|
||||
);
|
||||
|
||||
export const IcRoundChevronRight = createIcon('ic:round-chevron-right');
|
||||
// export const IcRoundMenuOpen = createIcon('ic:round-menu-open');
|
||||
|
||||
export const IcRoundMenu = createIcon('ic:round-menu');
|
||||
|
||||
export const IcRoundMoreHoriz = createIcon('ic:round-more-horiz');
|
||||
|
||||
export const IcRoundFitScreen = createIcon('ic:round-fit-screen');
|
||||
|
||||
export const IcTwotoneFitScreen = createIcon('ic:twotone-fit-screen');
|
||||
|
||||
export const IcRoundColorLens = createIcon('ic:round-color-lens');
|
||||
|
||||
export const IcRoundMoreVert = createIcon('ic:round-more-vert');
|
||||
|
||||
export const IcRoundFullscreen = createIcon('ic:round-fullscreen');
|
||||
|
||||
export const IcRoundFullscreenExit = createIcon('ic:round-fullscreen-exit');
|
||||
|
||||
export const IcRoundAutoAwesome = createIcon('ic:round-auto-awesome');
|
||||
|
||||
export const IcRoundClose = createIcon('ic:round-close');
|
||||
|
||||
export const IcRoundRestartAlt = createIcon('ic:round-restart-alt');
|
||||
|
||||
export const IcRoundLogout = createIcon('ic:round-logout');
|
||||
|
||||
export const IcOutlineVisibility = createIcon('ic:outline-visibility');
|
||||
|
||||
export const IcOutlineVisibilityOff = createIcon('ic:outline-visibility-off');
|
||||
|
||||
export const IcRoundSearch = createIcon('ic:round-search');
|
||||
|
||||
export const IcRoundFolderCopy = createIcon('ic:round-folder-copy');
|
||||
|
||||
export const IcRoundSubdirectoryArrowLeft = createIcon(
|
||||
'ic:round-subdirectory-arrow-left',
|
||||
);
|
||||
export const IcRoundArrowUpward = createIcon('ic:round-arrow-upward');
|
||||
|
||||
export const IcRoundArrowDownward = createIcon('ic:round-arrow-downward');
|
||||
|
||||
export const IcBaselineLanguage = createIcon('ic:baseline-language');
|
||||
|
||||
export const IcRoundSearchOff = createIcon('ic:round-search-off');
|
||||
|
||||
export const IcRoundNotificationsNone = createIcon(
|
||||
'ic:round-notifications-none',
|
||||
);
|
||||
|
||||
export const IcRoundMarkEmailRead = createIcon('ic:round-mark-email-read');
|
||||
|
||||
export const IcRoundWbSunny = createIcon('ic:round-wb-sunny');
|
||||
|
||||
export const IcRoundMotionPhotosAuto = createIcon(
|
||||
'ic:round-motion-photos-auto',
|
||||
);
|
||||
|
||||
export const IcRoundSettingsSuggest = createIcon('ic:round-settings-suggest');
|
||||
|
||||
export const IcRoundArrowBackIosNew = createIcon('ic:round-arrow-back-ios-new');
|
||||
|
||||
export const IcRoundMultipleStop = createIcon('ic:round-multiple-stop');
|
||||
|
||||
export const IcRoundRefresh = createIcon('ic:round-refresh');
|
||||
|
||||
export const IcRoundCreditScore = createIcon('ic:round-credit-score');
|
49
packages/@vben-core/shared/iconify/src/mdi.ts
Normal file
49
packages/@vben-core/shared/iconify/src/mdi.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createIcon } from './factory';
|
||||
|
||||
export const MdiKeyboardEsc = createIcon('mdi:keyboard-esc');
|
||||
|
||||
export const MdiLoading = createIcon('mdi:loading');
|
||||
|
||||
export const MdiWechat = createIcon('mdi:wechat');
|
||||
|
||||
export const MdiGithub = createIcon('mdi:github');
|
||||
|
||||
export const MdiGoogle = createIcon('mdi:google');
|
||||
|
||||
export const MdiQqchat = createIcon('mdi:qqchat');
|
||||
|
||||
export const MdiPin = createIcon('mdi:pin');
|
||||
|
||||
export const MdiPinOff = createIcon('mdi:pin-off');
|
||||
|
||||
export const MdiFormatHorizontalAlignLeft = createIcon(
|
||||
'mdi:format-horizontal-align-left',
|
||||
);
|
||||
|
||||
export const MdiFormatHorizontalAlignRight = createIcon(
|
||||
'mdi:format-horizontal-align-right',
|
||||
);
|
||||
|
||||
export const MdiArrowExpandHorizontal = createIcon(
|
||||
'mdi:arrow-expand-horizontal',
|
||||
);
|
||||
|
||||
export const MdiMenuClose = createIcon('mdi:menu-close');
|
||||
|
||||
export const MdiMenuOpen = createIcon('mdi:menu-open');
|
||||
|
||||
export const MdiDockLeft = createIcon('mdi:dock-left');
|
||||
|
||||
export const MdiDockRight = createIcon('mdi:dock-right');
|
||||
|
||||
export const MdiDockBottom = createIcon('mdi:dock-bottom');
|
||||
|
||||
export const MdiDriveDocument = createIcon('mdi:drive-document');
|
||||
|
||||
export const MdiMoonAndStars = createIcon('mdi:moon-and-stars');
|
||||
|
||||
export const MdiEditBoxOutline = createIcon('mdi:edit-box-outline');
|
||||
|
||||
export const MdiQuestionMarkCircleOutline = createIcon(
|
||||
'mdi:question-mark-circle-outline',
|
||||
);
|
5
packages/@vben-core/shared/iconify/tsconfig.json
Normal file
5
packages/@vben-core/shared/iconify/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"]
|
||||
}
|
7
packages/@vben-core/shared/toolkit/build.config.ts
Normal file
7
packages/@vben-core/shared/toolkit/build.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
53
packages/@vben-core/shared/toolkit/package.json
Normal file
53
packages/@vben-core/shared/toolkit/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@vben-core/toolkit",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/shared/toolkit"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"stub": "pnpm unbuild --stub",
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "4.1.0",
|
||||
"@vue/shared": "^3.4.27",
|
||||
"dayjs": "^1.11.11",
|
||||
"defu": "^6.1.4",
|
||||
"nprogress": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nprogress": "^0.2.3"
|
||||
}
|
||||
}
|
41
packages/@vben-core/shared/toolkit/src/color.test.ts
Normal file
41
packages/@vben-core/shared/toolkit/src/color.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { convertToHsl, convertToHslCssVar, isValidColor } from './color';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
44
packages/@vben-core/shared/toolkit/src/color.ts
Normal file
44
packages/@vben-core/shared/toolkit/src/color.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { TinyColor } from '@ctrl/tinycolor';
|
||||
/**
|
||||
* 将颜色转换为HSL格式。
|
||||
*
|
||||
* HSL是一种颜色模型,包括色相(Hue)、饱和度(Saturation)和亮度(Lightness)三个部分。
|
||||
* 这个函数使用TinyColor库将输入的颜色转换为HSL格式,并返回一个字符串。
|
||||
*
|
||||
* @param {string} color 输入的颜色,可以是任何TinyColor支持的颜色格式。
|
||||
* @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 输入的颜色,可以是任何TinyColor支持的颜色格式。
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查颜色是否有效
|
||||
* @param {string} color - 待检查的颜色
|
||||
* 如果颜色有效返回true,否则返回false
|
||||
*/
|
||||
function isValidColor(color?: string) {
|
||||
if (!color) {
|
||||
return false;
|
||||
}
|
||||
return new TinyColor(color).isValid;
|
||||
}
|
||||
|
||||
export { TinyColor, convertToHsl, convertToHslCssVar, isValidColor };
|
35
packages/@vben-core/shared/toolkit/src/date.test.ts
Normal file
35
packages/@vben-core/shared/toolkit/src/date.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatDate, formatDateTime } from './date';
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00.000Z")', () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
const actual = formatDate(date);
|
||||
expect(actual).toBe('2023-01-01');
|
||||
});
|
||||
|
||||
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00")', () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
const actual = formatDate(date, 'YYYY-MM-DD');
|
||||
expect(actual).toBe('2023-01-01');
|
||||
});
|
||||
|
||||
it('should throw an error when passed an invalid date', () => {
|
||||
const date = '2018-10-10-10-10-10';
|
||||
expect(formatDate(date)).toBe('Invalid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00.000Z")', () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
const actual = formatDateTime(date);
|
||||
expect(actual).toBe('2023-01-01 08:00:00');
|
||||
});
|
||||
|
||||
it('should throw an error when passed an invalid date', () => {
|
||||
const date = '2018-10-10-10-10-10';
|
||||
expect(formatDateTime(date)).toBe('Invalid Date');
|
||||
});
|
||||
});
|
25
packages/@vben-core/shared/toolkit/src/date.ts
Normal file
25
packages/@vben-core/shared/toolkit/src/date.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import dateFunc, { type ConfigType } from 'dayjs';
|
||||
|
||||
const DATE_TIME_TEMPLATE = 'YYYY-MM-DD HH:mm:ss';
|
||||
const DATE_TEMPLATE = 'YYYY-MM-DD';
|
||||
|
||||
/**
|
||||
* @zh_CN 格式化日期时间
|
||||
* @param date 待格式化的日期时间
|
||||
* @param format 格式化的方式
|
||||
* @returns 格式化后的日期字符串,默认:YYYY-MM-DD HH:mm:ss
|
||||
*/
|
||||
function formatDate(date?: ConfigType, format = DATE_TEMPLATE): string {
|
||||
return dateFunc(date).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 格式化日期时间
|
||||
* @param date 待格式化的日期时间
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
function formatDateTime(date?: ConfigType): string {
|
||||
return formatDate(date, DATE_TIME_TEMPLATE);
|
||||
}
|
||||
|
||||
export { formatDate, formatDateTime };
|
60
packages/@vben-core/shared/toolkit/src/diff.test.ts
Normal file
60
packages/@vben-core/shared/toolkit/src/diff.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { diff } from './diff';
|
||||
|
||||
describe('diff function', () => {
|
||||
it('should correctly find differences in flat objects', () => {
|
||||
const oldObj = { a: 1, b: 2, c: 3 };
|
||||
const newObj = { a: 1, b: 3, c: 3 };
|
||||
expect(diff(oldObj, newObj)).toEqual({ b: 3 });
|
||||
});
|
||||
|
||||
it('should correctly handle nested objects', () => {
|
||||
const oldObj = { a: { b: 1, c: 2 }, d: 3 };
|
||||
const newObj = { a: { b: 1, c: 3 }, d: 3 };
|
||||
expect(diff(oldObj, newObj)).toEqual({ a: { b: 1, c: 3 } });
|
||||
});
|
||||
|
||||
it('should correctly handle arrays`', () => {
|
||||
const oldObj = { a: [1, 2, 3] };
|
||||
const newObj = { a: [1, 2, 4] };
|
||||
expect(diff(oldObj, newObj)).toEqual({ a: [1, 2, 4] });
|
||||
});
|
||||
|
||||
it('should correctly handle nested arrays', () => {
|
||||
const oldObj = {
|
||||
a: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
};
|
||||
const newObj = {
|
||||
a: [
|
||||
[1, 2],
|
||||
[3, 5],
|
||||
],
|
||||
};
|
||||
expect(diff(oldObj, newObj)).toEqual({
|
||||
a: [
|
||||
[1, 2],
|
||||
[3, 5],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if objects are identical', () => {
|
||||
const oldObj = { a: 1, b: 2, c: 3 };
|
||||
const newObj = { a: 1, b: 2, c: 3 };
|
||||
expect(diff(oldObj, newObj)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return differences between two objects excluding ignored fields', () => {
|
||||
const oldObj = { a: 1, b: 2, c: 3, d: 6 };
|
||||
const newObj = { a: 2, b: 2, c: 4, d: 5 };
|
||||
const ignoreFields: (keyof typeof newObj)[] = ['a', 'd'];
|
||||
|
||||
const result = diff(oldObj, newObj, ignoreFields);
|
||||
|
||||
expect(result).toEqual({ c: 4 });
|
||||
});
|
||||
});
|
58
packages/@vben-core/shared/toolkit/src/diff.ts
Normal file
58
packages/@vben-core/shared/toolkit/src/diff.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export { arraysEqual, diff };
|
22
packages/@vben-core/shared/toolkit/src/hash.test.ts
Normal file
22
packages/@vben-core/shared/toolkit/src/hash.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateUUID } from './hash';
|
||||
|
||||
describe('generateUUID', () => {
|
||||
it('should return a string', () => {
|
||||
const uuid = generateUUID();
|
||||
expect(typeof uuid).toBe('string');
|
||||
});
|
||||
|
||||
it('should be length 32', () => {
|
||||
const uuid = generateUUID();
|
||||
expect(uuid.length).toBe(36);
|
||||
});
|
||||
|
||||
it('should have the correct format', () => {
|
||||
const uuid = generateUUID();
|
||||
const uuidRegex =
|
||||
/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
|
||||
expect(uuidRegex.test(uuid)).toBe(true);
|
||||
});
|
||||
});
|
31
packages/@vben-core/shared/toolkit/src/hash.ts
Normal file
31
packages/@vben-core/shared/toolkit/src/hash.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 生成一个UUID(通用唯一标识符)。
|
||||
*
|
||||
* UUID是一种用于软件构建的标识符,其目的是能够生成一个唯一的ID,以便在全局范围内标识信息。
|
||||
* 此函数用于生成一个符合version 4的UUID,这种UUID是随机生成的。
|
||||
*
|
||||
* 生成的UUID的格式为:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
* 其中,x是任意16进制数字,y是一个16进制数字,取值范围为[8, b]。
|
||||
*
|
||||
* @returns {string} 生成的UUID。
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
let d = Date.now();
|
||||
if (
|
||||
typeof performance !== 'undefined' &&
|
||||
typeof performance.now === 'function'
|
||||
) {
|
||||
d += performance.now(); // use high-precision timer if available
|
||||
}
|
||||
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(
|
||||
/[xy]/g,
|
||||
(c) => {
|
||||
const r = Math.trunc((d + Math.random() * 16) % 16);
|
||||
d = Math.floor(d / 16);
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
},
|
||||
);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
export { generateUUID };
|
10
packages/@vben-core/shared/toolkit/src/index.ts
Normal file
10
packages/@vben-core/shared/toolkit/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './color';
|
||||
export * from './date';
|
||||
export * from './diff';
|
||||
export * from './hash';
|
||||
export * from './inference';
|
||||
export * from './merge';
|
||||
export * from './namespace';
|
||||
export * from './nprogress';
|
||||
export * from './tree';
|
||||
export * from './window';
|
114
packages/@vben-core/shared/toolkit/src/inference.test.ts
Normal file
114
packages/@vben-core/shared/toolkit/src/inference.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
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('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()).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);
|
||||
});
|
||||
});
|
110
packages/@vben-core/shared/toolkit/src/inference.ts
Normal file
110
packages/@vben-core/shared/toolkit/src/inference.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查传入的值是否为空。
|
||||
*
|
||||
* 以下情况将被认为是空:
|
||||
* - 值为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);
|
||||
}
|
||||
|
||||
export {
|
||||
isEmpty,
|
||||
isFunction,
|
||||
isHttpUrl,
|
||||
isMacOs,
|
||||
isObject,
|
||||
isString,
|
||||
isUndefined,
|
||||
isWindow,
|
||||
isWindowsOs,
|
||||
};
|
1
packages/@vben-core/shared/toolkit/src/merge.ts
Normal file
1
packages/@vben-core/shared/toolkit/src/merge.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { defu as merge } from 'defu';
|
105
packages/@vben-core/shared/toolkit/src/namespace.ts
Normal file
105
packages/@vben-core/shared/toolkit/src/namespace.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @see copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-namespace/index.ts
|
||||
*/
|
||||
|
||||
export const defaultNamespace = 'vben';
|
||||
const statePrefix = 'is-';
|
||||
|
||||
const _bem = (
|
||||
namespace: string,
|
||||
block: string,
|
||||
blockSuffix: string,
|
||||
element: string,
|
||||
modifier: string,
|
||||
) => {
|
||||
let cls = `${namespace}-${block}`;
|
||||
if (blockSuffix) {
|
||||
cls += `-${blockSuffix}`;
|
||||
}
|
||||
if (element) {
|
||||
cls += `__${element}`;
|
||||
}
|
||||
if (modifier) {
|
||||
cls += `--${modifier}`;
|
||||
}
|
||||
return cls;
|
||||
};
|
||||
|
||||
const is: {
|
||||
(name: string): string;
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
(name: string, state: boolean | undefined): string;
|
||||
} = (name: string, ...args: [] | [boolean | undefined]) => {
|
||||
const state = args.length > 0 ? args[0] : true;
|
||||
return name && state ? `${statePrefix}${name}` : '';
|
||||
};
|
||||
|
||||
const useNamespace = (block: string) => {
|
||||
const namespace = defaultNamespace;
|
||||
const b = (blockSuffix = '') => _bem(namespace, block, blockSuffix, '', '');
|
||||
const e = (element?: string) =>
|
||||
element ? _bem(namespace, block, '', element, '') : '';
|
||||
const m = (modifier?: string) =>
|
||||
modifier ? _bem(namespace, block, '', '', modifier) : '';
|
||||
const be = (blockSuffix?: string, element?: string) =>
|
||||
blockSuffix && element
|
||||
? _bem(namespace, block, blockSuffix, element, '')
|
||||
: '';
|
||||
const em = (element?: string, modifier?: string) =>
|
||||
element && modifier ? _bem(namespace, block, '', element, modifier) : '';
|
||||
const bm = (blockSuffix?: string, modifier?: string) =>
|
||||
blockSuffix && modifier
|
||||
? _bem(namespace, block, blockSuffix, '', modifier)
|
||||
: '';
|
||||
const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
|
||||
blockSuffix && element && modifier
|
||||
? _bem(namespace, block, blockSuffix, element, modifier)
|
||||
: '';
|
||||
|
||||
// for css var
|
||||
// --el-xxx: value;
|
||||
const cssVar = (object: Record<string, string>) => {
|
||||
const styles: Record<string, string> = {};
|
||||
for (const key in object) {
|
||||
if (object[key]) {
|
||||
styles[`--${namespace}-${key}`] = object[key];
|
||||
}
|
||||
}
|
||||
return styles;
|
||||
};
|
||||
// with block
|
||||
const cssVarBlock = (object: Record<string, string>) => {
|
||||
const styles: Record<string, string> = {};
|
||||
for (const key in object) {
|
||||
if (object[key]) {
|
||||
styles[`--${namespace}-${block}-${key}`] = object[key];
|
||||
}
|
||||
}
|
||||
return styles;
|
||||
};
|
||||
|
||||
const cssVarName = (name: string) => `--${namespace}-${name}`;
|
||||
const cssVarBlockName = (name: string) => `--${namespace}-${block}-${name}`;
|
||||
|
||||
return {
|
||||
b,
|
||||
be,
|
||||
bem,
|
||||
bm,
|
||||
// css
|
||||
cssVar,
|
||||
cssVarBlock,
|
||||
cssVarBlockName,
|
||||
cssVarName,
|
||||
e,
|
||||
em,
|
||||
is,
|
||||
m,
|
||||
namespace,
|
||||
};
|
||||
};
|
||||
|
||||
type UseNamespaceReturn = ReturnType<typeof useNamespace>;
|
||||
|
||||
export type { UseNamespaceReturn };
|
||||
export { useNamespace };
|
43
packages/@vben-core/shared/toolkit/src/nprogress.ts
Normal file
43
packages/@vben-core/shared/toolkit/src/nprogress.ts
Normal 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 };
|
196
packages/@vben-core/shared/toolkit/src/tree.test.ts
Normal file
196
packages/@vben-core/shared/toolkit/src/tree.test.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
97
packages/@vben-core/shared/toolkit/src/tree.ts
Normal file
97
packages/@vben-core/shared/toolkit/src/tree.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
interface TreeConfigOptions {
|
||||
// 子属性的名称,默认为'children'
|
||||
childProps: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 遍历树形结构,并返回所有节点中指定的值。
|
||||
* @param tree 树形结构数组
|
||||
* @param getValue 获取节点值的函数
|
||||
* @param options 作为子节点数组的可选属性名称。
|
||||
* @returns 所有节点中指定的值的数组
|
||||
*/
|
||||
function traverseTreeValues<T, V>(
|
||||
tree: T[],
|
||||
getValue: (node: T) => V,
|
||||
options?: TreeConfigOptions,
|
||||
): V[] {
|
||||
const result: V[] = [];
|
||||
const { childProps } = options || {
|
||||
childProps: 'children',
|
||||
};
|
||||
|
||||
const dfs = (treeNode: T) => {
|
||||
const value = getValue(treeNode);
|
||||
result.push(value);
|
||||
const children = (treeNode as Record<string, any>)?.[childProps];
|
||||
if (!children) {
|
||||
return;
|
||||
}
|
||||
if (children.length > 0) {
|
||||
for (const child of children) {
|
||||
dfs(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const treeNode of tree) {
|
||||
dfs(treeNode);
|
||||
}
|
||||
return result.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
|
||||
* @param tree 要过滤的树结构的根节点数组。
|
||||
* @param filter 用于匹配每个节点的条件。
|
||||
* @param options 作为子节点数组的可选属性名称。
|
||||
* @returns 包含所有匹配节点的数组。
|
||||
*/
|
||||
function filterTree<T extends Record<string, any>>(
|
||||
tree: T[],
|
||||
filter: (node: T) => boolean,
|
||||
options?: TreeConfigOptions,
|
||||
): T[] {
|
||||
const { childProps } = options || {
|
||||
childProps: 'children',
|
||||
};
|
||||
|
||||
const _filterTree = (nodes: T[]): T[] => {
|
||||
return nodes.filter((node: Record<string, any>) => {
|
||||
if (filter(node as T)) {
|
||||
if (node[childProps]) {
|
||||
node[childProps] = _filterTree(node[childProps]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
return _filterTree(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条件重新映射给定树结构的节
|
||||
* @param tree 要过滤的树结构的根节点数组。
|
||||
* @param mapper 用于map每个节点的条件。
|
||||
* @param options 作为子节点数组的可选属性名称。
|
||||
*/
|
||||
function mapTree<T, V extends Record<string, any>>(
|
||||
tree: T[],
|
||||
mapper: (node: T) => 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;
|
||||
});
|
||||
}
|
||||
|
||||
export { filterTree, mapTree, traverseTreeValues };
|
33
packages/@vben-core/shared/toolkit/src/window.test.ts
Normal file
33
packages/@vben-core/shared/toolkit/src/window.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { openWindow } from './window'; // 假设你的函数在 'openWindow' 文件中
|
||||
|
||||
describe('generateUUID', () => {
|
||||
// 保存原始的 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',
|
||||
);
|
||||
});
|
||||
});
|
26
packages/@vben-core/shared/toolkit/src/window.ts
Normal file
26
packages/@vben-core/shared/toolkit/src/window.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
interface OpenWindowOptions {
|
||||
noopener?: boolean;
|
||||
noreferrer?: boolean;
|
||||
target?: '_blank' | '_parent' | '_self' | '_top' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新窗口打开URL。
|
||||
*
|
||||
* @param url - 需要打开的网址。
|
||||
* @param options - 打开窗口的选项。
|
||||
*/
|
||||
function openWindow(url: string, options: OpenWindowOptions = {}): void {
|
||||
// 解构并设置默认值
|
||||
const { noopener = true, noreferrer = true, target = '_blank' } = options;
|
||||
|
||||
// 基于选项创建特性字符串
|
||||
const features = [noopener && 'noopener=yes', noreferrer && 'noreferrer=yes']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
// 打开窗口
|
||||
window.open(url, target, features);
|
||||
}
|
||||
|
||||
export { openWindow };
|
5
packages/@vben-core/shared/toolkit/tsconfig.json
Normal file
5
packages/@vben-core/shared/toolkit/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/library.json",
|
||||
"include": ["src"]
|
||||
}
|
7
packages/@vben-core/shared/typings/build.config.ts
Normal file
7
packages/@vben-core/shared/typings/build.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
88
packages/@vben-core/shared/typings/global.d.ts
vendored
Normal file
88
packages/@vben-core/shared/typings/global.d.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'vue-router';
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* 是否固定标签页
|
||||
* @default false
|
||||
*/
|
||||
affixTab?: boolean;
|
||||
/**
|
||||
* 需要特定的角色标识才可以访问
|
||||
* @default []
|
||||
*/
|
||||
authority?: string[];
|
||||
/**
|
||||
* 徽标
|
||||
*/
|
||||
badge?: string;
|
||||
/**
|
||||
* 徽标类型
|
||||
*/
|
||||
badgeType?: 'dot' | 'normal';
|
||||
/**
|
||||
* 徽标颜色
|
||||
*/
|
||||
badgeVariants?:
|
||||
| 'default'
|
||||
| 'destructive'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| string;
|
||||
/**
|
||||
* 当前路由的子级在菜单中不展现
|
||||
* @default false
|
||||
*/
|
||||
hideChildrenInMenu?: boolean;
|
||||
/**
|
||||
* 当前路由在面包屑中不展现
|
||||
* @default false
|
||||
*/
|
||||
hideInBreadcrumb?: boolean;
|
||||
/**
|
||||
* 当前路由在菜单中不展现
|
||||
* @default false
|
||||
*/
|
||||
hideInMenu?: boolean;
|
||||
|
||||
/**
|
||||
* 当前路由在标签页不展现
|
||||
* @default false
|
||||
*/
|
||||
hideInTab?: boolean;
|
||||
/**
|
||||
* 路由跳转地址
|
||||
*/
|
||||
href?: string;
|
||||
/**
|
||||
* 图标(菜单/tab)
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* iframe 地址
|
||||
*/
|
||||
iframeSrc?: string;
|
||||
/**
|
||||
* 忽略权限,直接可以访问
|
||||
* @default false
|
||||
*/
|
||||
ignoreAccess?: boolean;
|
||||
/**
|
||||
* 开启KeepAlive缓存
|
||||
*/
|
||||
keepAlive?: boolean;
|
||||
/**
|
||||
* 路由是否已经加载过
|
||||
*/
|
||||
loaded?: boolean;
|
||||
/**
|
||||
* 用于路由->菜单排序
|
||||
*/
|
||||
orderNo?: number;
|
||||
/**
|
||||
* 标题名称
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
}
|
50
packages/@vben-core/shared/typings/package.json
Normal file
50
packages/@vben-core/shared/typings/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@vben-core/typings",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/shared/typings"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm build --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./global": {
|
||||
"types": "./global.d.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.27",
|
||||
"vue-router": "^4.3.2"
|
||||
}
|
||||
}
|
44
packages/@vben-core/shared/typings/src/access.ts
Normal file
44
packages/@vben-core/shared/typings/src/access.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
interface RoleInfo {
|
||||
/** 角色名 */
|
||||
roleName: string;
|
||||
/** 角色值 */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 用户信息 */
|
||||
interface UserInfo {
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
avatar: string;
|
||||
/**
|
||||
* 用户描述
|
||||
*/
|
||||
desc: string;
|
||||
/**
|
||||
* 首页地址
|
||||
*/
|
||||
homePath: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
realName: string;
|
||||
/**
|
||||
* 用户角色信息
|
||||
*/
|
||||
roles: RoleInfo[];
|
||||
/**
|
||||
* accessToken
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* 用户id
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
username: string;
|
||||
}
|
||||
|
||||
export type { RoleInfo, UserInfo };
|
6
packages/@vben-core/shared/typings/src/index.ts
Normal file
6
packages/@vben-core/shared/typings/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './access';
|
||||
export * from './menu-record';
|
||||
export * from './preference';
|
||||
export * from './tabs';
|
||||
export * from './tools';
|
||||
export * from './ui';
|
71
packages/@vben-core/shared/typings/src/menu-record.ts
Normal file
71
packages/@vben-core/shared/typings/src/menu-record.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 扩展路由原始对象
|
||||
*/
|
||||
type ExRouteRecordRaw = {
|
||||
parent?: string;
|
||||
parents?: string[];
|
||||
path?: any;
|
||||
} & RouteRecordRaw;
|
||||
|
||||
interface MenuRecordBadgeRaw {
|
||||
/**
|
||||
* 徽标
|
||||
*/
|
||||
badge?: string;
|
||||
/**
|
||||
* 徽标类型
|
||||
*/
|
||||
badgeType?: 'dot' | 'normal';
|
||||
/**
|
||||
* 徽标颜色
|
||||
*/
|
||||
badgeVariants?: 'destructive' | 'primary' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单原始对象
|
||||
*/
|
||||
interface MenuRecordRaw extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* 子菜单
|
||||
*/
|
||||
children?: MenuRecordRaw[];
|
||||
/**
|
||||
* 是否禁用菜单
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 图标名
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* 菜单名
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* 排序号
|
||||
*/
|
||||
orderNo?: number;
|
||||
/**
|
||||
* 父级路径
|
||||
*/
|
||||
parent?: string;
|
||||
/**
|
||||
* 所有父级路径
|
||||
*/
|
||||
parents?: string[];
|
||||
/**
|
||||
* 菜单路径,唯一,可当作key
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* 是否显示菜单
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export type { ExRouteRecordRaw, MenuRecordBadgeRaw, MenuRecordRaw };
|
130
packages/@vben-core/shared/typings/src/preference.ts
Normal file
130
packages/@vben-core/shared/typings/src/preference.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
type LayoutType =
|
||||
| 'full-content'
|
||||
| 'header-nav'
|
||||
| 'mixed-nav'
|
||||
| 'side-mixed-nav'
|
||||
| 'side-nav';
|
||||
|
||||
type BreadcrumbStyle = 'background' | 'normal';
|
||||
|
||||
type NavigationStyle = 'normal' | 'rounded';
|
||||
|
||||
type ThemeType = 'auto' | 'dark' | 'light';
|
||||
|
||||
type ContentCompactType = 'compact' | 'wide';
|
||||
|
||||
type LayoutHeaderMode = 'auto' | 'auto-scroll' | 'fixed' | 'static';
|
||||
|
||||
type PageTransitionType = 'fade-slide';
|
||||
|
||||
type AuthPageLayout = 'panel-center' | 'panel-left' | 'panel-right';
|
||||
|
||||
type SupportLocale = 'en-US' | 'zh-CN';
|
||||
|
||||
interface Language {
|
||||
key: SupportLocale;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
/** 应用名 */
|
||||
appName: string;
|
||||
/** 登录注册页面布局 */
|
||||
authPageLayout: AuthPageLayout;
|
||||
/** 面包屑是否只有一个时隐藏 */
|
||||
breadcrumbHideOnlyOne: boolean;
|
||||
/** 面包屑首页图标是否可见 */
|
||||
breadcrumbHome: boolean;
|
||||
/** 面包屑图标是否可见 */
|
||||
breadcrumbIcon: boolean;
|
||||
/** 面包屑类型 */
|
||||
breadcrumbStyle: BreadcrumbStyle;
|
||||
/** 面包屑是否可见 */
|
||||
breadcrumbVisible: boolean;
|
||||
/** 是否开启灰色模式 */
|
||||
colorGrayMode: boolean;
|
||||
/** 主题色 */
|
||||
colorPrimary: string;
|
||||
/** 是否开启色弱模式 */
|
||||
colorWeakMode: boolean;
|
||||
/** 是否开启紧凑模式 */
|
||||
compact: boolean;
|
||||
/** 是否开启内容紧凑模式 */
|
||||
contentCompact: ContentCompactType;
|
||||
/** 页脚Copyright */
|
||||
copyright: string;
|
||||
/** 应用默认头像 */
|
||||
defaultAvatar: string;
|
||||
/** 页脚是否固定 */
|
||||
footerFixed: boolean;
|
||||
/** 页脚是否可见 */
|
||||
footerVisible: boolean;
|
||||
/** header显示模式 */
|
||||
headerMode: LayoutHeaderMode;
|
||||
/** 顶栏是否可见 */
|
||||
headerVisible: boolean;
|
||||
/** 是否移动端 */
|
||||
isMobile: boolean;
|
||||
/** 开启标签页缓存功能 */
|
||||
keepAlive: boolean;
|
||||
/** 布局方式 */
|
||||
layout: LayoutType;
|
||||
/** 支持的语言 */
|
||||
locale: SupportLocale;
|
||||
/** 应用Logo */
|
||||
logo: string;
|
||||
/** logo是否可见 */
|
||||
logoVisible: boolean;
|
||||
/** 导航菜单风格 */
|
||||
navigationStyle: NavigationStyle;
|
||||
/** 是否开启页面加载进度条 */
|
||||
pageProgress: boolean;
|
||||
/** 页面切换动画 */
|
||||
pageTransition: PageTransitionType;
|
||||
/** 页面切换动画是否启用 */
|
||||
pageTransitionEnable: boolean;
|
||||
/** 是否开启半深色菜单(只在theme='light'时生效) */
|
||||
semiDarkMenu: boolean;
|
||||
/** 侧边栏是否折叠 */
|
||||
sideCollapse: boolean;
|
||||
/** 侧边栏折叠时,是否显示title */
|
||||
sideCollapseShowTitle: boolean;
|
||||
/** 菜单自动展开状态 */
|
||||
sideExpandOnHover: boolean;
|
||||
/** 侧边栏扩展区域是否折叠 */
|
||||
sideExtraCollapse: boolean;
|
||||
/** 侧边栏是否可见 */
|
||||
sideVisible: boolean;
|
||||
/** 侧边栏宽度 */
|
||||
sideWidth: number;
|
||||
/** 是否开启多标签页图标 */
|
||||
tabsIcon: boolean;
|
||||
/** 是否开启多标签页 */
|
||||
tabsVisible: boolean;
|
||||
/** 当前主题 */
|
||||
theme: ThemeType;
|
||||
}
|
||||
|
||||
// 这些属性是静态的,不会随着用户的操作而改变
|
||||
interface StaticPreference {
|
||||
/** 主题色预设 */
|
||||
colorPrimaryPresets: string[];
|
||||
/** 支持的语言 */
|
||||
supportLanguages: Language[];
|
||||
}
|
||||
|
||||
type PreferenceKeys = keyof Preference;
|
||||
|
||||
export type {
|
||||
AuthPageLayout,
|
||||
BreadcrumbStyle,
|
||||
ContentCompactType,
|
||||
LayoutHeaderMode,
|
||||
LayoutType,
|
||||
PageTransitionType,
|
||||
Preference,
|
||||
PreferenceKeys,
|
||||
StaticPreference,
|
||||
SupportLocale,
|
||||
ThemeType,
|
||||
};
|
3
packages/@vben-core/shared/typings/src/tabs.ts
Normal file
3
packages/@vben-core/shared/typings/src/tabs.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
export type TabItem = RouteLocationNormalized;
|
89
packages/@vben-core/shared/typings/src/tools.ts
Normal file
89
packages/@vben-core/shared/typings/src/tools.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { type ComputedRef, type MaybeRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 深度部分类型
|
||||
*/
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/**
|
||||
* 任意类型的异步函数
|
||||
*/
|
||||
|
||||
type AnyPromiseFunction<T extends any[] = any[], R = void> = (
|
||||
...arg: T
|
||||
) => PromiseLike<R>;
|
||||
|
||||
/**
|
||||
* 任意类型的普通函数
|
||||
*/
|
||||
type AnyNormalFunction<T extends any[] = any[], R = void> = (...arg: T) => R;
|
||||
|
||||
/**
|
||||
* 任意类型的函数
|
||||
*/
|
||||
type AnyFunction<T extends any[] = any[], R = void> =
|
||||
| AnyNormalFunction<T, R>
|
||||
| AnyPromiseFunction<T, R>;
|
||||
|
||||
/**
|
||||
* T | null 包装
|
||||
*/
|
||||
type Nullable<T> = T | null;
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
export {
|
||||
type AnyFunction,
|
||||
type AnyNormalFunction,
|
||||
type AnyPromiseFunction,
|
||||
type DeepPartial,
|
||||
type IntervalHandle,
|
||||
type MaybeComputedRef,
|
||||
type MaybeReadonlyRef,
|
||||
type NonNullable,
|
||||
type Nullable,
|
||||
type ReadonlyRecordable,
|
||||
type Recordable,
|
||||
type TimeoutHandle,
|
||||
};
|
6
packages/@vben-core/shared/typings/src/ui.ts
Normal file
6
packages/@vben-core/shared/typings/src/ui.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
interface SelectListItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type { SelectListItem };
|
5
packages/@vben-core/shared/typings/tsconfig.json
Normal file
5
packages/@vben-core/shared/typings/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/library.json",
|
||||
"include": ["src"]
|
||||
}
|
3
packages/@vben-core/uikit/README.md
Normal file
3
packages/@vben-core/uikit/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# uikit
|
||||
|
||||
用于管理公共组件、不同UI组件库封装的组件
|
54
packages/@vben-core/uikit/layout-ui/package.json
Normal file
54
packages/@vben-core/uikit/layout-ui/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@vben-core/layout-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/layout-ui"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/design": "workspace:*",
|
||||
"@vben-core/iconify": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/toolkit": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"vue": "^3.4.27"
|
||||
}
|
||||
}
|
1
packages/@vben-core/uikit/layout-ui/postcss.config.mjs
Normal file
1
packages/@vben-core/uikit/layout-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutContent } from './layout-content.vue';
|
||||
export { default as LayoutFooter } from './layout-footer.vue';
|
||||
export { default as LayoutHeader } from './layout-header.vue';
|
||||
export { default as LayoutSide } from './layout-side.vue';
|
||||
export { default as LayoutTabs } from './layout-tabs.vue';
|
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentCompactType } from '@vben-core/typings';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
* @default 'wide'
|
||||
*/
|
||||
contentCompact?: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
* @default 1200
|
||||
*/
|
||||
contentCompactWidth?: number;
|
||||
/**
|
||||
* padding
|
||||
* @default 16
|
||||
*/
|
||||
padding?: number;
|
||||
/**
|
||||
* paddingBottom
|
||||
* @default 16
|
||||
*/
|
||||
paddingBottom?: number;
|
||||
/**
|
||||
* paddingLeft
|
||||
* @default 16
|
||||
*/
|
||||
paddingLeft?: number;
|
||||
/**
|
||||
* paddingRight
|
||||
* @default 16
|
||||
*/
|
||||
paddingRight?: number;
|
||||
/**
|
||||
* paddingTop
|
||||
* @default 16
|
||||
*/
|
||||
paddingTop?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'LayoutContent' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentCompact: 'wide',
|
||||
contentCompactWidth: 1200,
|
||||
padding: 16,
|
||||
paddingBottom: 16,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 16,
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const {
|
||||
contentCompact,
|
||||
padding,
|
||||
paddingBottom,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingTop,
|
||||
} = props;
|
||||
|
||||
const compactStyle: CSSProperties =
|
||||
contentCompact === 'compact' ? { margin: '0 auto', width: `1200px` } : {};
|
||||
return {
|
||||
...compactStyle,
|
||||
flex: 1,
|
||||
padding: `${padding}px`,
|
||||
paddingBottom: `${paddingBottom}px`,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
paddingRight: `${paddingRight}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :style="style">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 背景颜色
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* 是否固定在顶部
|
||||
* @default true
|
||||
*/
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* 高度
|
||||
* @default 32
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 高度
|
||||
* @default 100%
|
||||
*/
|
||||
width?: string;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'LayoutFooter' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
backgroundColor: 'hsl(var(--color-background))',
|
||||
fixed: true,
|
||||
height: 32,
|
||||
show: true,
|
||||
width: '100%',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const { b } = useNamespace('footer');
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { backgroundColor, fixed, height, show, width, zIndex } = props;
|
||||
return {
|
||||
backgroundColor,
|
||||
height: `${height}px`,
|
||||
marginBottom: show ? '0' : `-${height}px`,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
:class="b()"
|
||||
class="bottom-0 w-full transition-all duration-200"
|
||||
:style="style"
|
||||
>
|
||||
<slot></slot>
|
||||
</footer>
|
||||
</template>
|
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { IcRoundMenu } from '@vben-core/iconify';
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 背景颜色
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* 横屏
|
||||
* @default false
|
||||
*/
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* 高度
|
||||
* @default 60
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* 是否混合导航
|
||||
* @default false
|
||||
*/
|
||||
isMixedNav?: boolean;
|
||||
/**
|
||||
* 是否移动端
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 是否显示关闭菜单按钮
|
||||
* @default true
|
||||
*/
|
||||
showToggleBtn?: boolean;
|
||||
/**
|
||||
* 侧边是否显示
|
||||
*/
|
||||
sideHidden?: boolean;
|
||||
/**
|
||||
* 侧边菜单宽度
|
||||
* @default 0
|
||||
*/
|
||||
sideWidth?: number;
|
||||
/**
|
||||
* 宽度
|
||||
* @default 100%
|
||||
*/
|
||||
width?: string;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'LayoutHeader' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
backgroundColor: 'hsl(var(--color-background))',
|
||||
// fixed: true,
|
||||
height: 60,
|
||||
isMixedNav: false,
|
||||
show: true,
|
||||
showToggleBtn: false,
|
||||
sideWidth: 0,
|
||||
width: '100%',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ openMenu: []; toggleMenu: [] }>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const { b, e } = useNamespace('header');
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { backgroundColor, fullWidth, height, show } = props;
|
||||
const right = !show || !fullWidth ? undefined : 0;
|
||||
|
||||
return {
|
||||
// ...(props.isMixedNav ? { left: 0, position: `fixed` } : {}),
|
||||
backgroundColor,
|
||||
height: `${height}px`,
|
||||
marginTop: show ? 0 : `-${height}px`,
|
||||
right,
|
||||
};
|
||||
});
|
||||
|
||||
const logoStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
minWidth: `${props.isMobile ? 40 : props.sideWidth}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function handleToggleMenu() {
|
||||
emit('toggleMenu');
|
||||
}
|
||||
|
||||
function handleOpenMenu() {
|
||||
emit('openMenu');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="b()" :style="style">
|
||||
<div v-if="slots.logo" :style="logoStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<VbenIconButton
|
||||
v-if="showToggleBtn"
|
||||
:class="e('toggle-btn')"
|
||||
@click="handleToggleMenu"
|
||||
>
|
||||
<IcRoundMenu class="size-5" />
|
||||
</VbenIconButton>
|
||||
|
||||
<VbenIconButton
|
||||
v-if="isMobile"
|
||||
:class="e('toggle-btn')"
|
||||
@click="handleOpenMenu"
|
||||
>
|
||||
<IcRoundMenu class="size-5" />
|
||||
</VbenIconButton>
|
||||
|
||||
<slot></slot>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('header') {
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
|
||||
@include e('toggle-btn') {
|
||||
margin: 0 4px 0 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { ScrollArea } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
// import { onClickOutside } from '@vueuse/core';
|
||||
import { computed, ref, shallowRef, useSlots, watchEffect } from 'vue';
|
||||
|
||||
import { SideCollapseButton, SidePinButton } from './widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 背景颜色
|
||||
*/
|
||||
backgroundColor: string;
|
||||
/**
|
||||
* 折叠区域高度
|
||||
* @default 32
|
||||
*/
|
||||
collapseHeight?: number;
|
||||
/**
|
||||
* 折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
collapseWidth?: number;
|
||||
/**
|
||||
* 隐藏的dom是否可见
|
||||
* @default true
|
||||
*/
|
||||
domVisible?: boolean;
|
||||
/**
|
||||
* 扩展区域背景颜色
|
||||
*/
|
||||
extraBackgroundColor: string;
|
||||
/**
|
||||
* 扩展区域宽度
|
||||
* @default 180
|
||||
*/
|
||||
extraWidth?: number;
|
||||
/**
|
||||
* 固定扩展区域
|
||||
* @default false
|
||||
*/
|
||||
fixedExtra?: boolean;
|
||||
/**
|
||||
* 头部高度
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
* @default false
|
||||
*/
|
||||
isSideMixed?: boolean;
|
||||
/**
|
||||
* 混合菜单宽度
|
||||
* @default 80
|
||||
*/
|
||||
mixedWidth?: number;
|
||||
/**
|
||||
* 顶部padding
|
||||
* @default 60
|
||||
*/
|
||||
paddingTop?: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 显示折叠按钮
|
||||
* @default false
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme?: string;
|
||||
|
||||
/**
|
||||
* 宽度
|
||||
* @default 180
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'LayoutSide' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapseHeight: 42,
|
||||
collapseWidth: 48,
|
||||
domVisible: true,
|
||||
extraWidth: 180,
|
||||
fixedExtra: false,
|
||||
isSideMixed: false,
|
||||
mixedWidth: 80,
|
||||
paddingTop: 60,
|
||||
show: true,
|
||||
showCollapseButton: true,
|
||||
theme: 'dark',
|
||||
width: 180,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ leave: [] }>();
|
||||
const collapse = defineModel<boolean>('collapse');
|
||||
const extraCollapse = defineModel<boolean>('extraCollapse');
|
||||
const expandOnHovering = defineModel<boolean>('expandOnHovering');
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
const extraVisible = defineModel<boolean>('extraVisible');
|
||||
|
||||
const { b, e, is } = useNamespace('side');
|
||||
const slots = useSlots();
|
||||
|
||||
const asideRef = shallowRef<HTMLDivElement | null>();
|
||||
const scrolled = ref(false);
|
||||
|
||||
const hiddenSideStyle = computed((): CSSProperties => {
|
||||
return calcMenuWidthStyle(true);
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { isSideMixed, paddingTop, zIndex } = props;
|
||||
|
||||
return {
|
||||
...calcMenuWidthStyle(false),
|
||||
paddingTop: `${paddingTop}px`,
|
||||
zIndex,
|
||||
...(isSideMixed && extraVisible.value ? { transition: 'none' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const extraStyle = computed((): CSSProperties => {
|
||||
const { extraBackgroundColor, extraWidth, width, zIndex } = props;
|
||||
return {
|
||||
backgroundColor: extraBackgroundColor,
|
||||
left: `${width}px`,
|
||||
width: extraVisible.value ? `${extraWidth}px` : 0,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const extraTitleStyle = computed((): CSSProperties => {
|
||||
const { headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `${headerHeight - 1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const contentWidthStyle = computed((): CSSProperties => {
|
||||
const { collapseWidth, fixedExtra, isSideMixed, mixedWidth } = props;
|
||||
if (isSideMixed && fixedExtra) {
|
||||
// if (!extraVisible.value) {
|
||||
// return {};
|
||||
// }
|
||||
return { width: `${collapse.value ? collapseWidth : mixedWidth}px` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
paddingTop: '8px',
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const headerStyle = computed((): CSSProperties => {
|
||||
const { headerHeight, isSideMixed } = props;
|
||||
|
||||
return {
|
||||
...(isSideMixed ? { display: 'flex', justifyContent: 'center' } : {}),
|
||||
height: `${headerHeight}px`,
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const extraContentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
return {
|
||||
color: 'red',
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const collapseStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight } = props;
|
||||
|
||||
return {
|
||||
height: `${collapseHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
|
||||
});
|
||||
|
||||
// onClickOutside(asideRef, (event) => {
|
||||
// const { fixedExtra, width } = props;
|
||||
// // 防止点击 aside 区域关闭
|
||||
// if (!fixedExtra && event.clientX >= width && extraVisible.value) {
|
||||
// extraVisible.value = false;
|
||||
// }
|
||||
// });
|
||||
|
||||
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
|
||||
const { backgroundColor, extraWidth, fixedExtra, isSideMixed, show, width } =
|
||||
props;
|
||||
|
||||
let widthValue = `${width + (isSideMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
|
||||
|
||||
const { collapseWidth } = props;
|
||||
|
||||
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
|
||||
widthValue = `${collapseWidth}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
|
||||
backgroundColor,
|
||||
flex: `0 0 ${widthValue}`,
|
||||
marginLeft: show ? 0 : `-${widthValue}`,
|
||||
maxWidth: widthValue,
|
||||
minWidth: widthValue,
|
||||
width: widthValue,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseenter() {
|
||||
// 未开启和未折叠状态不生效
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
if (!expandOnHovering.value) {
|
||||
collapse.value = false;
|
||||
}
|
||||
expandOnHovering.value = true;
|
||||
}
|
||||
|
||||
function handleMouseleave() {
|
||||
emit('leave');
|
||||
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
expandOnHovering.value = false;
|
||||
collapse.value = true;
|
||||
extraVisible.value = false;
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
scrolled.value = (target?.scrollTop ?? 0) > 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="domVisible" :class="e('hide')" :style="hiddenSideStyle"></div>
|
||||
<aside
|
||||
:class="[b(), is(theme, true)]"
|
||||
:style="style"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="handleMouseleave"
|
||||
>
|
||||
<SidePinButton
|
||||
v-if="!collapse && !isSideMixed"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
:theme="theme"
|
||||
/>
|
||||
<div v-if="slots.logo" :style="headerStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<ScrollArea :style="contentStyle" :on-scroll="handleScroll">
|
||||
<div :class="[e('shadow'), { scrolled }]"></div>
|
||||
<slot></slot>
|
||||
</ScrollArea>
|
||||
|
||||
<div :style="collapseStyle"></div>
|
||||
<SideCollapseButton
|
||||
v-if="showCollapseButton && !isSideMixed"
|
||||
v-model:collapse="collapse"
|
||||
:theme="theme"
|
||||
/>
|
||||
<div
|
||||
v-if="isSideMixed"
|
||||
ref="asideRef"
|
||||
:class="e('extra')"
|
||||
:style="extraStyle"
|
||||
>
|
||||
<SideCollapseButton
|
||||
v-if="isSideMixed && expandOnHover"
|
||||
v-model:collapse="extraCollapse"
|
||||
:theme="theme"
|
||||
/>
|
||||
|
||||
<SidePinButton
|
||||
v-if="!extraCollapse"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
:theme="theme"
|
||||
/>
|
||||
<div v-if="!extraCollapse" :style="extraTitleStyle">
|
||||
<slot name="extra-title"></slot>
|
||||
</div>
|
||||
<ScrollArea
|
||||
:style="extraContentStyle"
|
||||
:class="e('extra-content')"
|
||||
:on-scroll="handleScroll"
|
||||
>
|
||||
<div :class="[e('shadow'), { scrolled }]"></div>
|
||||
<slot name="extra"></slot>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('side') {
|
||||
--color-surface: var(--color-menu);
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: all 0.2s ease 0s;
|
||||
|
||||
@include is('dark') {
|
||||
--color-surface: var(--color-menu-dark);
|
||||
}
|
||||
|
||||
@include e('shadow') {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
inline-size: 100%;
|
||||
block-size: 40px;
|
||||
height: 50px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--color-surface)),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
will-change: opacity;
|
||||
|
||||
&.scrolled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@include is('dark') {
|
||||
.#{$namespace}-side__extra {
|
||||
&-content {
|
||||
border-color: hsl(var(--color-dark-border)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include e('hide') {
|
||||
height: 100%;
|
||||
transition: all 0.2s ease 0s;
|
||||
}
|
||||
|
||||
@include e('extra') {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease 0s;
|
||||
|
||||
&-content {
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 背景颜色
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* 高度
|
||||
* @default 30
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'LayoutTabs' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
backgroundColor: 'hsl(var(--color-background))',
|
||||
fixed: true,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
const { b, e } = useNamespace('tabs');
|
||||
|
||||
const hiddenStyle = computed((): CSSProperties => {
|
||||
const { height } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { backgroundColor } = props;
|
||||
return {
|
||||
...hiddenStyle.value,
|
||||
backgroundColor,
|
||||
display: 'flex',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="b()" :style="style">
|
||||
<slot></slot>
|
||||
<div :class="e('toolbar')">
|
||||
<slot name="toolbar"></slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('tabs') {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
// transition: all 0.2s;
|
||||
|
||||
@include e('toolbar') {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,2 @@
|
||||
export { default as SideCollapseButton } from './side-collapse-button.vue';
|
||||
export { default as SidePinButton } from './side-pin-button.vue';
|
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { MdiMenuClose, MdiMenuOpen } from '@vben-core/iconify';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
interface Props {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SideCollapseButton' });
|
||||
|
||||
withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const collapse = defineModel<boolean>('collapse');
|
||||
|
||||
const { b, is } = useNamespace('side-collapse');
|
||||
|
||||
function handleCollapse() {
|
||||
collapse.value = !collapse.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[b(), is(theme, true)]" @click.stop="handleCollapse">
|
||||
<MdiMenuClose v-if="collapse" />
|
||||
<MdiMenuOpen v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('side-collapse') {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
color: hsl(var(--color-foreground) / 60%);
|
||||
cursor: pointer;
|
||||
background: hsl(var(--color-accent)) !important;
|
||||
border-radius: 4px;
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@include is('dark') {
|
||||
color: hsl(var(--color-dark-foreground) / 60%) !important;
|
||||
background: hsl(var(--color-dark-accent)) !important;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--color-dark-foreground)) !important;
|
||||
background: hsl(var(--color-dark-accent-hover)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
background: hsl(var(--color-accent-hover));
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { MdiPin, MdiPinOff } from '@vben-core/iconify';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
interface Props {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SidePinButton' });
|
||||
|
||||
withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
|
||||
const { b, is } = useNamespace('side-pin');
|
||||
|
||||
function togglePined() {
|
||||
expandOnHover.value = !expandOnHover.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[b(), is(theme, true)]" @click="togglePined">
|
||||
<MdiPinOff v-if="!expandOnHover" />
|
||||
<MdiPin v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('side-pin') {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
color: hsl(var(--color-foreground) / 60%);
|
||||
cursor: pointer;
|
||||
background: hsl(var(--color-accent)) !important;
|
||||
border-radius: 4px;
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@include is('dark') {
|
||||
color: hsl(var(--color-dark-foreground) / 60%) !important;
|
||||
background: unset;
|
||||
background: hsl(var(--color-dark-accent)) !important;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--color-dark-foreground)) !important;
|
||||
background: hsl(var(--color-dark-accent-hover)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
background: hsl(var(--color-accent-hover));
|
||||
}
|
||||
}
|
||||
</style>
|
2
packages/@vben-core/uikit/layout-ui/src/index.ts
Normal file
2
packages/@vben-core/uikit/layout-ui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type * from './vben-layout';
|
||||
export { default as VbenAdminLayout } from './vben-layout.vue';
|
171
packages/@vben-core/uikit/layout-ui/src/vben-layout.ts
Normal file
171
packages/@vben-core/uikit/layout-ui/src/vben-layout.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
ContentCompactType,
|
||||
LayoutHeaderMode,
|
||||
LayoutType,
|
||||
ThemeType,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
interface VbenLayoutProps {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
* @default 'wide'
|
||||
*/
|
||||
contentCompact?: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
* @default 1200
|
||||
*/
|
||||
contentCompactWidth?: number;
|
||||
/**
|
||||
* padding
|
||||
* @default 16
|
||||
*/
|
||||
contentPadding?: number;
|
||||
/**
|
||||
* paddingBottom
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
/**
|
||||
* paddingLeft
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingLeft?: number;
|
||||
/**
|
||||
* paddingRight
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingRight?: number;
|
||||
/**
|
||||
* paddingTop
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingTop?: number;
|
||||
/**
|
||||
* footer背景颜色
|
||||
* @default #fff
|
||||
*/
|
||||
footerBackgroundColor?: string;
|
||||
/**
|
||||
* footer 是否固定
|
||||
* @default true
|
||||
*/
|
||||
footerFixed?: boolean;
|
||||
/**
|
||||
* footer 高度
|
||||
* @default 32
|
||||
*/
|
||||
footerHeight?: number;
|
||||
/**
|
||||
* footer 是否可见
|
||||
* @default false
|
||||
*/
|
||||
footerVisible?: boolean;
|
||||
/**
|
||||
* 背景颜色
|
||||
* @default #fff
|
||||
*/
|
||||
headerBackgroundColor?: string;
|
||||
/**
|
||||
* header高度
|
||||
* @default 48
|
||||
*/
|
||||
headerHeight?: number;
|
||||
/**
|
||||
* header高度增加高度
|
||||
* 在顶部存在导航时,额外加高header高度
|
||||
* @default 10
|
||||
*/
|
||||
headerHeightOffset?: number;
|
||||
/**
|
||||
* header 显示模式
|
||||
* @default 'fixed'
|
||||
*/
|
||||
headerMode?: LayoutHeaderMode;
|
||||
/**
|
||||
* header是否显示
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* 是否移动端显示
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 布局方式
|
||||
* side-nav 侧边菜单布局
|
||||
* header-nav 顶部菜单布局
|
||||
* mixed-nav 侧边&顶部菜单布局
|
||||
* side-mixed-nav 侧边混合菜单布局
|
||||
* full-content 全屏内容布局
|
||||
* @default side-nav
|
||||
*/
|
||||
layout?: LayoutType;
|
||||
/**
|
||||
* 侧边菜单折叠状态
|
||||
* @default false
|
||||
*/
|
||||
sideCollapse?: boolean;
|
||||
/**
|
||||
* 侧边菜单是否折叠时,是否显示title
|
||||
* @default true
|
||||
*/
|
||||
sideCollapseShowTitle?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* 混合侧边扩展区域是否可见
|
||||
* @default false
|
||||
*/
|
||||
sideMixedExtraVisible?: boolean;
|
||||
/**
|
||||
* 混合侧边栏宽度
|
||||
* @default 80
|
||||
*/
|
||||
sideMixedWidth?: number;
|
||||
/**
|
||||
* 侧边栏是否半深色
|
||||
* @default false
|
||||
*/
|
||||
sideSemiDark?: boolean;
|
||||
/**
|
||||
* 侧边栏
|
||||
* @default dark
|
||||
*/
|
||||
sideTheme?: ThemeType;
|
||||
/**
|
||||
* 侧边栏是否可见
|
||||
* @default true
|
||||
*/
|
||||
sideVisible?: boolean;
|
||||
/**
|
||||
* 侧边栏宽度
|
||||
* @default 210
|
||||
*/
|
||||
sideWidth?: number;
|
||||
/**
|
||||
* footer背景颜色
|
||||
* @default #fff
|
||||
*/
|
||||
tabsBackgroundColor?: string;
|
||||
/**
|
||||
* tab高度
|
||||
* @default 30
|
||||
*/
|
||||
tabsHeight?: number;
|
||||
/**
|
||||
* tab是否可见
|
||||
* @default true
|
||||
*/
|
||||
tabsVisible?: boolean;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 100
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
export type { VbenLayoutProps };
|
603
packages/@vben-core/uikit/layout-ui/src/vben-layout.vue
Normal file
603
packages/@vben-core/uikit/layout-ui/src/vben-layout.vue
Normal file
@@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
LayoutContent,
|
||||
LayoutFooter,
|
||||
LayoutHeader,
|
||||
LayoutSide,
|
||||
LayoutTabs,
|
||||
} from './components';
|
||||
import { VbenLayoutProps } from './vben-layout';
|
||||
|
||||
interface Props extends VbenLayoutProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLayout',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentCompact: 'wide',
|
||||
contentPadding: 0,
|
||||
contentPaddingBottom: 0,
|
||||
contentPaddingLeft: 0,
|
||||
contentPaddingRight: 0,
|
||||
contentPaddingTop: 0,
|
||||
// footerBackgroundColor: '#fff',
|
||||
footerFixed: true,
|
||||
footerHeight: 32,
|
||||
footerVisible: false,
|
||||
// headerBackgroundColor: 'hsl(var(--color-background))',
|
||||
headerHeight: 50,
|
||||
headerHeightOffset: 10,
|
||||
|
||||
headerMode: 'fixed',
|
||||
headerVisible: true,
|
||||
isMobile: false,
|
||||
layout: 'side-nav',
|
||||
sideCollapseShowTitle: false,
|
||||
// sideCollapse: false,
|
||||
sideCollapseWidth: 60,
|
||||
sideMixedWidth: 80,
|
||||
sideSemiDark: true,
|
||||
sideTheme: 'dark',
|
||||
sideWidth: 180,
|
||||
// tabsBackgroundColor: 'hsl(var(--color-background))',
|
||||
tabsHeight: 38,
|
||||
tabsVisible: true,
|
||||
zIndex: 200,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ sideMouseLeave: [] }>();
|
||||
const sideCollapse = defineModel<boolean>('sideCollapse');
|
||||
const sideExtraVisible = defineModel<boolean>('sideExtraVisible');
|
||||
const sideExtraCollapse = defineModel<boolean>('sideExtraCollapse');
|
||||
const sideExpandOnHover = defineModel<boolean>('sideExpandOnHover');
|
||||
const sideVisible = defineModel<boolean>('sideVisible', { default: true });
|
||||
|
||||
const { b, e, is } = useNamespace('layout');
|
||||
const {
|
||||
arrivedState,
|
||||
directions,
|
||||
isScrolling,
|
||||
y: scrollY,
|
||||
} = useScroll(document);
|
||||
const { y: mouseY } = useMouse({ type: 'client' });
|
||||
|
||||
// side是否处于hover状态展开菜单中
|
||||
const sideExpandOnHovering = ref(false);
|
||||
const sideHidden = ref(false);
|
||||
const headerIsHidden = ref(false);
|
||||
|
||||
const realLayout = computed(() => {
|
||||
return props.isMobile ? 'side-nav' : props.layout;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否全屏显示content,不需要侧边、底部、顶部、tab区域
|
||||
*/
|
||||
const fullContent = computed(() => realLayout.value === 'full-content');
|
||||
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
*/
|
||||
const isSideMixedNav = computed(() => realLayout.value === 'side-mixed-nav');
|
||||
|
||||
/**
|
||||
* 是否为头部导航模式
|
||||
*/
|
||||
const isHeaderNav = computed(() => realLayout.value === 'header-nav');
|
||||
|
||||
/**
|
||||
* 是否为混合导航模式
|
||||
*/
|
||||
const isMixedNav = computed(() => realLayout.value === 'mixed-nav');
|
||||
|
||||
/**
|
||||
* 顶栏是否自动隐藏
|
||||
*/
|
||||
const isHeaderAuto = computed(() => props.headerMode === 'auto');
|
||||
|
||||
/**
|
||||
* header区域高度
|
||||
*/
|
||||
const getHeaderHeight = computed(() => {
|
||||
const { headerHeight, headerHeightOffset, headerVisible } = props;
|
||||
|
||||
if (!headerVisible) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 顶部存在导航时,增加10
|
||||
const offset = isMixedNav.value || isHeaderNav.value ? headerHeightOffset : 0;
|
||||
|
||||
return headerHeight + offset;
|
||||
});
|
||||
|
||||
const headerWrapperHeight = computed(() => {
|
||||
let height = 0;
|
||||
if (props.headerVisible) {
|
||||
height += getHeaderHeight.value;
|
||||
}
|
||||
if (props.tabsVisible) {
|
||||
height += props.tabsHeight;
|
||||
}
|
||||
|
||||
return height;
|
||||
});
|
||||
|
||||
const getSideCollapseWidth = computed(() => {
|
||||
const { sideCollapseShowTitle, sideCollapseWidth, sideMixedWidth } = props;
|
||||
return sideCollapseShowTitle || isSideMixedNav
|
||||
? sideMixedWidth
|
||||
: sideCollapseWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边区域是否可见
|
||||
*/
|
||||
const sideVisibleState = computed(() => {
|
||||
return !isHeaderNav.value && sideVisible.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边区域离顶部高度
|
||||
*/
|
||||
const sidePaddingTop = computed(() => {
|
||||
const { isMobile } = props;
|
||||
return isMixedNav.value && !isMobile ? getHeaderHeight.value : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边宽度
|
||||
*/
|
||||
const getSideWidth = computed(() => {
|
||||
const { isMobile, sideMixedWidth, sideWidth } = props;
|
||||
let width = 0;
|
||||
|
||||
if (
|
||||
!sideVisibleState.value ||
|
||||
(sideHidden.value && !isSideMixedNav.value && !isMixedNav.value)
|
||||
) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if (isSideMixedNav.value && !isMobile) {
|
||||
width = sideMixedWidth;
|
||||
} else if (sideCollapse.value) {
|
||||
width = isMobile ? 0 : getSideCollapseWidth.value;
|
||||
} else {
|
||||
width = sideWidth;
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取扩展区域宽度
|
||||
*/
|
||||
const getExtraWidth = computed(() => {
|
||||
const { sideWidth } = props;
|
||||
return sideExtraCollapse.value ? getSideCollapseWidth.value : sideWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否侧边栏模式,包含混合侧边
|
||||
*/
|
||||
const isSideMode = computed(() =>
|
||||
['mixed-nav', 'side-mixed-nav', 'side-nav'].includes(realLayout.value),
|
||||
);
|
||||
|
||||
const showSide = computed(() => {
|
||||
return isSideMode.value && sideVisible.value;
|
||||
});
|
||||
|
||||
const sideFace = computed(() => {
|
||||
const { sideSemiDark, sideTheme } = props;
|
||||
const isDark = sideTheme === 'dark' || sideSemiDark;
|
||||
|
||||
let backgroundColor = '';
|
||||
let extraBackgroundColor = '';
|
||||
|
||||
if (isDark) {
|
||||
backgroundColor = isSideMixedNav.value
|
||||
? 'hsl(var(--color-menu-dark-darken))'
|
||||
: 'hsl(var(--color-menu-dark))';
|
||||
} else {
|
||||
backgroundColor = isSideMixedNav.value
|
||||
? 'hsl(var(--color-menu-darken))'
|
||||
: 'hsl(var(--color-menu))';
|
||||
}
|
||||
|
||||
extraBackgroundColor = isDark
|
||||
? 'hsl(var(--color-menu-dark))'
|
||||
: 'hsl(var(--color-menu))';
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
extraBackgroundColor,
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 遮罩可见性
|
||||
*/
|
||||
const maskVisible = computed(() => !sideCollapse.value && props.isMobile);
|
||||
|
||||
/**
|
||||
* header fixed值
|
||||
*/
|
||||
const headerFixed = computed(() => {
|
||||
return (
|
||||
isMixedNav.value ||
|
||||
['auto', 'auto-scroll', 'fixed'].includes(props.headerMode)
|
||||
);
|
||||
});
|
||||
|
||||
const mainStyle = computed(() => {
|
||||
let width = '100%';
|
||||
let sidebarWidth = 'unset';
|
||||
if (
|
||||
headerFixed.value &&
|
||||
!['header-nav', 'mixed-nav'].includes(realLayout.value) &&
|
||||
showSide.value &&
|
||||
!props.isMobile
|
||||
) {
|
||||
// pin模式下生效
|
||||
const isSideNavEffective =
|
||||
isSideMixedNav.value && sideExpandOnHover.value && sideExtraVisible.value;
|
||||
|
||||
if (isSideNavEffective) {
|
||||
const sideCollapseWidth = sideCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: props.sideMixedWidth;
|
||||
const sideWidth = sideExtraCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: props.sideWidth;
|
||||
|
||||
// 100% - 侧边菜单混合宽度 - 菜单宽度
|
||||
sidebarWidth = `${sideCollapseWidth + sideWidth}px`;
|
||||
width = `calc(100% - ${sidebarWidth})`;
|
||||
} else {
|
||||
sidebarWidth =
|
||||
sideExpandOnHovering.value && !sideExpandOnHover.value
|
||||
? `${getSideCollapseWidth.value}px`
|
||||
: `${getSideWidth.value}px`;
|
||||
width = `calc(100% - ${sidebarWidth})`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sidebarWidth,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const tabsStyle = computed((): CSSProperties => {
|
||||
let width = '';
|
||||
let marginLeft = 0;
|
||||
|
||||
if (!isMixedNav.value) {
|
||||
width = '100%';
|
||||
} else if (sideVisible.value) {
|
||||
marginLeft = sideCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: props.sideWidth;
|
||||
width = `calc(100% - ${getSideWidth.value}px)`;
|
||||
} else {
|
||||
width = '100%';
|
||||
}
|
||||
|
||||
return {
|
||||
marginLeft: `${marginLeft}px`,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const footerWidth = computed(() => {
|
||||
if (!props.footerFixed) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
return mainStyle.value.width;
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
|
||||
return {
|
||||
marginTop:
|
||||
fixed &&
|
||||
!fullContent.value &&
|
||||
!headerIsHidden.value &&
|
||||
(!isHeaderAuto.value || scrollY.value < headerWrapperHeight.value)
|
||||
? `${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const headerZIndex = computed(() => {
|
||||
const { zIndex } = props;
|
||||
const offset = isMixedNav.value ? 1 : 0;
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const headerWrapperStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
return {
|
||||
height: fullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
||||
left: isMixedNav.value ? 0 : mainStyle.value.sidebarWidth,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
top:
|
||||
headerIsHidden.value || fullContent.value
|
||||
? `-${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
width: mainStyle.value.width,
|
||||
'z-index': headerZIndex.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边栏z-index
|
||||
*/
|
||||
const sideZIndex = computed(() => {
|
||||
const { isMobile, zIndex } = props;
|
||||
const offset = isMobile || isSideMode.value ? 1 : -1;
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const maskStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
zIndex: props.zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const showHeaderToggleButton = computed(() => {
|
||||
return (
|
||||
isSideMode.value &&
|
||||
!isSideMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!props.isMobile
|
||||
);
|
||||
// return false;
|
||||
});
|
||||
|
||||
const showHeaderLogo = computed(() => {
|
||||
return !isSideMode.value || isMixedNav.value || props.isMobile;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isMobile,
|
||||
(val) => {
|
||||
sideCollapse.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
{
|
||||
const mouseMove = () => {
|
||||
mouseY.value > headerWrapperHeight.value
|
||||
? (headerIsHidden.value = true)
|
||||
: (headerIsHidden.value = false);
|
||||
};
|
||||
watch(
|
||||
[() => props.headerMode, () => mouseY.value],
|
||||
() => {
|
||||
if (!isHeaderAuto.value || isMixedNav.value || fullContent.value) {
|
||||
return;
|
||||
}
|
||||
headerIsHidden.value = true;
|
||||
mouseMove();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
|
||||
if (scrollY.value < headerWrapperHeight.value) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
if (topArrived) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top) {
|
||||
headerIsHidden.value = false;
|
||||
} else if (bottom) {
|
||||
headerIsHidden.value = true;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
watch(
|
||||
() => scrollY.value,
|
||||
() => {
|
||||
if (
|
||||
props.headerMode !== 'auto-scroll' ||
|
||||
isMixedNav.value ||
|
||||
fullContent.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isScrolling.value) {
|
||||
checkHeaderIsHidden(
|
||||
directions.top,
|
||||
directions.bottom,
|
||||
arrivedState.top,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleClickMask() {
|
||||
sideCollapse.value = true;
|
||||
}
|
||||
|
||||
function handleToggleMenu() {
|
||||
// sideVisible.value = !sideVisible.value;
|
||||
sideHidden.value = !sideHidden.value;
|
||||
}
|
||||
|
||||
function handleOpenMenu() {
|
||||
sideCollapse.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[b(), is(realLayout, true)]">
|
||||
<slot name="preference"></slot>
|
||||
<slot name="back-top"></slot>
|
||||
<LayoutSide
|
||||
v-if="sideVisibleState"
|
||||
v-model:collapse="sideCollapse"
|
||||
v-model:extra-collapse="sideExtraCollapse"
|
||||
v-model:expand-on-hovering="sideExpandOnHovering"
|
||||
v-model:expand-on-hover="sideExpandOnHover"
|
||||
v-model:extra-visible="sideExtraVisible"
|
||||
:dom-visible="!isMobile"
|
||||
:fixed-extra="sideExpandOnHover"
|
||||
:mixed-width="sideMixedWidth"
|
||||
:header-height="isMixedNav ? 0 : getHeaderHeight"
|
||||
:collapse-width="getSideCollapseWidth"
|
||||
:is-side-mixed="isSideMixedNav"
|
||||
:padding-top="sidePaddingTop"
|
||||
:show="showSide"
|
||||
:extra-width="getExtraWidth"
|
||||
:width="getSideWidth"
|
||||
:z-index="sideZIndex"
|
||||
v-bind="sideFace"
|
||||
@leave="() => emit('sideMouseLeave')"
|
||||
>
|
||||
<template v-if="isSideMode && !isMixedNav" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSideMixedNav">
|
||||
<slot name="mixed-menu"></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="menu"></slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="side-extra"></slot>
|
||||
</template>
|
||||
<template #extra-title>
|
||||
<slot name="side-extra-title"></slot>
|
||||
</template>
|
||||
</LayoutSide>
|
||||
|
||||
<div :class="e('main')">
|
||||
<div :style="headerWrapperStyle" :class="e('header-wrapper')">
|
||||
<LayoutHeader
|
||||
v-if="headerVisible"
|
||||
:full-width="!isSideMode"
|
||||
:height="getHeaderHeight"
|
||||
:show="!fullContent"
|
||||
:side-hidden="sideHidden"
|
||||
:show-toggle-btn="showHeaderToggleButton"
|
||||
:width="mainStyle.width"
|
||||
:is-mixed-nav="isMixedNav"
|
||||
:is-mobile="isMobile"
|
||||
:z-index="headerZIndex"
|
||||
:side-width="sideWidth"
|
||||
@toggle-menu="handleToggleMenu"
|
||||
@open-menu="handleOpenMenu"
|
||||
>
|
||||
<template v-if="showHeaderLogo" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
<slot name="header"></slot>
|
||||
</LayoutHeader>
|
||||
|
||||
<LayoutTabs v-if="tabsVisible" :height="tabsHeight" :style="tabsStyle">
|
||||
<slot name="tabs"></slot>
|
||||
<template #toolbar>
|
||||
<slot name="tabs-toolbar"></slot>
|
||||
</template>
|
||||
</LayoutTabs>
|
||||
</div>
|
||||
|
||||
<!-- </div> -->
|
||||
<LayoutContent
|
||||
:class="e('content')"
|
||||
:style="contentStyle"
|
||||
:content-compact="contentCompact"
|
||||
:content-compact-width="contentCompactWidth"
|
||||
:padding="contentPadding"
|
||||
:padding-bottom="contentPaddingBottom"
|
||||
:padding-left="contentPaddingLeft"
|
||||
:padding-right="contentPaddingRight"
|
||||
:padding-top="contentPaddingTop"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</LayoutContent>
|
||||
|
||||
<LayoutFooter
|
||||
v-if="footerVisible"
|
||||
:fixed="footerFixed"
|
||||
:height="footerHeight"
|
||||
:show="!fullContent"
|
||||
:width="footerWidth"
|
||||
:z-index="zIndex"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</LayoutFooter>
|
||||
</div>
|
||||
<div
|
||||
v-if="maskVisible"
|
||||
:class="e('mask')"
|
||||
:style="maskStyle"
|
||||
@click="handleClickMask"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('layout') {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
||||
@include e('main') {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
background-color: hsl(var(--color-body));
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@include e('content') {
|
||||
transition: margin-top 0.3s ease;
|
||||
}
|
||||
|
||||
@include e('header-wrapper') {
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
@include e('mask') {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgb(0 0 0 / 40%);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
1
packages/@vben-core/uikit/layout-ui/tailwind.config.mjs
Normal file
1
packages/@vben-core/uikit/layout-ui/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
5
packages/@vben-core/uikit/layout-ui/tsconfig.json
Normal file
5
packages/@vben-core/uikit/layout-ui/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"]
|
||||
}
|
3
packages/@vben-core/uikit/layout-ui/vite.config.mts
Normal file
3
packages/@vben-core/uikit/layout-ui/vite.config.mts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
export default defineConfig();
|
1
packages/@vben-core/uikit/menu-ui/README.md
Normal file
1
packages/@vben-core/uikit/menu-ui/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 菜单组件
|
54
packages/@vben-core/uikit/menu-ui/package.json
Normal file
54
packages/@vben-core/uikit/menu-ui/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@vben-core/menu-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/menu-ui"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"imports": {
|
||||
"#*": "./src/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/design": "workspace:*",
|
||||
"@vben-core/iconify": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/toolkit": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"vue": "^3.4.27"
|
||||
}
|
||||
}
|
1
packages/@vben-core/uikit/menu-ui/postcss.config.mjs
Normal file
1
packages/@vben-core/uikit/menu-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RendererElement } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'CollapseTransition',
|
||||
});
|
||||
|
||||
const reset = (el: RendererElement) => {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
};
|
||||
|
||||
const on = {
|
||||
afterEnter(el: RendererElement) {
|
||||
el.style.maxHeight = '';
|
||||
el.style.overflow = el.dataset.oldOverflow;
|
||||
},
|
||||
|
||||
afterLeave(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
beforeEnter(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
if (el.style.height) el.dataset.elExistsHeight = el.style.height;
|
||||
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginBottom = 0;
|
||||
},
|
||||
|
||||
beforeLeave(el: RendererElement) {
|
||||
if (!el.dataset) el.dataset = {};
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop;
|
||||
el.dataset.oldMarginTop = el.style.marginTop;
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom;
|
||||
el.dataset.oldMarginBottom = el.style.marginBottom;
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
el.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
enter(el: RendererElement) {
|
||||
requestAnimationFrame(() => {
|
||||
el.dataset.oldOverflow = el.style.overflow;
|
||||
if (el.dataset.elExistsHeight) {
|
||||
el.style.maxHeight = el.dataset.elExistsHeight;
|
||||
} else if (el.scrollHeight === 0) {
|
||||
el.style.maxHeight = 0;
|
||||
} else {
|
||||
el.style.maxHeight = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop;
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom;
|
||||
el.style.marginTop = el.dataset.oldMarginTop;
|
||||
el.style.marginBottom = el.dataset.oldMarginBottom;
|
||||
el.style.overflow = 'hidden';
|
||||
});
|
||||
},
|
||||
|
||||
enterCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
|
||||
leave(el: RendererElement) {
|
||||
if (el.scrollHeight !== 0) {
|
||||
el.style.maxHeight = 0;
|
||||
el.style.paddingTop = 0;
|
||||
el.style.paddingBottom = 0;
|
||||
el.style.marginTop = 0;
|
||||
el.style.marginBottom = 0;
|
||||
}
|
||||
},
|
||||
|
||||
leaveCancelled(el: RendererElement) {
|
||||
reset(el);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="collapse-transition" v-on="on">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
@@ -0,0 +1,3 @@
|
||||
export { default as Menu } from './menu.vue';
|
||||
export { default as MenuItem } from './menu-item.vue';
|
||||
export { default as SubMenu } from './sub-menu.vue';
|
114
packages/@vben-core/uikit/menu-ui/src/components/menu-item.vue
Normal file
114
packages/@vben-core/uikit/menu-ui/src/components/menu-item.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenIcon, VbenMenuBadge, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, useSlots } from 'vue';
|
||||
|
||||
import { useMenu, useMenuContext, useSubMenuContext } from '../hooks';
|
||||
|
||||
import type { MenuItemProps, MenuItemRegistered } from '../interface';
|
||||
|
||||
interface Props extends MenuItemProps {}
|
||||
|
||||
defineOptions({ name: 'MenuItem' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ click: [MenuItemRegistered] }>();
|
||||
|
||||
const slots = useSlots();
|
||||
const { b, e, is } = useNamespace('menu-item');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
|
||||
const active = computed(() => props.path === rootMenu?.activePath);
|
||||
const isTopLevelMenuItem = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
|
||||
const getCollapseShowTitle = computed(
|
||||
() =>
|
||||
rootMenu.props?.collapseShowTitle &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props.collapse,
|
||||
);
|
||||
|
||||
const showTooltip = computed(
|
||||
() =>
|
||||
rootMenu.props.mode === 'vertical' &&
|
||||
isTopLevelMenuItem.value &&
|
||||
rootMenu.props?.collapse &&
|
||||
slots.title,
|
||||
);
|
||||
|
||||
const item: MenuItemRegistered = reactive({
|
||||
active,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单项点击事件
|
||||
*/
|
||||
function handleClick() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
rootMenu?.handleMenuItemClick?.({
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
emit('click', item);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addMenuItem?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeMenuItem?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
b(),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
is('collapse-show-title', getCollapseShowTitle),
|
||||
]"
|
||||
role="menuitem"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<VbenTooltip v-if="showTooltip" side="right">
|
||||
<template #trigger>
|
||||
<div :class="[nsMenu.be('tooltip', 'trigger')]">
|
||||
<VbenIcon :class="nsMenu.e('icon')" :icon="icon" fallback />
|
||||
<slot></slot>
|
||||
<span v-if="getCollapseShowTitle" :class="nsMenu.e('name')">
|
||||
<slot name="title"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="title"></slot>
|
||||
</VbenTooltip>
|
||||
<div v-show="!showTooltip" :class="[e('content')]">
|
||||
<VbenMenuBadge v-bind="props" />
|
||||
<VbenIcon
|
||||
v-if="isTopLevelMenuItem"
|
||||
:class="nsMenu.e('icon')"
|
||||
:icon="icon"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<slot></slot>
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
346
packages/@vben-core/uikit/menu-ui/src/components/menu.vue
Normal file
346
packages/@vben-core/uikit/menu-ui/src/components/menu.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts" setup>
|
||||
import { IcRoundMoreHoriz } from '@vben-core/iconify';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import { UseResizeObserverReturn, useResizeObserver } from '@vueuse/core';
|
||||
import {
|
||||
type VNodeArrayChildren,
|
||||
computed,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
toRef,
|
||||
useSlots,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuStyle,
|
||||
} from '../hooks';
|
||||
import { flattedChildren } from '../utils';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
import type {
|
||||
MenuItemClicked,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
} from '../interface';
|
||||
|
||||
interface Props extends MenuProps {}
|
||||
|
||||
defineOptions({ name: 'Menu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
collapse: false,
|
||||
mode: 'vertical',
|
||||
rounded: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [string, string[]];
|
||||
open: [string, string[]];
|
||||
select: [string, string[]];
|
||||
}>();
|
||||
|
||||
const { b, is } = useNamespace('menu');
|
||||
const menuStyle = useMenuStyle();
|
||||
const slots = useSlots();
|
||||
const menu = ref<HTMLUListElement>();
|
||||
const sliceIndex = ref(-1);
|
||||
const openedMenus = ref<MenuProvider['openedMenus']>(
|
||||
props.defaultOpeneds && !props.collapse ? [...props.defaultOpeneds] : [],
|
||||
);
|
||||
const activePath = ref<MenuProvider['activePath']>(props.defaultActive);
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const mouseInChild = ref(false);
|
||||
const defaultSlots: VNodeArrayChildren = slots.default?.() ?? [];
|
||||
|
||||
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
|
||||
return (
|
||||
props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
||||
);
|
||||
});
|
||||
|
||||
const getSlot = computed(() => {
|
||||
const originalSlot = flattedChildren(defaultSlots) as VNodeArrayChildren;
|
||||
const slotDefault =
|
||||
sliceIndex.value === -1
|
||||
? originalSlot
|
||||
: originalSlot.slice(0, sliceIndex.value);
|
||||
|
||||
const slotMore =
|
||||
sliceIndex.value === -1 ? [] : originalSlot.slice(sliceIndex.value);
|
||||
|
||||
return { showSlotMore: slotMore.length > 0, slotDefault, slotMore };
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.collapse,
|
||||
(value) => {
|
||||
if (value) openedMenus.value = [];
|
||||
},
|
||||
);
|
||||
|
||||
watch(items.value, initMenu);
|
||||
|
||||
watch(
|
||||
() => props.defaultActive,
|
||||
(currentActive = '') => {
|
||||
if (!items.value[currentActive]) {
|
||||
activePath.value = '';
|
||||
}
|
||||
updateActiveName(currentActive);
|
||||
},
|
||||
);
|
||||
|
||||
let resizeStopper: UseResizeObserverReturn['stop'];
|
||||
watchEffect(() => {
|
||||
if (props.mode === 'horizontal') {
|
||||
resizeStopper = useResizeObserver(menu, handleResize).stop;
|
||||
} else {
|
||||
resizeStopper?.();
|
||||
}
|
||||
});
|
||||
|
||||
// 注入上下文
|
||||
createMenuContext(
|
||||
reactive({
|
||||
activePath,
|
||||
addMenuItem,
|
||||
addSubMenu,
|
||||
closeMenu,
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
isMenuPopup,
|
||||
openMenu,
|
||||
openedMenus,
|
||||
props,
|
||||
removeMenuItem,
|
||||
removeSubMenu,
|
||||
subMenus,
|
||||
theme: toRef(props, 'theme'),
|
||||
items,
|
||||
}),
|
||||
);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
level: 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
function calcMenuItemWidth(menuItem: HTMLElement) {
|
||||
const computedStyle = getComputedStyle(menuItem);
|
||||
const marginLeft = Number.parseInt(computedStyle.marginLeft, 10);
|
||||
const marginRight = Number.parseInt(computedStyle.marginRight, 10);
|
||||
return menuItem.offsetWidth + marginLeft + marginRight || 0;
|
||||
}
|
||||
|
||||
function calcSliceIndex() {
|
||||
if (!menu.value) {
|
||||
return -1;
|
||||
}
|
||||
const items = [...(menu.value?.childNodes ?? [])].filter(
|
||||
(item) =>
|
||||
// remove comment type node #12634
|
||||
item.nodeName !== '#comment' &&
|
||||
(item.nodeName !== '#text' || item.nodeValue),
|
||||
) as HTMLElement[];
|
||||
|
||||
const moreItemWidth = 46;
|
||||
const computedMenuStyle = getComputedStyle(menu?.value);
|
||||
|
||||
const paddingLeft = Number.parseInt(computedMenuStyle.paddingLeft, 10);
|
||||
const paddingRight = Number.parseInt(computedMenuStyle.paddingRight, 10);
|
||||
const menuWidth = menu.value?.clientWidth - paddingLeft - paddingRight;
|
||||
|
||||
let calcWidth = 0;
|
||||
let sliceIndex = 0;
|
||||
items.forEach((item, index) => {
|
||||
calcWidth += calcMenuItemWidth(item);
|
||||
if (calcWidth <= menuWidth - moreItemWidth) {
|
||||
sliceIndex = index + 1;
|
||||
}
|
||||
});
|
||||
return sliceIndex === items.length ? -1 : sliceIndex;
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, wait = 33.34) {
|
||||
let timmer: ReturnType<typeof setTimeout> | null;
|
||||
return () => {
|
||||
timmer && clearTimeout(timmer);
|
||||
timmer = setTimeout(() => {
|
||||
fn();
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
let isFirstTimeRender = true;
|
||||
function handleResize() {
|
||||
if (sliceIndex.value === calcSliceIndex()) {
|
||||
return;
|
||||
}
|
||||
const callback = () => {
|
||||
sliceIndex.value = -1;
|
||||
nextTick(() => {
|
||||
sliceIndex.value = calcSliceIndex();
|
||||
});
|
||||
};
|
||||
callback();
|
||||
// // execute callback directly when first time resize to avoid shaking
|
||||
isFirstTimeRender ? callback() : debounce(callback)();
|
||||
isFirstTimeRender = false;
|
||||
}
|
||||
|
||||
function getActivePaths() {
|
||||
const activeItem = activePath.value && items.value[activePath.value];
|
||||
|
||||
if (!activeItem || props.mode === 'horizontal' || props.collapse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return activeItem.parentPaths;
|
||||
}
|
||||
|
||||
// 默认展开菜单
|
||||
function initMenu() {
|
||||
const parentPaths = getActivePaths();
|
||||
|
||||
// 展开该菜单项的路径上所有子菜单
|
||||
// expand all subMenus of the menu item
|
||||
parentPaths.forEach((path) => {
|
||||
const subMenu = subMenus.value[path];
|
||||
subMenu && openMenu(path, subMenu.parentPaths);
|
||||
});
|
||||
}
|
||||
|
||||
function updateActiveName(val: string) {
|
||||
const itemsInData = items.value;
|
||||
const item =
|
||||
itemsInData[val] ||
|
||||
(activePath.value && itemsInData[activePath.value]) ||
|
||||
itemsInData[props.defaultActive || ''];
|
||||
|
||||
activePath.value = item ? item.path : val;
|
||||
}
|
||||
|
||||
function handleMenuItemClick(data: MenuItemClicked) {
|
||||
const { collapse, mode } = props;
|
||||
if (mode === 'horizontal' || collapse) {
|
||||
openedMenus.value = [];
|
||||
}
|
||||
const { parentPaths, path } = data;
|
||||
if (!path || !parentPaths) {
|
||||
return;
|
||||
}
|
||||
activePath.value = path;
|
||||
emit('select', path, parentPaths);
|
||||
}
|
||||
|
||||
function handleSubMenuClick({ parentPaths, path }: MenuItemRegistered) {
|
||||
const isOpened = openedMenus.value.includes(path);
|
||||
|
||||
if (isOpened) {
|
||||
closeMenu(path, parentPaths);
|
||||
} else {
|
||||
openMenu(path, parentPaths);
|
||||
}
|
||||
}
|
||||
|
||||
function close(path: string) {
|
||||
const i = openedMenus.value.indexOf(path);
|
||||
|
||||
if (i !== -1) {
|
||||
openedMenus.value.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭、折叠菜单
|
||||
*/
|
||||
function closeMenu(path: string, parentPaths: string[]) {
|
||||
if (props.accordion) {
|
||||
openedMenus.value = subMenus.value[path]?.parentPaths;
|
||||
}
|
||||
|
||||
close(path);
|
||||
|
||||
emit('close', path, parentPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击展开菜单
|
||||
*/
|
||||
function openMenu(path: string, parentPaths: string[]) {
|
||||
if (openedMenus.value.includes(path)) {
|
||||
return;
|
||||
}
|
||||
// 手风琴模式菜单
|
||||
if (props.accordion) {
|
||||
const activeParentPaths = getActivePaths();
|
||||
if (activeParentPaths.includes(path)) {
|
||||
parentPaths = activeParentPaths;
|
||||
}
|
||||
openedMenus.value = openedMenus.value.filter((path: string) =>
|
||||
parentPaths.includes(path),
|
||||
);
|
||||
}
|
||||
openedMenus.value.push(path);
|
||||
emit('open', path, parentPaths);
|
||||
}
|
||||
|
||||
function addMenuItem(item: MenuItemRegistered) {
|
||||
items.value[item.path] = item;
|
||||
}
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
function removeMenuItem(item: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(items.value, item.path);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul
|
||||
ref="menu"
|
||||
:class="[
|
||||
b(),
|
||||
is(mode, true),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
is('collapse', collapse),
|
||||
]"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<template v-if="mode === 'horizontal' && getSlot.showSlotMore">
|
||||
<template v-for="item in getSlot.slotDefault" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
<SubMenu path="sub-menu-more" is-sub-menu-more>
|
||||
<template #title>
|
||||
<IcRoundMoreHoriz />
|
||||
</template>
|
||||
<template v-for="item in getSlot.slotMore" :key="item.key">
|
||||
<component :is="item" />
|
||||
</template>
|
||||
</SubMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
@@ -0,0 +1,2 @@
|
||||
export type * from './normal-menu';
|
||||
export { default as NormalMenu } from './normal-menu.vue';
|
@@ -0,0 +1,27 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
interface NormalMenuProps {
|
||||
/**
|
||||
* 菜单数据
|
||||
*/
|
||||
activePath?: string;
|
||||
/**
|
||||
* 是否折叠
|
||||
*/
|
||||
collapse?: boolean;
|
||||
/**
|
||||
* 菜单项
|
||||
*/
|
||||
menus?: MenuRecordRaw[];
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export type { NormalMenuProps };
|
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import type { NormalMenuProps } from './normal-menu';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'NormalMenu',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
activePath: '',
|
||||
collapse: false,
|
||||
menus: () => [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const { b, e, is } = useNamespace('normal-menu');
|
||||
|
||||
function handleClick(menu: MenuRecordRaw) {
|
||||
emit('select', menu);
|
||||
}
|
||||
|
||||
function handleMouseenter(menu: MenuRecordRaw) {
|
||||
emit('enter', menu);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="[
|
||||
b(),
|
||||
is('collapse', collapse),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
]"
|
||||
>
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<li
|
||||
:class="[e('item'), is('active', activePath === menu.path)]"
|
||||
@click="handleClick(menu)"
|
||||
@mouseenter="handleMouseenter(menu)"
|
||||
>
|
||||
<VbenIcon :class="e('icon')" :icon="menu.icon" fallback />
|
||||
<span :class="e('name')"> {{ menu.name }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
@include b('normal-menu') {
|
||||
--menu-item-margin-y: 4px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-padding-y: 11px;
|
||||
--menu-item-padding-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
--menu-dark-background: 0deg 0% 100% / 10%;
|
||||
// --menu-light-background: 240deg 5% 96%;
|
||||
|
||||
position: relative;
|
||||
height: calc(100% - 4px);
|
||||
|
||||
@include is('rounded') {
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-margin-x: 8px;
|
||||
}
|
||||
|
||||
@include is('dark') {
|
||||
.#{$namespace}-normal-menu__item {
|
||||
color: hsl(var(--color-dark-foreground) / 80%);
|
||||
}
|
||||
}
|
||||
|
||||
@include e('item') {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// max-width: 64px;
|
||||
// max-height: 64px;
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: var(--menu-item-margin-y) var(--menu-item-margin-x);
|
||||
color: hsl(var(--color-foreground) / 90%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
// color 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
@include is('active') {
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background-color: hsl(var(--color-primary));
|
||||
|
||||
.#{$namespace}-normal-menu__name {
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
background-color: hsl(var(--menu-dark-background));
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include is('dark') {
|
||||
.#{$namespace}-normal-menu__item {
|
||||
&:not(.is-active):hover {
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background-color: hsl(var(--menu-dark-background));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include is('collapse') {
|
||||
.#{$namespace}-normal-menu__name {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@include e('icon') {
|
||||
max-height: 20px;
|
||||
font-size: 20px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
@include e('name') {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
IcRoundChevronRight,
|
||||
IcRoundKeyboardArrowDown,
|
||||
} from '@vben-core/iconify';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useMenuContext } from '../hooks';
|
||||
|
||||
import type { MenuItemProps } from '../interface';
|
||||
|
||||
interface Props extends MenuItemProps {
|
||||
isMenuMore: boolean;
|
||||
isTopLevelMenuSubmenu: boolean;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenuContent' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isMenuMore: false,
|
||||
level: 0,
|
||||
});
|
||||
|
||||
const rootMenu = useMenuContext();
|
||||
const { b, e, is } = useNamespace('sub-menu-content');
|
||||
const nsMenu = useNamespace('menu');
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
|
||||
const collapse = computed(() => {
|
||||
return rootMenu.props.collapse;
|
||||
});
|
||||
|
||||
const isFirstLevel = computed(() => {
|
||||
return props.level === 1;
|
||||
});
|
||||
|
||||
const getCollapseShowTitle = computed(() => {
|
||||
return (
|
||||
rootMenu.props.collapseShowTitle && isFirstLevel.value && collapse.value
|
||||
);
|
||||
});
|
||||
|
||||
const mode = computed(() => {
|
||||
return rootMenu?.props.mode;
|
||||
});
|
||||
|
||||
const showArrowIcon = computed(() => {
|
||||
return mode.value === 'horizontal' || !(isFirstLevel.value && collapse.value);
|
||||
});
|
||||
|
||||
const hiddenTitle = computed(() => {
|
||||
return (
|
||||
mode.value === 'vertical' &&
|
||||
isFirstLevel.value &&
|
||||
collapse.value &&
|
||||
!getCollapseShowTitle.value
|
||||
);
|
||||
});
|
||||
|
||||
const iconComp = computed(() => {
|
||||
return (mode.value === 'horizontal' && !isFirstLevel.value) ||
|
||||
(mode.value === 'vertical' && collapse.value)
|
||||
? IcRoundChevronRight
|
||||
: IcRoundKeyboardArrowDown;
|
||||
});
|
||||
|
||||
const iconArrowStyle = computed(() => {
|
||||
return opened.value ? { transform: `rotate(180deg)` } : {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
b(),
|
||||
is('collapse-show-title', getCollapseShowTitle),
|
||||
is('more', isMenuMore),
|
||||
]"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<VbenIcon
|
||||
v-if="isTopLevelMenuSubmenu && !isMenuMore"
|
||||
:class="nsMenu.e('icon')"
|
||||
:icon="icon"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<div v-if="!hiddenTitle" :class="[e('title')]">
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="iconComp"
|
||||
v-if="!isMenuMore"
|
||||
v-show="showArrowIcon"
|
||||
:class="[e('icon-arrow')]"
|
||||
:style="iconArrowStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
271
packages/@vben-core/uikit/menu-ui/src/components/sub-menu.vue
Normal file
271
packages/@vben-core/uikit/menu-ui/src/components/sub-menu.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { VbenHoverCard } from '@vben-core/shadcn-ui';
|
||||
import { useNamespace } from '@vben-core/toolkit';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
createSubMenuContext,
|
||||
useMenu,
|
||||
useMenuContext,
|
||||
useMenuStyle,
|
||||
useSubMenuContext,
|
||||
} from '../hooks';
|
||||
import CollapseTransition from './collapse-transition.vue';
|
||||
import SubMenuContent from './sub-menu-content.vue';
|
||||
|
||||
import type {
|
||||
MenuItemRegistered,
|
||||
MenuProvider,
|
||||
SubMenuProps,
|
||||
} from '../interface';
|
||||
|
||||
interface Props extends SubMenuProps {
|
||||
isSubMenuMore?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
isSubMenuMore: false,
|
||||
});
|
||||
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
const { b, is } = useNamespace('sub-menu');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const subMenuStyle = useMenuStyle(subMenu);
|
||||
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const timer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
handleMouseleave,
|
||||
level: (subMenu?.level ?? 0) + 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
const isTopLevelMenuSubmenu = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
|
||||
const rounded = computed(() => rootMenu?.props.rounded);
|
||||
const currentLevel = computed(() => subMenu?.level ?? 0);
|
||||
const isFirstLevel = computed(() => {
|
||||
return currentLevel.value === 1;
|
||||
});
|
||||
|
||||
const contentProps = computed((): HoverCardContentProps => {
|
||||
const side =
|
||||
mode.value === 'horizontal' && isFirstLevel.value ? 'bottom' : 'right';
|
||||
return {
|
||||
side,
|
||||
sideOffset: isFirstLevel.value ? 5 : 10,
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed(() => {
|
||||
let isActive = false;
|
||||
|
||||
Object.values(items.value).forEach((item) => {
|
||||
if (item.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(subMenus.value).forEach((subItem) => {
|
||||
if (subItem.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
return isActive;
|
||||
});
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击submenu展开/关闭
|
||||
*/
|
||||
function handleClick() {
|
||||
const mode = rootMenu?.props.mode;
|
||||
if (
|
||||
// 当前菜单禁用时,不展开
|
||||
props.disabled ||
|
||||
(rootMenu?.props.collapse && mode === 'vertical') ||
|
||||
// 水平模式下不展开
|
||||
mode === 'horizontal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootMenu?.handleSubMenuClick({
|
||||
active: active.value,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
|
||||
if (event.type === 'focus') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
|
||||
props.disabled
|
||||
) {
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
rootMenu?.openMenu(props.path, parentPaths.value);
|
||||
}, showTimeout);
|
||||
parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
}
|
||||
|
||||
function handleMouseleave(deepDispatch = false) {
|
||||
if (
|
||||
!rootMenu?.props.collapse &&
|
||||
rootMenu?.props.mode === 'vertical' &&
|
||||
subMenu
|
||||
) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
}
|
||||
timer.value = setTimeout(() => {
|
||||
!mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
|
||||
}, 300);
|
||||
|
||||
if (deepDispatch) {
|
||||
subMenu?.handleMouseleave?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
const item = reactive({
|
||||
active,
|
||||
parentPaths,
|
||||
path: props.path,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addSubMenu?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeSubMenu?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
b(),
|
||||
is('opened', opened),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
]"
|
||||
@focus="handleMouseenter"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="() => handleMouseleave()"
|
||||
>
|
||||
<template v-if="rootMenu.isMenuPopup">
|
||||
<VbenHoverCard
|
||||
:content-class="[
|
||||
nsMenu.e('popup-container'),
|
||||
is(rootMenu.theme, true),
|
||||
opened ? '' : 'hidden',
|
||||
]"
|
||||
:content-props="contentProps"
|
||||
:open="true"
|
||||
:open-delay="0"
|
||||
>
|
||||
<template #trigger>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
:icon="icon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
</template>
|
||||
<div
|
||||
:class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
|
||||
@focus="(e) => handleMouseenter(e, 100)"
|
||||
@mouseenter="(e) => handleMouseenter(e, 100)"
|
||||
@mouseleave="() => handleMouseleave(true)"
|
||||
>
|
||||
<ul
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</VbenHoverCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SubMenuContent
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
:icon="icon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:class="is('active', active)"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
<CollapseTransition>
|
||||
<ul
|
||||
v-show="opened"
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</CollapseTransition>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
2
packages/@vben-core/uikit/menu-ui/src/hooks/index.ts
Normal file
2
packages/@vben-core/uikit/menu-ui/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-menu';
|
||||
export * from './use-menu-context';
|
@@ -0,0 +1,55 @@
|
||||
import { getCurrentInstance, inject, provide } from 'vue';
|
||||
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
import type { MenuProvider, SubMenuProvider } from '../interface';
|
||||
|
||||
const menuContextKey = Symbol('menuContext');
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createMenuContext(injectMenuData: MenuProvider) {
|
||||
provide(menuContextKey, injectMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Provide menu context
|
||||
*/
|
||||
function createSubMenuContext(injectSubMenuData: SubMenuProvider) {
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
provide(`subMenu:${instance?.uid}`, injectSubMenuData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const rootMenu = inject(menuContextKey) as MenuProvider;
|
||||
return rootMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN Inject menu context
|
||||
*/
|
||||
function useSubMenuContext() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
const parentMenu = findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
const subMenu = inject(`subMenu:${parentMenu?.uid}`) as SubMenuProvider;
|
||||
return subMenu;
|
||||
}
|
||||
|
||||
export {
|
||||
createMenuContext,
|
||||
createSubMenuContext,
|
||||
useMenuContext,
|
||||
useSubMenuContext,
|
||||
};
|
47
packages/@vben-core/uikit/menu-ui/src/hooks/use-menu.ts
Normal file
47
packages/@vben-core/uikit/menu-ui/src/hooks/use-menu.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { computed, getCurrentInstance } from 'vue';
|
||||
|
||||
import { SubMenuProvider } from '../interface';
|
||||
import { findComponentUpward } from '../utils';
|
||||
|
||||
function useMenu() {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) {
|
||||
throw new Error('instance is required');
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 获取所有父级菜单链路
|
||||
*/
|
||||
const parentPaths = computed(() => {
|
||||
let parent = instance.parent;
|
||||
const paths: string[] = [instance.props.path as string];
|
||||
while (parent?.type.name !== 'Menu') {
|
||||
if (parent?.props.path) {
|
||||
paths.unshift(parent.props.path as string);
|
||||
}
|
||||
parent = parent?.parent ?? null;
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const parentMenu = computed(() => {
|
||||
return findComponentUpward(instance, ['Menu', 'SubMenu']);
|
||||
});
|
||||
|
||||
return {
|
||||
parentMenu,
|
||||
parentPaths,
|
||||
};
|
||||
}
|
||||
|
||||
function useMenuStyle(menu?: SubMenuProvider) {
|
||||
const subMenuStyle = computed(() => {
|
||||
return {
|
||||
'--menu-level': menu ? menu?.level ?? 0 + 1 : 0,
|
||||
};
|
||||
});
|
||||
return subMenuStyle;
|
||||
}
|
||||
|
||||
export { useMenu, useMenuStyle };
|
5
packages/@vben-core/uikit/menu-ui/src/index.ts
Normal file
5
packages/@vben-core/uikit/menu-ui/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import './styles/index.scss';
|
||||
|
||||
export * from './components/normal-menu';
|
||||
export type * from './interface';
|
||||
export { default as Menu } from './menu.vue';
|
135
packages/@vben-core/uikit/menu-ui/src/interface/index.ts
Normal file
135
packages/@vben-core/uikit/menu-ui/src/interface/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { MenuRecordBadgeRaw, ThemeType } from '@vben-core/typings';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
interface MenuProps {
|
||||
/**
|
||||
* @zh_CN 是否开启手风琴模式
|
||||
* @default true
|
||||
*/
|
||||
accordion?: boolean;
|
||||
/**
|
||||
* @zh_CN 菜单是否折叠
|
||||
* @default false
|
||||
*/
|
||||
collapse?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单折叠时是否显示菜单名称
|
||||
* @default false
|
||||
*/
|
||||
collapseShowTitle?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认激活的菜单
|
||||
*/
|
||||
defaultActive?: string;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认展开的菜单
|
||||
*/
|
||||
defaultOpeneds?: string[];
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单模式
|
||||
* @default vertical
|
||||
*/
|
||||
mode?: 'horizontal' | 'vertical';
|
||||
|
||||
/**
|
||||
* @zh_CN 是否圆润风格
|
||||
* @default true
|
||||
*/
|
||||
rounded?: boolean;
|
||||
|
||||
/**
|
||||
* @zh_CN 菜单主题
|
||||
* @default dark
|
||||
*/
|
||||
theme?: ThemeType;
|
||||
}
|
||||
|
||||
interface SubMenuProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* @zh_CN submenu 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemProps extends MenuRecordBadgeRaw {
|
||||
/**
|
||||
* @zh_CN 是否禁用
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @zh_CN 图标
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* @zh_CN menuitem 名称
|
||||
*/
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuItemRegistered {
|
||||
active: boolean;
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
// export interface MenuItemClicked {
|
||||
// name: string;
|
||||
// }
|
||||
|
||||
interface MenuItemClicked {
|
||||
parentPaths: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MenuProvider {
|
||||
activePath?: string;
|
||||
addMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
closeMenu: (path: string, parentLinks: string[]) => void;
|
||||
handleMenuItemClick: (item: MenuItemClicked) => void;
|
||||
handleSubMenuClick: (subMenu: MenuItemRegistered) => void;
|
||||
isMenuPopup: boolean;
|
||||
items: Record<string, MenuItemRegistered>;
|
||||
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
openedMenus: string[];
|
||||
props: MenuProps;
|
||||
removeMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
|
||||
subMenus: Record<string, MenuItemRegistered>;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
interface SubMenuProvider {
|
||||
addSubMenu: (item: MenuItemRegistered) => void;
|
||||
handleMouseleave?: (deepDispatch: boolean) => void;
|
||||
level: number;
|
||||
mouseInChild: Ref<boolean>;
|
||||
removeSubMenu: (item: MenuItemRegistered) => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
MenuItemClicked,
|
||||
MenuItemProps,
|
||||
MenuItemRegistered,
|
||||
MenuProps,
|
||||
MenuProvider,
|
||||
SubMenuProps,
|
||||
SubMenuProvider,
|
||||
};
|
37
packages/@vben-core/uikit/menu-ui/src/menu.vue
Normal file
37
packages/@vben-core/uikit/menu-ui/src/menu.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import { useForwardProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Menu } from './components';
|
||||
import { MenuProps } from './interface';
|
||||
import SubMenu from './sub-menu.vue';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuUi',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapse: false,
|
||||
// theme: 'dark',
|
||||
});
|
||||
|
||||
const forward = useForwardProps(props);
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'update:openKeys': [key: Key[]];
|
||||
// 'update:selectedKeys': [key: Key[]];
|
||||
// }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu v-bind="forward">
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<SubMenu :menu="menu" />
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
508
packages/@vben-core/uikit/menu-ui/src/styles/index.scss
Normal file
508
packages/@vben-core/uikit/menu-ui/src/styles/index.scss
Normal file
@@ -0,0 +1,508 @@
|
||||
@import '@vben-core/design/global';
|
||||
|
||||
.#{$namespace}-menu__popup-container,
|
||||
.#{$namespace}-menu {
|
||||
--menu-title-width: 140px;
|
||||
--menu-item-icon-width: 20px;
|
||||
--menu-item-height: 38px;
|
||||
--menu-item-padding-y: 26px;
|
||||
--menu-item-padding-x: 12px;
|
||||
--menu-item-popup-padding-y: 22px;
|
||||
--menu-item-popup-padding-x: 12px;
|
||||
--menu-item-margin-y: 4px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-collapse-padding-y: 25px;
|
||||
--menu-item-collapse-padding-x: 0px;
|
||||
--menu-item-collapse-margin-y: 4px;
|
||||
--menu-item-collapse-margin-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
--menu-item-indent: 24px;
|
||||
--menu-font-size: 14px;
|
||||
--menu-dark-background: 0deg 0% 100% / 10%;
|
||||
--menu-light-background: 192deg 1% 93%;
|
||||
|
||||
&.is-dark {
|
||||
--menu-background-color: hsl(var(--color-menu-dark));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--color-menu-opened-dark));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--color-dark-foreground) / 80%);
|
||||
--menu-item-hover-color: hsl(var(--color-primary-foreground));
|
||||
--menu-item-hover-background-color: hsl(var(--menu-dark-background));
|
||||
--menu-item-active-color: hsl(var(--color-primary-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--color-primary));
|
||||
--menu-submenu-hover-color: hsl(var(--color-dark-foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--menu-dark-background));
|
||||
--menu-submenu-active-color: hsl(var(--color-dark-foreground));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-background-color: hsl(var(--color-menu));
|
||||
// --menu-submenu-opened-background-color: hsl(var(--color-menu-opened));
|
||||
--menu-item-background-color: var(--menu-background-color);
|
||||
--menu-item-color: hsl(var(--color-foreground));
|
||||
--menu-item-hover-color: var(--menu-item-color);
|
||||
--menu-item-hover-background-color: hsl(var(--menu-light-background));
|
||||
--menu-item-active-color: hsl(var(--color-primary-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--color-primary));
|
||||
--menu-submenu-hover-color: hsl(var(--color-primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--menu-light-background));
|
||||
--menu-submenu-active-color: hsl(var(--color-primary));
|
||||
--menu-submenu-active-background-color: transparent;
|
||||
--menu-submenu-background-color: var(--menu-background-color);
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-margin-x: 8px;
|
||||
--menu-item-collapse-margin-x: 6px;
|
||||
--menu-item-radius: 6px;
|
||||
}
|
||||
|
||||
&.is-horizontal:not(.is-rounded) {
|
||||
--menu-item-height: 60px;
|
||||
--menu-item-radius: 0px;
|
||||
}
|
||||
|
||||
&.is-horizontal.is-rounded {
|
||||
--menu-item-height: 40px;
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-padding-x: 12px;
|
||||
}
|
||||
|
||||
// .vben-menu__popup,
|
||||
&.is-horizontal {
|
||||
--menu-item-padding-y: 0px;
|
||||
--menu-item-padding-x: 10px;
|
||||
--menu-item-margin-y: 0px;
|
||||
--menu-item-margin-x: 1px;
|
||||
--menu-background-color: transparent;
|
||||
|
||||
&.is-dark {
|
||||
--menu-item-hover-color: var(--color-foreground);
|
||||
--menu-item-hover-background-color: hsl(var(--menu-dark-background));
|
||||
--menu-item-active-color: hsl(var(--color-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--menu-dark-background));
|
||||
--menu-submenu-active-color: hsl(var(--color-foreground));
|
||||
--menu-submenu-active-background-color: hsl(var(--menu-dark-background));
|
||||
--menu-submenu-hover-color: hsl(var(--color-foreground));
|
||||
--menu-submenu-hover-background-color: hsl(var(--menu-dark-background));
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
--menu-item-active-color: hsl(var(--color-foreground));
|
||||
--menu-item-active-background-color: hsl(var(--menu-light-background));
|
||||
--menu-item-hover-background-color: hsl(var(--menu-light-background));
|
||||
--menu-item-hover-color: hsl(var(--color-primary));
|
||||
--menu-submenu-hover-color: hsl(var(--color-primary));
|
||||
--menu-submenu-hover-background-color: hsl(var(--menu-light-background));
|
||||
--menu-submenu-active-color: hsl(var(--color-foreground));
|
||||
--menu-submenu-active-background-color: hsl(var(--menu-light-background));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-item-active {
|
||||
color: var(--menu-item-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-active-background-color);
|
||||
}
|
||||
|
||||
@mixin menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
// gap: 12px;
|
||||
align-items: center;
|
||||
height: var(--menu-item-height);
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: 0 var(--menu-item-margin-x) var(--menu-item-margin-y)
|
||||
var(--menu-item-margin-x);
|
||||
font-size: var(--menu-font-size);
|
||||
color: var(--menu-item-color);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-background-color);
|
||||
border: none;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
@include is(disabled) {
|
||||
cursor: not-allowed;
|
||||
background: none !important;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__icon {
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-menu__icon {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
* {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin menu-title {
|
||||
max-width: var(--menu-title-width);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@include b('menu') {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: hsl(var(--menu-background-color));
|
||||
|
||||
// 垂直菜单
|
||||
@include is('vertical') {
|
||||
& {
|
||||
&:not(.#{$namespace}-menu.is-collapse) {
|
||||
& .#{$namespace}-menu-item,
|
||||
& .#{$namespace}-sub-menu-content,
|
||||
& .#{$namespace}-menu-item-group__title {
|
||||
padding-left: calc(
|
||||
var(--menu-item-indent) + var(--menu-level) *
|
||||
var(--menu-item-indent)
|
||||
);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
// .#{$namespace}-menu {
|
||||
// background: var(--menu-submenu-opened-background-color);
|
||||
|
||||
// .#{$namespace}-sub-menu,
|
||||
// .#{$namespace}-menu-item:not(.is-active),
|
||||
// .#{$namespace}-sub-menu-content:not(.is-active) {
|
||||
// background: var(--menu-submenu-opened-background-color);
|
||||
// }
|
||||
// }
|
||||
& > .#{$namespace}-menu {
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(
|
||||
0px + var(--menu-item-indent) + var(--menu-level) *
|
||||
var(--menu-item-indent)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu-content {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
& > .#{$namespace}-menu-item {
|
||||
padding-left: calc(var(--menu-item-indent) - 8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include is('horizontal') {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
height: var(--height-horizontal-height);
|
||||
border-right: none;
|
||||
|
||||
.#{$namespace}-menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--menu-item-height);
|
||||
padding-right: calc(var(--menu-item-padding-x) + 6px);
|
||||
margin: 0;
|
||||
margin-right: 2px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
|
||||
& > .#{$namespace}-sub-menu {
|
||||
height: var(--menu-item-height);
|
||||
margin-right: 2px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& .#{$namespace}-sub-menu-content {
|
||||
height: 100%;
|
||||
padding-right: 40px;
|
||||
// border-bottom: 2px solid transparent;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
& .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& > .#{$namespace}-menu-item.is-active {
|
||||
color: var(--menu-item-active-color);
|
||||
}
|
||||
|
||||
// &.is-light {
|
||||
// & > .#{$namespace}-sub-menu {
|
||||
// &.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// &:not(.is-active) .#{$namespace}-sub-menu-content {
|
||||
// &:hover {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// & > .#{$namespace}-menu-item.is-active {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):hover,
|
||||
// & .#{$namespace}-menu-item:not(.is-disabled):focus {
|
||||
// border-bottom: 2px solid var(--menu-item-active-color);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// 折叠菜单
|
||||
|
||||
@include is('collapse') {
|
||||
.#{$namespace}-menu__icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
.#{$namespace}-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--menu-item-collapse-padding-y)
|
||||
var(--menu-item-collapse-padding-x);
|
||||
margin: var(--menu-item-collapse-margin-y)
|
||||
var(--menu-item-collapse-margin-x);
|
||||
transition: all 0.3s;
|
||||
|
||||
&.is-active {
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
border-radius: var(--menu-item-radius);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-active {
|
||||
color: hsl(var(--color-primary-foreground)) !important;
|
||||
background: var(--menu-item-active-background-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-rounded {
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
&.is-collapse-show-title {
|
||||
// padding: 32px 0 !important;
|
||||
margin: 4px 8px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include e('popup-container') {
|
||||
max-width: 240px;
|
||||
height: unset;
|
||||
padding: 0;
|
||||
background: var(--menu-background-color);
|
||||
}
|
||||
|
||||
@include e('popup') {
|
||||
padding: 4px 0;
|
||||
border-radius: var(--menu-item-radius);
|
||||
|
||||
.#{$namespace}-sub-menu-content,
|
||||
.#{$namespace}-menu-item {
|
||||
padding: var(--menu-item-popup-padding-y) var(--menu-item-popup-padding-x);
|
||||
}
|
||||
}
|
||||
|
||||
@include e('icon') {
|
||||
flex-shrink: 0;
|
||||
// width: var(--menu-item-icon-width);
|
||||
max-height: var(--menu-item-icon-width);
|
||||
margin-right: 12px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@include b('menu-item') {
|
||||
fill: var(--menu-item-color);
|
||||
stroke: var(--menu-item-color);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
@include is(active) {
|
||||
fill: var(--menu-item-active-color);
|
||||
stroke: var(--menu-item-active-color);
|
||||
|
||||
@include menu-item-active;
|
||||
}
|
||||
|
||||
@include e('content') {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--menu-item-height);
|
||||
}
|
||||
|
||||
@include is('collapse-show-title') {
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
flex-direction: column;
|
||||
}
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu__name {
|
||||
display: inline-flex;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: var(--menu-item-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-item-hover-background-color) !important;
|
||||
}
|
||||
|
||||
.#{$namespace}-menu-tooltip__trigger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 var(--menu-item-padding-x);
|
||||
font-size: var(--menu-font-size);
|
||||
line-height: var(--menu-item-height);
|
||||
}
|
||||
}
|
||||
|
||||
@include b('sub-menu') {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--menu-submenu-background-color);
|
||||
fill: var(--menu-item-color);
|
||||
stroke: var(--menu-item-color);
|
||||
|
||||
@include is('active') {
|
||||
div[data-state='open'] > .#{$namespace}-sub-menu-content,
|
||||
> .#{$namespace}-sub-menu-content {
|
||||
color: var(--menu-submenu-active-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-active-background-color);
|
||||
fill: var(--menu-submenu-active-color);
|
||||
stroke: var(--menu-submenu-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include b('sub-menu-content') {
|
||||
height: var(--menu-item-height);
|
||||
|
||||
@include menu-item;
|
||||
|
||||
@include e('icon-arrow') {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
width: inherit;
|
||||
margin-top: -8px;
|
||||
margin-right: 0;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
opacity: 1;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
@include e('title') {
|
||||
@include menu-title;
|
||||
}
|
||||
|
||||
@include is('collapse-show-title') {
|
||||
flex-direction: column;
|
||||
padding: 32px 0 !important;
|
||||
// margin: 4px 8px !important;
|
||||
.#{$namespace}-menu__icon {
|
||||
display: block;
|
||||
font-size: 20px !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.#{$namespace}-sub-menu-content__title {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-more {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
color: var(--menu-submenu-hover-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background: var(--menu-submenu-hover-background-color);
|
||||
|
||||
svg {
|
||||
fill: var(--menu-submenu-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user