This commit is contained in:
dap
2024-08-16 07:41:43 +08:00
33 changed files with 935 additions and 481 deletions

View File

@@ -42,9 +42,9 @@ version-resolver:
minor: minor:
labels: labels:
- "minor" - "minor"
- "feature"
patch: patch:
labels: labels:
- "feature"
- "patch" - "patch"
- "bug" - "bug"
- "maintenance" - "maintenance"

View File

@@ -2,6 +2,10 @@ import type { DefaultTheme, HeadConfig } from 'vitepress';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import {
GitChangelog,
GitChangelogMarkdownSection,
} from '@nolebase/vitepress-plugin-git-changelog/vite';
import { type PwaOptions, withPwa } from '@vite-pwa/vitepress'; import { type PwaOptions, withPwa } from '@vite-pwa/vitepress';
import { defineConfigWithTheme } from 'vitepress'; import { defineConfigWithTheme } from 'vitepress';
@@ -98,6 +102,12 @@ export default withPwa(
json: { json: {
stringify: true, stringify: true,
}, },
plugins: [
GitChangelog({
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
}),
GitChangelogMarkdownSection(),
],
server: { server: {
fs: { fs: {
allow: ['../..'], allow: ['../..'],

View File

@@ -1,6 +1,7 @@
// https://vitepress.dev/guide/custom-theme // https://vitepress.dev/guide/custom-theme
import type { Theme } from 'vitepress'; import type { Theme } from 'vitepress';
import { NolebaseGitChangelogPlugin } from '@nolebase/vitepress-plugin-git-changelog/client';
import DefaultTheme from 'vitepress/theme'; import DefaultTheme from 'vitepress/theme';
import SiteLayout from './components/site-layout.vue'; import SiteLayout from './components/site-layout.vue';
@@ -9,11 +10,13 @@ import { initHmPlugin } from './plugins/hm';
import './styles'; import './styles';
import '@nolebase/vitepress-plugin-git-changelog/client/style.css';
export default { export default {
enhanceApp({ app }) { enhanceApp({ app }) {
// ... // ...
app.component('VbenContributors', VbenContributors); app.component('VbenContributors', VbenContributors);
app.use(NolebaseGitChangelogPlugin);
// 百度统计 // 百度统计
initHmPlugin(); initHmPlugin();
}, },

View File

@@ -11,6 +11,7 @@
"medium-zoom": "^1.1.0" "medium-zoom": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nolebase/vitepress-plugin-git-changelog": "^2.4.0",
"@vite-pwa/vitepress": "^0.5.0", "@vite-pwa/vitepress": "^0.5.0",
"vitepress": "^1.3.2", "vitepress": "^1.3.2",
"vue": "^3.4.37" "vue": "^3.4.37"

View File

@@ -223,7 +223,7 @@ css 变量内的颜色,必须使用 `hsl` 格式,如 `0 0% 100%`,不需要
你只需要在你的项目中覆盖你想要修改的 CSS 变量即可。例如,要更改默认卡片背景色,你可以在你的 CSS 文件中添加以下内容进行覆盖: 你只需要在你的项目中覆盖你想要修改的 CSS 变量即可。例如,要更改默认卡片背景色,你可以在你的 CSS 文件中添加以下内容进行覆盖:
### 默认主题下 ### 默认主题下
```css ```css
:root { :root {
@@ -1222,7 +1222,7 @@ export const overridesPreferences = defineOverridesPreferences({
侧边栏颜色通过`--sidebar`变量来配置 侧边栏颜色通过`--sidebar`变量来配置
### 默认主题下 ### 默认主题下
```css ```css
:root { :root {
@@ -1244,7 +1244,7 @@ export const overridesPreferences = defineOverridesPreferences({
侧边栏颜色通过`--header`变量来配置 侧边栏颜色通过`--header`变量来配置
### 默认主题下 ### 默认主题下
```css ```css
:root { :root {

View File

@@ -27,7 +27,7 @@
} }
}, },
"dependencies": { "dependencies": {
"eslint-config-turbo": "^2.0.12", "eslint-config-turbo": "^2.0.13",
"eslint-plugin-command": "^0.2.3", "eslint-plugin-command": "^0.2.3",
"eslint-plugin-import-x": "^3.1.0" "eslint-plugin-import-x": "^3.1.0"
}, },
@@ -39,7 +39,7 @@
"eslint": "^9.9.0", "eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jsdoc": "^50.2.0", "eslint-plugin-jsdoc": "^50.2.2",
"eslint-plugin-jsonc": "^2.16.0", "eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-n": "^17.10.2", "eslint-plugin-n": "^17.10.2",
"eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-no-only-tests": "^3.1.0",

View File

@@ -55,8 +55,8 @@
"postcss": "^8.4.41", "postcss": "^8.4.41",
"postcss-antd-fixes": "^0.2.0", "postcss-antd-fixes": "^0.2.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-preset-env": "^10.0.0", "postcss-preset-env": "^10.0.1",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.10",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -42,7 +42,7 @@
"@types/html-minifier-terser": "^7.0.2", "@types/html-minifier-terser": "^7.0.2",
"@vben/node-utils": "workspace:*", "@vben/node-utils": "workspace:*",
"@vitejs/plugin-vue": "^5.1.2", "@vitejs/plugin-vue": "^5.1.2",
"@vitejs/plugin-vue-jsx": "^4.0.0", "@vitejs/plugin-vue-jsx": "^4.0.1",
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"rollup": "^4.20.0", "rollup": "^4.20.0",
@@ -50,7 +50,7 @@
"sass": "^1.77.8", "sass": "^1.77.8",
"vite": "^5.4.0", "vite": "^5.4.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-dts": "4.0.2", "vite-plugin-dts": "4.0.3",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2"
} }
} }

View File

@@ -23,7 +23,7 @@ import { viteImportMapPlugin } from './importmap';
import { viteInjectAppLoadingPlugin } from './inject-app-loading'; import { viteInjectAppLoadingPlugin } from './inject-app-loading';
import { viteMetadataPlugin } from './inject-metadata'; import { viteMetadataPlugin } from './inject-metadata';
import { viteLicensePlugin } from './license'; import { viteLicensePlugin } from './license';
import { viteNitroMockPlugin } from './nitor-mock'; import { viteNitroMockPlugin } from './nitro-mock';
import { vitePrintPlugin } from './print'; import { vitePrintPlugin } from './print';
/** /**

View File

@@ -60,7 +60,7 @@
"@changesets/cli": "^2.27.7", "@changesets/cli": "^2.27.7",
"@ls-lint/ls-lint": "^2.2.3", "@ls-lint/ls-lint": "^2.2.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.2.0", "@types/node": "^22.3.0",
"@vben/commitlint-config": "workspace:*", "@vben/commitlint-config": "workspace:*",
"@vben/eslint-config": "workspace:*", "@vben/eslint-config": "workspace:*",
"@vben/prettier-config": "workspace:*", "@vben/prettier-config": "workspace:*",
@@ -71,7 +71,7 @@
"@vben/vite-config": "workspace:*", "@vben/vite-config": "workspace:*",
"@vben/vsh": "workspace:*", "@vben/vsh": "workspace:*",
"@vitejs/plugin-vue": "^5.1.2", "@vitejs/plugin-vue": "^5.1.2",
"@vitejs/plugin-vue-jsx": "^4.0.0", "@vitejs/plugin-vue-jsx": "^4.0.1",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -81,8 +81,8 @@
"jsdom": "^24.1.1", "jsdom": "^24.1.1",
"lint-staged": "^15.2.9", "lint-staged": "^15.2.9",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.10",
"turbo": "^2.0.12", "turbo": "^2.0.13",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"unbuild": "^2.0.0", "unbuild": "^2.0.0",
"vite": "^5.4.0", "vite": "^5.4.0",

View File

@@ -9,6 +9,7 @@ export {
Bell, Bell,
BookOpenText, BookOpenText,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
Copy, Copy,

View File

@@ -3,6 +3,7 @@
* @en_US Layout content height * @en_US Layout content height
*/ */
export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`; export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`;
export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`;
/** /**
* @zh_CN 默认命名空间 * @zh_CN 默认命名空间

View File

@@ -1,140 +1,127 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getElementVisibleHeight } from './dom'; // 假设函数位于 utils.ts 中 import { getElementVisibleRect } from './dom'; // 假设函数位于 utils.ts 中
describe('getElementVisibleHeight', () => { describe('getElementVisibleRect', () => {
// Mocking the getBoundingClientRect method // 设置浏览器视口尺寸的 mock
const mockGetBoundingClientRect = vi.fn(); beforeEach(() => {
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(
800,
beforeAll(() => { );
// Mock getBoundingClientRect method vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
Element.prototype.getBoundingClientRect = mockGetBoundingClientRect; vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(
1000,
);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
}); });
afterAll(() => { it('should return default rect if element is undefined', () => {
// Restore original getBoundingClientRect method expect(getElementVisibleRect()).toEqual({
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; bottom: 0,
}); height: 0,
it('should return 0 if the element is null or undefined', () => {
expect(getElementVisibleHeight(null)).toBe(0);
expect(getElementVisibleHeight()).toBe(0);
});
it('should return the visible height of the element', () => {
// Mock the getBoundingClientRect return value
mockGetBoundingClientRect.mockReturnValue({
bottom: 500,
height: 400,
left: 0, left: 0,
right: 0, right: 0,
toJSON: () => ({}), top: 0,
width: 0,
});
});
it('should return default rect if element is null', () => {
expect(getElementVisibleRect(null)).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
it('should return correct visible rect when element is fully visible', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100, top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the top', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 200,
height: 250,
left: 100,
right: 500,
top: -50,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 200,
height: 200,
left: 100,
right: 500,
top: 0,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the right', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 800,
right: 1200,
top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 800,
right: 1000,
top: 100,
width: 200,
});
});
it('should return all zeros when element is completely off-screen', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 1200,
height: 300,
left: 1100,
right: 1400,
top: 900,
width: 300,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 800,
height: 0,
left: 1100,
right: 1000,
top: 900,
width: 0, width: 0,
x: 0,
y: 0,
}); });
const mockElement = document.createElement('div');
document.body.append(mockElement);
// Mocking window.innerHeight and document.documentElement.clientHeight
const originalInnerHeight = window.innerHeight;
const originalClientHeight = document.documentElement.clientHeight;
Object.defineProperty(window, 'innerHeight', {
value: 600,
writable: true,
});
Object.defineProperty(document.documentElement, 'clientHeight', {
value: 600,
writable: true,
});
expect(getElementVisibleHeight(mockElement)).toBe(400);
// Restore original values
Object.defineProperty(window, 'innerHeight', {
value: originalInnerHeight,
writable: true,
});
Object.defineProperty(document.documentElement, 'clientHeight', {
value: originalClientHeight,
writable: true,
});
mockElement.remove();
});
it('should return the visible height when element is partially out of viewport', () => {
// Mock the getBoundingClientRect return value
mockGetBoundingClientRect.mockReturnValue({
bottom: 300,
height: 400,
left: 0,
right: 0,
toJSON: () => ({}),
top: -100,
width: 0,
x: 0,
y: 0,
});
const mockElement = document.createElement('div');
document.body.append(mockElement);
// Mocking window.innerHeight and document.documentElement.clientHeight
const originalInnerHeight = window.innerHeight;
const originalClientHeight = document.documentElement.clientHeight;
Object.defineProperty(window, 'innerHeight', {
value: 600,
writable: true,
});
Object.defineProperty(document.documentElement, 'clientHeight', {
value: 600,
writable: true,
});
expect(getElementVisibleHeight(mockElement)).toBe(300);
// Restore original values
Object.defineProperty(window, 'innerHeight', {
value: originalInnerHeight,
writable: true,
});
Object.defineProperty(document.documentElement, 'clientHeight', {
value: originalClientHeight,
writable: true,
});
mockElement.remove();
});
it('should return 0 if the element is completely out of viewport', () => {
// Mock the getBoundingClientRect return value
mockGetBoundingClientRect.mockReturnValue({
bottom: -100,
height: 400,
left: 0,
right: 0,
toJSON: () => ({}),
top: -500,
width: 0,
x: 0,
y: 0,
});
const mockElement = document.createElement('div');
document.body.append(mockElement);
expect(getElementVisibleHeight(mockElement)).toBe(0);
mockElement.remove();
}); });
}); });

View File

@@ -1,12 +1,28 @@
export interface VisibleDomRect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
}
/** /**
* 获取元素可见高度 * 获取元素可见信息
* @param element * @param element
*/ */
function getElementVisibleHeight( export function getElementVisibleRect(
element?: HTMLElement | null | undefined, element?: HTMLElement | null | undefined,
): number { ): VisibleDomRect {
if (!element) { if (!element) {
return 0; return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
} }
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const viewHeight = Math.max( const viewHeight = Math.max(
@@ -17,7 +33,20 @@ function getElementVisibleHeight(
const top = Math.max(rect.top, 0); const top = Math.max(rect.top, 0);
const bottom = Math.min(rect.bottom, viewHeight); const bottom = Math.min(rect.bottom, viewHeight);
return Math.max(0, bottom - top); const viewWidth = Math.max(
} document.documentElement.clientWidth,
window.innerWidth,
);
export { getElementVisibleHeight }; const left = Math.max(rect.left, 0);
const right = Math.min(rect.right, viewWidth);
return {
bottom,
height: Math.max(0, bottom - top),
left,
right,
top,
width: Math.max(0, right - left),
};
}

View File

@@ -1,4 +1,4 @@
export * from './use-content-height'; export * from './use-content-style';
export * from './use-namespace'; export * from './use-namespace';
export * from './use-sortable'; export * from './use-sortable';
export { export {

View File

@@ -1,47 +0,0 @@
import { computed, onMounted, ref, watch } from 'vue';
import {
CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
getElementVisibleHeight,
} from '@vben-core/shared';
import { useCssVar, useDebounceFn, useWindowSize } from '@vueuse/core';
/**
* @zh_CN 获取内容高度(可视区域,不包含滚动条)
*/
function useContentHeight() {
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
const contentStyles = computed(() => {
return {
height: `var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT})`,
};
});
return { contentHeight, contentStyles };
}
/**
* @zh_CN 创建内容高度监听
*/
function useContentHeightListener() {
const contentElement = ref<HTMLDivElement | null>(null);
const { height, width } = useWindowSize();
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
const debouncedCalcHeight = useDebounceFn(() => {
contentHeight.value = `${getElementVisibleHeight(contentElement.value)}px`;
}, 200);
watch([height, width], () => {
debouncedCalcHeight();
});
onMounted(() => {
debouncedCalcHeight();
});
return { contentElement };
}
export { useContentHeight, useContentHeightListener };

View File

@@ -0,0 +1,55 @@
import type { CSSProperties } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import {
CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
CSS_VARIABLE_LAYOUT_CONTENT_WIDTH,
getElementVisibleRect,
type VisibleDomRect,
} from '@vben-core/shared';
import { useCssVar, useDebounceFn } from '@vueuse/core';
/**
* @zh_CN content style
*/
function useContentStyle() {
const contentElement = ref<HTMLDivElement | null>(null);
const visibleDomRect = ref<null | VisibleDomRect>(null);
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
const contentWidth = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_WIDTH);
const overlayStyle = computed((): CSSProperties => {
const { height, left, top, width } = visibleDomRect.value ?? {};
return {
height: `${height}px`,
left: `${left}px`,
position: 'fixed',
top: `${top}px`,
width: `${width}px`,
zIndex: 1000,
};
});
const debouncedCalcHeight = useDebounceFn(
(_entries: ResizeObserverEntry[]) => {
visibleDomRect.value = getElementVisibleRect(contentElement.value);
contentHeight.value = `${visibleDomRect.value.height}px`;
contentWidth.value = `${visibleDomRect.value.width}px`;
},
100,
);
onMounted(() => {
nextTick(() => {
if (contentElement.value) {
const observer = new ResizeObserver(debouncedCalcHeight);
observer.observe(contentElement.value);
}
});
});
return { contentElement, overlayStyle, visibleDomRect };
}
export { useContentStyle };

View File

@@ -4,7 +4,7 @@ import type { ContentCompactType } from '@vben-core/typings';
import type { CSSProperties } from 'vue'; import type { CSSProperties } from 'vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useContentHeightListener } from '@vben-core/composables'; import { useContentStyle } from '@vben-core/composables';
interface Props { interface Props {
/** /**
@@ -24,7 +24,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {}); const props = withDefaults(defineProps<Props>(), {});
const { contentElement } = useContentHeightListener(); const { contentElement, overlayStyle } = useContentStyle();
const style = computed((): CSSProperties => { const style = computed((): CSSProperties => {
const { const {
@@ -53,7 +53,9 @@ const style = computed((): CSSProperties => {
</script> </script>
<template> <template>
<main ref="contentElement" :style="style" class="bg-background-deep"> <main ref="contentElement" :style="style" class="bg-background-deep relative">
<!-- <BlurShadow :style="shadowStyle" /> -->
<slot :overlay-style="overlayStyle" name="overlay"></slot>
<slot></slot> <slot></slot>
</main> </main>
</template> </template>

View File

@@ -519,6 +519,10 @@ function handleOpenMenu() {
class="transition-[margin-top] duration-200" class="transition-[margin-top] duration-200"
> >
<slot name="content"></slot> <slot name="content"></slot>
<template #overlay="{ overlayStyle }">
<slot :overlay-style="overlayStyle" name="content-overlay"></slot>
</template>
</LayoutContent> </LayoutContent>
<LayoutFooter <LayoutFooter

View File

@@ -72,9 +72,10 @@ function scrollIntoView() {
<template> <template>
<div :style="style" class="tabs-chrome size-full flex-1 overflow-hidden pt-1"> <div :style="style" class="tabs-chrome size-full flex-1 overflow-hidden pt-1">
<VbenScrollbar <VbenScrollbar
id="tabs-scrollbar"
class="tabs-chrome__scrollbar h-full" class="tabs-chrome__scrollbar h-full"
horizontal horizontal
scroll-bar-class="z-10" scroll-bar-class="z-10 hidden"
> >
<!-- footer -> 4px --> <!-- footer -> 4px -->
<div <div

View File

@@ -72,11 +72,12 @@ function scrollIntoView() {
</script> </script>
<template> <template>
<div class="h-full flex-1 overflow-hidden"> <div class="size-full flex-1 overflow-hidden">
<VbenScrollbar <VbenScrollbar
id="tabs-scrollbar"
class="tabs-scrollbar h-full" class="tabs-scrollbar h-full"
horizontal horizontal
scroll-bar-class="z-10" scroll-bar-class="z-10 hidden"
> >
<div <div
:class="contentClass" :class="contentClass"

View File

@@ -7,8 +7,10 @@ import type { TabsProps } from './types';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useForwardPropsEmits, useSortable } from '@vben-core/composables'; import { useForwardPropsEmits, useSortable } from '@vben-core/composables';
import { ChevronLeft, ChevronRight } from '@vben-core/icons';
import { Tabs, TabsChrome } from './components'; import { Tabs, TabsChrome } from './components';
import { useTabsViewScroll } from './use-tabs-view-scroll';
interface Props extends TabsProps {} interface Props extends TabsProps {}
@@ -30,6 +32,8 @@ const emit = defineEmits<{
const forward = useForwardPropsEmits(props, emit); const forward = useForwardPropsEmits(props, emit);
const { initScrollbar, scrollDirection } = useTabsViewScroll();
const sortableInstance = ref<null | Sortable>(null); const sortableInstance = ref<null | Sortable>(null);
// 可能会找到拖拽的子元素这里需要确保拖拽的dom时tab元素 // 可能会找到拖拽的子元素这里需要确保拖拽的dom时tab元素
@@ -104,13 +108,21 @@ async function initTabsSortable() {
sortableInstance.value = await initializeSortable(); sortableInstance.value = await initializeSortable();
} }
onMounted(initTabsSortable); async function init() {
await nextTick();
initTabsSortable();
initScrollbar();
}
onMounted(() => {
init();
});
watch( watch(
() => props.styleType, () => props.styleType,
() => { () => {
sortableInstance.value?.destroy(); sortableInstance.value?.destroy();
initTabsSortable(); init();
}, },
); );
@@ -120,6 +132,32 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<TabsChrome v-if="styleType === 'chrome'" v-bind="forward" /> <div
<Tabs v-else v-bind="forward" /> :class="{
'overflow-hidden': styleType !== 'chrome',
}"
class="flex h-full flex-1"
>
<!-- 左侧滚动按钮 -->
<span
class="hover:bg-muted text-muted-foreground cursor-pointer border-r px-2"
@click="scrollDirection('left')"
>
<ChevronLeft class="size-4 h-full" />
</span>
<TabsChrome
v-if="styleType === 'chrome'"
v-bind="{ ...forward, ...$attrs, ...$props }"
/>
<Tabs v-else v-bind="{ ...forward, ...$attrs, ...$props }" />
<!-- 左侧滚动按钮 -->
<span
class="hover:bg-muted text-muted-foreground cursor-pointer border-l px-2"
@click="scrollDirection('right')"
>
<ChevronRight class="size-4 h-full" />
</span>
</div>
</template> </template>

View File

@@ -0,0 +1,59 @@
import { nextTick, ref } from 'vue';
type El = Element | null | undefined;
export function useTabsViewScroll(scrollDistance: number = 150) {
const scrollbarEl = ref<El>(null);
const scrollViewportEl = ref<El>(null);
function getScrollClientWidth() {
if (!scrollbarEl.value || !scrollViewportEl.value) return {};
const scrollbarWidth = scrollbarEl.value.clientWidth;
const scrollViewWidth = scrollViewportEl.value.clientWidth;
return {
scrollbarWidth,
scrollViewWidth,
};
}
function scrollDirection(
direction: 'left' | 'right',
distance: number = scrollDistance,
) {
const { scrollbarWidth, scrollViewWidth } = getScrollClientWidth();
if (!scrollbarWidth || !scrollViewWidth) return;
if (scrollbarWidth > scrollViewWidth) return;
scrollViewportEl.value?.scrollBy({
behavior: 'smooth',
left:
direction === 'left'
? -(scrollbarWidth - distance)
: +(scrollbarWidth - distance),
});
}
async function initScrollbar() {
await nextTick();
const barEl = document.querySelector('#tabs-scrollbar');
const viewportEl = barEl?.querySelector(
'div[data-radix-scroll-area-viewport]',
);
scrollbarEl.value = barEl;
scrollViewportEl.value = viewportEl;
const activeItem = viewportEl?.querySelector('.is-active');
activeItem?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
return {
initScrollbar,
scrollDirection,
};
}

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { CSSProperties } from 'vue';
import { VbenSpinner } from '@vben-core/shadcn-ui';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContentSpinner' });
defineProps<{ overlayStyle: CSSProperties }>();
const { spinning } = useContentSpinner();
</script>
<template>
<VbenSpinner :spinning="spinning" :style="overlayStyle" />
</template>

View File

@@ -7,20 +7,15 @@ import type {
import { type VNode } from 'vue'; import { type VNode } from 'vue';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import { useContentHeight } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences'; import { preferences, usePreferences } from '@vben/preferences';
import { storeToRefs, useTabbarStore } from '@vben/stores'; import { storeToRefs, useTabbarStore } from '@vben/stores';
import { VbenSpinner } from '@vben-core/shadcn-ui';
import { IFrameRouterView } from '../../iframe'; import { IFrameRouterView } from '../../iframe';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContent' }); defineOptions({ name: 'LayoutContent' });
const tabbarStore = useTabbarStore(); const tabbarStore = useTabbarStore();
const { keepAlive } = usePreferences(); const { keepAlive } = usePreferences();
const { spinning } = useContentSpinner();
const { contentStyles } = useContentHeight();
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } = const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
storeToRefs(tabbarStore); storeToRefs(tabbarStore);
@@ -86,11 +81,6 @@ function transformComponent(
<template> <template>
<div class="relative h-full"> <div class="relative h-full">
<VbenSpinner
v-if="preferences.transition.loading"
:spinning="spinning"
:style="contentStyles"
/>
<IFrameRouterView /> <IFrameRouterView />
<RouterView v-slot="{ Component, route }"> <RouterView v-slot="{ Component, route }">
<Transition :name="getTransitionName(route)" appear mode="out-in"> <Transition :name="getTransitionName(route)" appear mode="out-in">

View File

@@ -1 +1,2 @@
export { default as LayoutContent } from './content.vue'; export { default as LayoutContent } from './content.vue';
export { default as LayoutContentSpinner } from './content-spinner.vue';

View File

@@ -16,7 +16,7 @@ import { VbenAdminLayout } from '@vben-core/layout-ui';
import { Toaster, VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui'; import { Toaster, VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets'; import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
import { LayoutContent } from './content'; import { LayoutContent, LayoutContentSpinner } from './content';
import { Copyright } from './copyright'; import { Copyright } from './copyright';
import { LayoutFooter } from './footer'; import { LayoutFooter } from './footer';
import { LayoutHeader } from './header'; import { LayoutHeader } from './header';
@@ -297,6 +297,12 @@ const headerSlots = computed(() => {
<template #content> <template #content>
<LayoutContent /> <LayoutContent />
</template> </template>
<template
v-if="preferences.transition.loading"
#content-overlay="{ overlayStyle }"
>
<LayoutContentSpinner :overlay-style="overlayStyle" />
</template>
<!-- 页脚 --> <!-- 页脚 -->
<template v-if="preferences.footer.enable" #footer> <template v-if="preferences.footer.enable" #footer>

View File

@@ -0,0 +1,50 @@
import { ref } from 'vue';
type El = HTMLElement | null | undefined;
export function useTabViewScroll(scrollDistance: number = 150) {
const scrollbarEl = ref<El>(null);
const scrollViewportEl = ref<El>(null);
function setScrollBarEl(el: El) {
scrollbarEl.value = el;
}
function setScrollViewEl(el: El) {
scrollViewportEl.value = el;
}
function getScrollClientWidth() {
if (!scrollbarEl.value || !scrollViewportEl.value) return {};
const scrollbarWidth = scrollbarEl.value.clientWidth;
const scrollViewWidth = scrollViewportEl.value.clientWidth;
return {
scrollbarWidth,
scrollViewWidth,
};
}
function scrollDirection(
direction: 'left' | 'right',
distance: number = scrollDistance,
) {
const { scrollbarWidth, scrollViewWidth } = getScrollClientWidth();
if (!scrollbarWidth || !scrollViewWidth) return;
if (scrollbarWidth > scrollViewWidth) return;
scrollViewportEl.value?.scrollBy({
behavior: 'smooth',
left: direction === 'left' ? -distance : +distance,
});
}
return {
scrollDirection,
setScrollBarEl,
setScrollViewEl,
};
}

View File

@@ -478,7 +478,7 @@ function cloneTab(route: TabDefinition): TabDefinition {
if (!route) { if (!route) {
return route; return route;
} }
const { matched, ...opt } = route; const { matched, meta, ...opt } = route;
return { return {
...opt, ...opt,
matched: (matched matched: (matched
@@ -488,6 +488,10 @@ function cloneTab(route: TabDefinition): TabDefinition {
path: item.path, path: item.path,
})) }))
: undefined) as RouteRecordNormalized[], : undefined) as RouteRecordNormalized[],
meta: {
...meta,
newTabTitle: meta.newTabTitle,
},
}; };
} }

View File

@@ -4,7 +4,7 @@ import { ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useClipboard } from '@vueuse/core'; import { useClipboard } from '@vueuse/core';
import { Button, Input } from 'ant-design-vue'; import { Button, Card, Input } from 'ant-design-vue';
const source = ref('Hello'); const source = ref('Hello');
const { copy, text } = useClipboard({ source }); const { copy, text } = useClipboard({ source });
@@ -12,12 +12,14 @@ const { copy, text } = useClipboard({ source });
<template> <template>
<Page title="剪切板示例"> <Page title="剪切板示例">
<Card title="基本使用">
<p class="mb-3"> <p class="mb-3">
Current copied: <code>{{ text || 'none' }}</code> Current copied: <code>{{ text || 'none' }}</code>
</p> </p>
<Input.Group class="flex"> <div class="flex">
<Input v-model:value="source" placeholder="请输入" /> <Input class="mr-3 flex w-[200px]" />
<Button type="primary" @click="copy(source)"> Copy </Button> <Button type="primary" @click="copy(source)"> Copy </Button>
</Input.Group> </div>
</Card>
</Page> </Page>
</template> </template>

View File

@@ -31,7 +31,7 @@ const { isFullscreen: isDomFullscreen, toggle: toggleDom } =
</div> </div>
</Card> </Card>
<Card class="mt-3" title="Dom Full Screen"> <Card class="mt-5" title="Dom Full Screen">
<Button type="primary" @click="toggleDom"> Enter Dom Full Screen </Button> <Button type="primary" @click="toggleDom"> Enter Dom Full Screen </Button>
</Card> </Card>

754
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff