feat: And surface switching loading optimization

pull/48/MERGE
vben 2024-06-23 19:39:44 +08:00
parent 6afed34437
commit 24aab5b4bb
24 changed files with 596 additions and 1696 deletions

View File

@ -22,17 +22,21 @@ function setupCommonGuard(router: Router) {
const loadedPaths = new Set<string>();
router.beforeEach(async (to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
if (preferences.transition.progress) {
if (!to.meta.loaded && preferences.transition.progress) {
startProgress();
}
to.meta.loaded = loadedPaths.has(to.path);
return true;
});
router.afterEach((to) => {
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
loadedPaths.add(to.path);
if (preferences.tabbar.enable) {
loadedPaths.add(to.path);
}
// 关闭页面加载进度条
if (preferences.transition.progress) {

View File

@ -3,7 +3,7 @@
// import { echartsInstance as echarts } from '@vben/chart-ui';
defineOptions({ name: 'WelCome' });
defineOptions({ name: 'Welcome' });
// const cardList = ref([
// {
@ -247,5 +247,4 @@ defineOptions({ name: 'WelCome' });
<template>
<div>dashboard</div>
<!-- <Dashboard :card-list="cardList" :chart-tabs="chartTabs" /> -->
</template>

View File

@ -3,7 +3,9 @@
"language": "en,en-US",
"allowCompoundWords": true,
"words": [
"clsx",
"esno",
"taze",
"acmr",
"antd",
"brotli",

View File

@ -38,7 +38,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-i": "^2.29.1",
"eslint-plugin-jsdoc": "^48.4.0",
"eslint-plugin-jsdoc": "^48.5.0",
"eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-n": "^17.9.0",
"eslint-plugin-no-only-tests": "^3.1.0",

View File

@ -105,11 +105,11 @@ export default {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
destructive: {
...createColorsPattern('destructive'),
DEFAULT: 'hsl(var(--destructive))',
},
foreground: 'hsl(var(--foreground))',
green: {
...createColorsPattern('green'),
@ -146,7 +146,6 @@ export default {
desc: 'hsl(var(--secondary-desc))',
foreground: 'hsl(var(--secondary-foreground))',
},
success: {
...createColorsPattern('success'),
DEFAULT: 'hsl(var(--success))',

View File

@ -46,7 +46,7 @@
"rollup": "^4.18.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.6",
"unplugin-turbo-console": "^1.8.7",
"unplugin-turbo-console": "^1.8.8-beta.1",
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-dts": "^3.9.1",

View File

@ -34,7 +34,7 @@
.title {
margin-top: 66px;
font-size: 30px;
font-size: 28px;
font-weight: 600;
color: rgb(0 0 0 / 85%);
}
@ -56,7 +56,7 @@
width: 48px;
height: 5px;
content: '';
background: #0065cc50;
background: hsl(var(--primary) / 50%);
border-radius: 50%;
animation: shadow-ani 0.5s linear infinite;
}
@ -68,7 +68,7 @@
width: 100%;
height: 100%;
content: '';
background: #0065cc;
background: hsl(var(--primary));
border-radius: 4px;
animation: jump-ani 0.5s linear infinite;
}

View File

@ -68,7 +68,7 @@
"is-ci": "^3.0.1",
"jsdom": "^24.1.0",
"rimraf": "^5.0.7",
"taze": "^0.13.8",
"taze": "^0.13.9",
"turbo": "^2.0.5",
"typescript": "^5.5.2",
"unbuild": "^2.0.0",

View File

@ -76,6 +76,7 @@ const defaultPreferences: Preferences = {
},
transition: {
enable: true,
loading: true,
name: 'fade-slide',
progress: true,
},

View File

@ -341,9 +341,9 @@ class PreferenceManager {
// 保存重置后的偏好设置
this.savePreferences(this.state);
// 从存储中移除偏好设置项
this.cache?.removeItem(STORAGE_KEY);
this.cache?.removeItem(STORAGE_KEY_THEME);
this.cache?.removeItem(STORAGE_KEY_LOCALE);
[STORAGE_KEY, STORAGE_KEY_THEME, STORAGE_KEY_LOCALE].forEach((key) => {
this.cache?.removeItem(key);
});
}
/**

View File

@ -150,6 +150,8 @@ interface ThemePreferences {
interface TransitionPreferences {
/** 页面切换动画是否启用 */
enable: boolean;
// /** 是否开启页面加载loading */
loading: boolean;
/** 页面切换动画 */
name: PageTransitionType | string;
/** 是否开启页面加载进度动画 */

View File

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

View File

@ -0,0 +1,118 @@
type StorageType = 'localStorage' | 'sessionStorage';
interface StorageManagerOptions {
prefix?: string;
storageType?: StorageType;
}
interface StorageItem<T> {
expiry?: number;
value: T;
}
class StorageManager {
private prefix: string;
private storage: Storage;
constructor({
prefix = '',
storageType = 'localStorage',
}: StorageManagerOptions = {}) {
this.prefix = prefix;
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
/**
*
* @param key
* @returns
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
}
/**
*
*/
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
}
/**
*
*/
clearExpiredItems(): void {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const shortKey = key.replace(this.prefix, '');
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
}
}
}
/**
*
* @param key
* @param defaultValue
* @returns
*/
getItem<T>(key: string, defaultValue: T | null = null): T | null {
const fullKey = this.getFullKey(key);
const itemStr = this.storage.getItem(fullKey);
if (!itemStr) {
return defaultValue;
}
try {
const item: StorageItem<T> = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(fullKey);
return defaultValue;
}
return item.value;
} catch (error) {
console.error(`Error parsing item with key "${fullKey}":`, error);
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
return defaultValue;
}
}
/**
*
* @param key
*/
removeItem(key: string): void {
const fullKey = this.getFullKey(key);
this.storage.removeItem(fullKey);
}
/**
*
* @param key
* @param value
* @param ttl
*/
setItem<T>(key: string, value: T, ttl?: number): void {
const fullKey = this.getFullKey(key);
const expiry = ttl ? Date.now() + ttl : undefined;
const item: StorageItem<T> = { expiry, value };
try {
this.storage.setItem(fullKey, JSON.stringify(item));
} catch (error) {
console.error(`Error setting item with key "${fullKey}":`, error);
}
}
}
export { StorageManager };

View File

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

View File

@ -2,7 +2,9 @@
import type { ContentCompactType } from '@vben-core/typings';
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useCssVar, useDebounceFn, useWindowSize } from '@vueuse/core';
interface Props {
/**
@ -52,6 +54,14 @@ const props = withDefaults(defineProps<Props>(), {
paddingTop: 16,
});
const domElement = 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`;
}, 200);
const style = computed((): CSSProperties => {
const {
contentCompact,
@ -76,10 +86,18 @@ const style = computed((): CSSProperties => {
paddingTop: `${paddingTop}px`,
};
});
watch([height, width], () => {
debouncedCalcHeight();
});
onMounted(() => {
debouncedCalcHeight();
});
</script>
<template>
<main :style="style">
<main ref="domElement" :style="style">
<slot></slot>
</main>
</template>

View File

@ -20,7 +20,7 @@ defineOptions({
const props = withDefaults(defineProps<Props>(), {
minLoadingTime: 50,
});
const startTime = ref(0);
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(true);
const timer = ref<ReturnType<typeof setTimeout>>();
@ -33,11 +33,12 @@ watch(
clearTimeout(timer.value);
return;
}
startTime.value = performance.now();
timer.value = setTimeout(() => {
const loadingTime = performance.now() - startTime.value;
showSpinner.value = loadingTime > props.minLoadingTime;
// startTime.value = performance.now();
timer.value = setTimeout(() => {
// const loadingTime = performance.now() - startTime.value;
showSpinner.value = true;
if (showSpinner.value) {
renderSpinner.value = true;
}
@ -49,13 +50,14 @@ watch(
);
function onTransitionEnd() {
renderSpinner.value = false;
if (!showSpinner.value) {
renderSpinner.value = false;
}
}
</script>
<template>
<div
v-if="renderSpinner"
:class="{
'invisible opacity-0': !showSpinner,
}"

View File

@ -2,15 +2,18 @@
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { preferences, usePreferences } from '@vben-core/preferences';
import { Spinner } from '@vben-core/shadcn-ui';
import { storeToRefs, useTabsStore } from '@vben-core/stores';
import { IFrameRouterView } from '../../iframe';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContent' });
const { keepAlive } = usePreferences();
const tabsStore = useTabsStore();
const { onTransitionEnd, spinning } = useContentSpinner();
const { getCacheTabs, getExcludeTabs, renderRouteView } =
storeToRefs(tabsStore);
@ -29,32 +32,45 @@ function getTransitionName(route: RouteLocationNormalizedLoaded) {
}
// 使
if (route.meta.loaded) {
return;
}
// if (route.meta.loaded) {
// return;
// }
// 使
const inTabs = getCacheTabs.value.includes(route.name as string);
return inTabs && route.meta.loaded ? undefined : transitionName;
}
</script>
<template>
<IFrameRouterView />
<RouterView v-slot="{ Component, route }">
<Transition :name="getTransitionName(route)" appear mode="out-in">
<KeepAlive
v-if="keepAlive"
:exclude="getExcludeTabs"
:include="getCacheTabs"
<div class="relative h-full">
<Spinner
v-if="preferences.transition.loading"
:spinning="spinning"
class="h-[var(--vben-content-client-height)]"
/>
<IFrameRouterView />
<RouterView v-slot="{ Component, route }">
<Transition
:name="getTransitionName(route)"
appear
mode="out-in"
@transitionend="onTransitionEnd"
>
<component
:is="Component"
v-if="renderRouteView"
v-show="!route.meta.iframeSrc"
:key="route.fullPath"
/>
</KeepAlive>
<component :is="Component" v-else :key="route.fullPath" />
</Transition>
</RouterView>
<KeepAlive
v-if="keepAlive"
:exclude="getExcludeTabs"
:include="getCacheTabs"
>
<component
:is="Component"
v-if="renderRouteView"
v-show="!route.meta.iframeSrc"
:key="route.fullPath"
/>
</KeepAlive>
<component :is="Component" v-else :key="route.fullPath" />
</Transition>
</RouterView>
</div>
</template>

View File

@ -0,0 +1,56 @@
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { preferences } from '@vben-core/preferences';
function useContentSpinner() {
const spinning = ref(false);
const isStartTransition = ref(false);
const startTime = ref(0);
const router = useRouter();
const minShowTime = 500;
const enableLoading = computed(() => preferences.transition.loading);
const onEnd = () => {
if (!enableLoading.value) {
return;
}
const processTime = performance.now() - startTime.value;
if (processTime < minShowTime) {
setTimeout(() => {
spinning.value = false;
}, minShowTime - processTime);
} else {
spinning.value = false;
}
};
router.beforeEach((to) => {
if (to.meta.loaded || !enableLoading.value) {
return true;
}
isStartTransition.value = false;
startTime.value = performance.now();
spinning.value = true;
return true;
});
router.afterEach((to) => {
if (to.meta.loaded || !enableLoading.value) {
return true;
}
// 未进入过渡动画
if (!isStartTransition.value) {
// 关闭加载动画
onEnd();
}
isStartTransition.value = false;
return true;
});
return { onTransitionEnd: onEnd, spinning };
}
export { useContentSpinner };

View File

@ -6,13 +6,14 @@ import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceAnimation',
});
const transitionProgress = defineModel<boolean>('transitionProgress', {
//
default: false,
});
const transitionName = defineModel<string>('transitionName');
const transitionEnable = defineModel<boolean>('transitionEnable');
const transitionLoading = defineModel<boolean>('transitionLoading');
const transitionPreset = ['fade', 'fade-slide', 'fade-up', 'fade-down'];
@ -23,10 +24,13 @@ function handleClick(value: string) {
<template>
<SwitchItem v-model="transitionProgress">
{{ $t('preferences.page-progress') }}
{{ $t('preferences.animation.progress') }}
</SwitchItem>
<SwitchItem v-model="transitionLoading">
{{ $t('preferences.animation.loading') }}
</SwitchItem>
<SwitchItem v-model="transitionEnable">
{{ $t('preferences.page-transition') }}
{{ $t('preferences.animation.transition') }}
</SwitchItem>
<div
v-if="transitionEnable"

View File

@ -42,6 +42,7 @@ import Preferences from './preferences.vue';
:theme-mode="preferences.theme.mode"
:theme-radius="preferences.theme.radius"
:transition-enable="preferences.transition.enable"
:transition-loading="preferences.transition.loading"
:transition-name="preferences.transition.name"
:transition-progress="preferences.transition.progress"
@update:app-ai-assistant="
@ -143,6 +144,9 @@ import Preferences from './preferences.vue';
@update:transition-enable="
(val) => updatePreferences({ transition: { enable: val } })
"
@update:transition-loading="
(val) => updatePreferences({ transition: { loading: val } })
"
@update:transition-name="
(val) => updatePreferences({ transition: { name: val } })
"

View File

@ -64,6 +64,7 @@ const appContentCompact = defineModel<ContentCompactType>('appContentCompact');
const transitionProgress = defineModel<boolean>('transitionProgress');
const transitionName = defineModel<string>('transitionName');
const transitionLoading = defineModel<boolean>('transitionLoading');
const transitionEnable = defineModel<boolean>('transitionEnable');
const themeColorPrimary = defineModel<string>('themeColorPrimary');
@ -209,9 +210,10 @@ function handleReset() {
/>
</Block>
<Block :title="$t('preferences.animation')">
<Block :title="$t('preferences.animation.name')">
<Animation
v-model:transition-enable="transitionEnable"
v-model:transition-loading="transitionLoading"
v-model:transition-name="transitionName"
v-model:transition-progress="transitionProgress"
/>

View File

@ -147,7 +147,7 @@ preferences:
full-content-tip: Display only the main content, no menus
weak-mode: Color Weak Mode
gray-mode: Gray Mode
animation: Animation
language: Language
dynamic-title: Dynamic Title
normal: Normal
@ -168,8 +168,7 @@ preferences:
breadcrumb-background: background
breadcrumb-style: Breadcrumb Type
breadcrumb-hide-only-one: Hidden when only one left
page-progress: Loading progress bar
page-transition: Page transition animation
copy: Copy Preferences
copy-success: Copy successful. Please replace in `src/preferences.ts` of the app
reset-success: Preferences reset successfully
@ -180,6 +179,11 @@ preferences:
tabs-icon: Display Tabbar Icon
mode: Mode
logo-visible: Display Logo
animation:
name: Animation
loading: Page transition loading
transition: Page transition animation
progress: Page transition progress
theme:
name: Theme
builtin: Built-in

View File

@ -150,7 +150,7 @@ preferences:
follow-system: 跟随系统
weak-mode: 色弱模式
gray-mode: 灰色模式
animation: 动画
navigation-menu: 导航菜单
navigation-style: 导航菜单风格
navigation-accordion: 侧边导航菜单手风琴模式
@ -167,8 +167,6 @@ preferences:
breadcrumb-style: 面包屑风格
breadcrumb-hide-only-one: 只有一个时隐藏
breadcrumb-background: 背景
page-progress: 加载进度条
page-transition: 页面切换动画
copy: 复制偏好设置
copy-success: 拷贝成功,请在 app 下的 `src/preferences.ts`内进行覆盖
reset-success: 重置偏好设置成功
@ -179,6 +177,11 @@ preferences:
tabs-icon: 显示标签栏图标
mode: 模式
logo-visible: 显示 Logo
animation:
name: 动画
loading: 页面切换 Loading
transition: 页面切换动画
progress: 页面加载进度条
theme:
name: 主题
builtin: 内置主题

File diff suppressed because it is too large Load Diff