feat: add dashboard page

This commit is contained in:
vben
2024-06-23 23:18:55 +08:00
parent 199d5506ac
commit c58c0797ba
100 changed files with 1908 additions and 1081 deletions

View File

@@ -34,6 +34,6 @@
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.11.0",
"vue": "^3.4.30"
"vue": "^3.4.31"
}
}

View File

@@ -27,7 +27,7 @@ const defaultPreferences: Preferences = {
styleType: 'normal',
},
footer: {
enable: true,
enable: false,
fixed: true,
},
header: {

View File

@@ -36,7 +36,7 @@ describe('preferences', () => {
});
it('initPreferences should initialize preferences with overrides and namespace', async () => {
const overrides = { theme: { colorPrimary: 'hsl(211 91% 39%)' } };
const overrides = { theme: { colorPrimary: 'hsl(245 82% 67%)' } };
const namespace = 'testNamespace';
await preferenceManager.initPreferences({ namespace, overrides });

View File

@@ -42,7 +42,7 @@
"@vben-core/typings": "workspace:*",
"pinia": "2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vue": "^3.4.30",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
}
}

View File

@@ -1,9 +1,9 @@
import type { TabItem } from '@vben-core/typings';
import type { RouteRecordNormalized, Router } from 'vue-router';
import { toRaw } from 'vue';
import { startProgress, stopProgress } from '@vben-core/toolkit';
import { TabItem } from '@vben-core/typings';
import { acceptHMRUpdate, defineStore } from 'pinia';

View File

@@ -22,6 +22,6 @@
},
"dependencies": {
"@iconify/vue": "^4.1.2",
"vue": "^3.4.30"
"vue": "^3.4.31"
}
}

View File

@@ -4,6 +4,7 @@ import { Icon } from '@iconify/vue';
function createIcon(name: string) {
return defineComponent({
name: `SvgIcon-${name}`,
setup(props, { attrs }) {
return () => h(Icon, { icon: name, ...props, ...attrs });
},

View File

@@ -36,7 +36,7 @@
}
},
"dependencies": {
"@vue/shared": "^3.4.30",
"@vue/shared": "^3.4.31",
"clsx": "2.1.1",
"defu": "^6.1.4",
"nprogress": "^0.2.0",

View File

@@ -0,0 +1,140 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { getElementVisibleHeight } from './dom'; // 假设函数位于 utils.ts 中
describe('getElementVisibleHeight', () => {
// Mocking the getBoundingClientRect method
const mockGetBoundingClientRect = vi.fn();
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
beforeAll(() => {
// Mock getBoundingClientRect method
Element.prototype.getBoundingClientRect = mockGetBoundingClientRect;
});
afterAll(() => {
// Restore original getBoundingClientRect method
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
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,
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(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

@@ -0,0 +1,24 @@
/**
* 获取元素可见高度
* @param element
* @returns
*/
function getElementVisibleHeight(
element?: HTMLElement | null | undefined,
): number {
if (!element) {
return 0;
}
const rect = element.getBoundingClientRect();
const viewHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight,
);
const top = Math.max(rect.top, 0);
const bottom = Math.min(rect.bottom, viewHeight);
return Math.max(0, bottom - top);
}
export { getElementVisibleHeight };

View File

@@ -1,5 +1,6 @@
export * from './cn';
export * from './diff';
export * from './dom';
export * from './hash';
export * from './inference';
export * from './letter';
@@ -7,5 +8,6 @@ export * from './merge';
export * from './namespace';
export * from './nprogress';
export * from './tree';
export * from './unique';
export * from './update-css-variables';
export * from './window';

View File

@@ -97,11 +97,20 @@ function isWindowsOs(): boolean {
return windowsRegex.test(navigator.userAgent);
}
/**
* 检查传入的值是否为数字
* @param value
*/
function isNumber(value: any): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
export {
isEmpty,
isFunction,
isHttpUrl,
isMacOs,
isNumber,
isObject,
isString,
isUndefined,

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { uniqueByField } from './unique';
describe('uniqueByField', () => {
it('should return an array with unique items based on id field', () => {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toHaveLength(3); // After deduplication, there should be three objects left
expect(uniqueItems).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
]);
});
it('should return an empty array when input array is empty', () => {
const items: any[] = []; // Empty array
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toEqual([]);
});
it('should handle arrays with only one item correctly', () => {
const items = [{ id: 1, name: 'Item 1' }];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toHaveLength(1);
expect(uniqueItems).toEqual([{ id: 1, name: 'Item 1' }]);
});
it('should preserve the order of the first occurrence of each item', () => {
const items = [
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results (order of first occurrences preserved)
expect(uniqueItems).toEqual([
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
]);
});
});

View File

@@ -0,0 +1,15 @@
/**
* 根据指定字段对对象数组进行去重
* @param arr 要去重的对象数组
* @param key 去重依据的字段名
* @returns 去重后的对象数组
*/
function uniqueByField<T>(arr: T[], key: keyof T): T[] {
const seen = new Map<any, T>();
return arr.filter((item) => {
const value = item[key];
return seen.has(value) ? false : (seen.set(value, item), true);
});
}
export { uniqueByField };

View File

@@ -39,7 +39,7 @@
}
},
"dependencies": {
"vue": "^3.4.30",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
}
}

View File

@@ -39,8 +39,9 @@
"dependencies": {
"@vben-core/iconify": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.11.0",
"vue": "^3.4.30"
"vue": "^3.4.31"
}
}

View File

@@ -4,6 +4,8 @@ import type { ContentCompactType } from '@vben-core/typings';
import type { CSSProperties } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { getElementVisibleHeight } from '@vben-core/toolkit';
import { useCssVar, useDebounceFn, useWindowSize } from '@vueuse/core';
interface Props {
@@ -54,12 +56,12 @@ const props = withDefaults(defineProps<Props>(), {
paddingTop: 16,
});
const domElement = ref<HTMLDivElement | null>();
const contentElement = ref<HTMLDivElement | null>();
const { height, width } = useWindowSize();
const contentClientHeight = useCssVar('--vben-content-client-height');
const debouncedCalcHeight = useDebounceFn(() => {
contentClientHeight.value = `${domElement.value?.clientHeight ?? window.innerHeight}px`;
contentClientHeight.value = `${getElementVisibleHeight(contentElement.value)}px`;
}, 200);
const style = computed((): CSSProperties => {
@@ -97,7 +99,7 @@ onMounted(() => {
</script>
<template>
<main ref="domElement" :style="style">
<main ref="contentElement" :style="style">
<slot></slot>
</main>
</template>

View File

@@ -43,6 +43,6 @@
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.11.0",
"vue": "^3.4.30"
"vue": "^3.4.31"
}
}

View File

@@ -50,7 +50,7 @@
"@vueuse/core": "^10.11.0",
"class-variance-authority": "^0.7.0",
"radix-vue": "^1.8.5",
"vue": "^3.4.30",
"vue": "^3.4.31",
"vue-sonner": "^1.1.3"
}
}

View File

@@ -13,7 +13,7 @@ interface Props extends BacktopProps {}
defineOptions({ name: 'BackTop' });
const props = withDefaults(defineProps<Props>(), {
bottom: 40,
bottom: 24,
isGroup: false,
right: 40,
target: '',
@@ -32,7 +32,7 @@ const { handleClick, visible } = useBackTop(props);
<VbenButton
v-if="visible"
:style="backTopStyle"
class="bg-accent data fixed bottom-10 right-5 h-10 w-10 rounded-full"
class="bg-accent hover:bg-heavy data fixed bottom-10 right-5 z-10 h-10 w-10 rounded-full"
size="icon"
variant="icon"
@click="handleClick"

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { computed, onMounted, ref, unref, watch, watchEffect } from 'vue';
import { isNumber } from '@vben-core/toolkit';
import { TransitionPresets, useTransition } from '@vueuse/core';
interface Props {
autoplay?: boolean;
color?: string;
decimal?: string;
decimals?: number;
duration?: number;
endVal?: number;
prefix?: string;
separator?: string;
startVal?: number;
suffix?: string;
transition?: keyof typeof TransitionPresets;
useEasing?: boolean;
}
defineOptions({ name: 'CountToAnimator' });
const props = withDefaults(defineProps<Props>(), {
autoplay: true,
color: '',
decimal: '.',
decimals: 0,
duration: 1500,
endVal: 2021,
prefix: '',
separator: ',',
startVal: 0,
suffix: '',
transition: 'linear',
useEasing: true,
});
const emit = defineEmits(['onStarted', 'onFinished']);
const source = ref(props.startVal);
const disabled = ref(false);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(unref(outputValue)));
watchEffect(() => {
source.value = props.startVal;
});
watch([() => props.startVal, () => props.endVal], () => {
if (props.autoplay) {
start();
}
});
onMounted(() => {
props.autoplay && start();
});
function start() {
run();
source.value = props.endVal;
}
function reset() {
source.value = props.startVal;
run();
}
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => emit('onFinished'),
onStarted: () => emit('onStarted'),
...(props.useEasing
? { transition: TransitionPresets[props.transition] }
: {}),
});
}
function formatNumber(num: number | string) {
if (!num && num !== 0) {
return '';
}
const { decimal, decimals, prefix, separator, suffix } = props;
num = Number(num).toFixed(decimals);
num += '';
const x = num.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator && !isNumber(separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, `$1${separator}$2`);
}
}
return prefix + x1 + x2 + suffix;
}
defineExpose({ reset });
</script>
<template>
<span :style="{ color }">
{{ value }}
</span>
</template>

View File

@@ -0,0 +1 @@
export { default as VbenCountToAnimator } from './count-to-animator.vue';

View File

@@ -6,6 +6,7 @@ export * from './breadcrumb';
export * from './button';
export * from './checkbox';
export * from './context-menu';
export * from './count-to-animator';
export * from './dropdown-menu';
export * from './floating-button-group';
export * from './full-screen';

View File

@@ -42,6 +42,6 @@
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"vue": "^3.4.30"
"vue": "^3.4.31"
}
}