add PageTransition

This commit is contained in:
Timi
2026-09-22 21:27:08 +08:00
parent 37cf38fe27
commit 45f77d3168
7 changed files with 470 additions and 1 deletions
+7
View File
@@ -36,6 +36,13 @@ export interface ComponentMeta {
export const COMPONENT_LIST: ComponentMeta[] = [ export const COMPONENT_LIST: ComponentMeta[] = [
// ---------- 布局组件 ---------- // ---------- 布局组件 ----------
{
name: "PageTransition",
title: "PageTransition 页面过渡",
description: "为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。",
category: "布局组件",
entry: "src/components/page-transition/index.vue"
},
{ {
name: "RootLayout", name: "RootLayout",
title: "RootLayout 根布局", title: "RootLayout 根布局",
+2 -1
View File
@@ -1,11 +1,12 @@
# 组件总览 # 组件总览
共 15 个组件,分为布局、编辑器、交互与 AI 对话四类。 共 16 个组件,分为布局、编辑器、交互与 AI 对话四类。
## 布局组件 ## 布局组件
| 组件 | 说明 | | 组件 | 说明 |
| --- | --- | | --- | --- |
| [PageTransition](/components/page-transition) | 为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。 |
| [RootLayout](/components/root-layout) | 移动端页面骨架,主体内容之外自动渲染版权与备案信息。 | | [RootLayout](/components/root-layout) | 移动端页面骨架,主体内容之外自动渲染版权与备案信息。 |
| [Space](/components/space) | 控制子元素之间的间距与排列方向,支持分隔符。 | | [Space](/components/space) | 控制子元素之间的间距与排列方向,支持分隔符。 |
+37
View File
@@ -0,0 +1,37 @@
# PageTransition 页面过渡
为 Vue Router 页面提供可配置的页面过渡和按路由缓存能力。
## 基础用法
`PageTransition` 必须放在 `RouterView` 所在的路由布局中,并且自身需要有明确的宽高。
```vue
<template>
<div class="layout">
<page-transition :keep-alive="route => route.meta.keepAlive === true" />
</div>
</template>
<style scoped lang="less">
.layout {
height: 100vh;
}
</style>
```
组件默认根据路由层级选择 `push-left` 和 `push-right`。层级的解析顺序是:`transitionDepth` query、`depth` meta、动态层级 meta,最后回退到 `matched.length`。业务路由结构特殊时,直接传入 `get-route-depth`,不要让组件猜测业务规则。
## 缓存页面
`keep-alive` 支持布尔值或路由谓词。谓词返回 `true` 的页面会进入缓存,`keep-alive-max` 用于限制缓存数量。默认不缓存页面,避免通用布局意外保留业务表单状态。
## 自定义过渡
`transition` 默认值为 `auto`,也可以传入 `false` 禁用过渡、传入字符串使用自定义过渡名,或传入函数按目标路由和来源路由返回过渡名。组件内置 `push-left`、`push-right` 和 `fade`。
组件默认尊重系统的“减少动态效果”设置;如果业务必须保持页面翻页动画,可以设置 `:respect-reduced-motion="false"`。
## API
<api-table name="PageTransition" />
+14
View File
@@ -1,5 +1,6 @@
/** 导出所有组件 */ /** 导出所有组件 */
import RootLayout from "./root-layout"; import RootLayout from "./root-layout";
import PageTransition from "./page-transition";
import DurationPicker from "./duration-picker"; import DurationPicker from "./duration-picker";
import CodeEditor from "./code-editor"; import CodeEditor from "./code-editor";
import TiptapEditor from "./tiptap-editor"; import TiptapEditor from "./tiptap-editor";
@@ -19,6 +20,7 @@ import {
export default [ export default [
RootLayout, RootLayout,
PageTransition,
DurationPicker, DurationPicker,
CodeEditor, CodeEditor,
TiptapEditor, TiptapEditor,
@@ -37,6 +39,7 @@ export default [
export { export {
RootLayout, RootLayout,
PageTransition,
DurationPicker, DurationPicker,
CodeEditor, CodeEditor,
TiptapEditor, TiptapEditor,
@@ -88,6 +91,17 @@ export type {
SpaceSlots SpaceSlots
} from "./space"; } from "./space";
export type {
PageTransitionBackground,
PageTransitionClass,
PageTransitionKeepAliveMatcher,
PageTransitionNameResolver,
PageTransitionProps,
PageTransitionRoute,
PageTransitionRouteDepthResolver,
PageTransitionRouteKeyResolver
} from "./page-transition";
export type { export type {
CodeEditorExpose CodeEditorExpose
} from "./code-editor"; } from "./code-editor";
+16
View File
@@ -0,0 +1,16 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export type {
PageTransitionBackground,
PageTransitionClass,
PageTransitionKeepAliveMatcher,
PageTransitionNameResolver,
PageTransitionProps,
PageTransitionRoute,
PageTransitionRouteDepthResolver,
PageTransitionRouteKeyResolver
} from "./type";
export const PageTransition = WebToolkit.withInstall(view);
export default PageTransition;
+333
View File
@@ -0,0 +1,333 @@
<template>
<div
class="page-transition"
:class="{ 'page-transition-reduced-motion': props.respectReducedMotion }"
>
<RouterView v-slot="{ Component, route }">
<Transition
:name="transitionName"
:mode="props.transitionMode"
:appear="props.appear"
>
<KeepAlive :include="keepAlivePageNames" :max="props.keepAliveMax">
<component
:is="isKeepAlive(route) ? CachedPage : RoutePage"
:key="resolveRouteKey(route)"
:component="Component"
:background="getPageBackground(route)"
:class="props.pageClass"
:style="props.pageStyle"
/>
</KeepAlive>
</Transition>
</RouterView>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, h, onUnmounted, ref, watch } from "vue";
import type { Component, PropType, StyleValue } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from "vue-router";
import type {
PageTransitionProps,
PageTransitionRoute
} from "./type";
defineOptions({
name: "PageTransition"
});
interface RoutePageProps {
component: Component;
background?: string;
}
const routePageProps = {
component: {
type: null as unknown as PropType<Component>,
required: true
},
background: String
};
function createRoutePage(name: string) {
return defineComponent({
name,
inheritAttrs: false,
props: routePageProps,
setup(pageProps: RoutePageProps, { attrs }) {
return () => h(
"div",
{
...attrs,
class: ["page-transition-page", attrs.class],
style: [attrs.style as StyleValue, pageProps.background === undefined ? undefined : { background: pageProps.background }]
},
[h(pageProps.component)]
);
}
});
}
const RoutePage = createRoutePage("PageTransitionPage");
const CachedPage = createRoutePage("PageTransitionKeepAlivePage");
const keepAlivePageNames = ["PageTransitionKeepAlivePage"];
const props = defineProps({
transition: {
type: [String, Function, Boolean] as PropType<PageTransitionProps["transition"]>,
default: "auto"
},
transitionMode: String as PropType<PageTransitionProps["transitionMode"]>,
appear: Boolean,
respectReducedMotion: {
type: Boolean,
default: true
},
keepAlive: {
type: [Boolean, Function] as PropType<PageTransitionProps["keepAlive"]>,
default: false
},
keepAliveMax: Number,
getRouteDepth: Function as PropType<PageTransitionProps["getRouteDepth"]>,
routeDepthQueryKey: {
type: String,
default: "transitionDepth"
},
routeDepthMetaKey: {
type: String,
default: "depth"
},
routeDepthBaseMetaKey: {
type: String,
default: "baseDepth"
},
routeDepthDynamicMetaKey: {
type: String,
default: "dynamicDepth"
},
getRouteKey: Function as PropType<PageTransitionProps["getRouteKey"]>,
pageBackground: [String, Function] as PropType<PageTransitionProps["pageBackground"]>,
pageClass: [String, Array, Object] as PropType<PageTransitionProps["pageClass"]>,
pageStyle: [String, Array, Object] as PropType<PageTransitionProps["pageStyle"]>
});
const router = useRouter();
const route = useRoute();
const transitionName = ref(resolveTransitionName(route, route));
function resolveTransitionName(
to: PageTransitionRoute,
from: PageTransitionRoute
): string {
const transition = props.transition ?? "auto";
if (transition === false) {
return "";
}
if (typeof transition === "function") {
const name = transition(to, from);
return name === false ? "" : name;
}
if (transition !== "auto") {
return transition;
}
const toDepth = resolveRouteDepth(to);
const fromDepth = resolveRouteDepth(from);
if (toDepth === undefined || fromDepth === undefined || toDepth === fromDepth) {
return "";
}
return fromDepth < toDepth ? "push-left" : "push-right";
}
function resolveRouteDepth(sourceRoute: PageTransitionRoute): number | undefined {
if (props.getRouteDepth) {
return props.getRouteDepth(sourceRoute);
}
const queryDepth = parseDepth(sourceRoute.query[props.routeDepthQueryKey ?? "transitionDepth"]);
if (queryDepth !== undefined) {
return queryDepth;
}
const meta = sourceRoute.meta as Record<string, unknown>;
const depthKey = props.routeDepthMetaKey ?? "depth";
const depth = parseDepth(meta[depthKey]);
const dynamicKey = props.routeDepthDynamicMetaKey ?? "dynamicDepth";
if (meta[dynamicKey] === true) {
const baseDepth = parseDepth(meta[props.routeDepthBaseMetaKey ?? "baseDepth"]) ?? depth ?? 0;
const pathSegments = sourceRoute.path.split("/").filter(Boolean);
return baseDepth + Math.max(0, pathSegments.length - 1);
}
if (depth !== undefined) {
return depth;
}
return sourceRoute.matched.length;
}
function parseDepth(value: unknown): number | undefined {
if (Array.isArray(value)) {
return parseDepth(value[0]);
}
if (typeof value !== "string" && typeof value !== "number") {
return undefined;
}
if (typeof value === "string" && !value.trim()) {
return undefined;
}
const depth = typeof value === "number" ? value : Number(value.trim());
if (!Number.isInteger(depth) || depth < 0) {
return undefined;
}
return depth;
}
function isKeepAlive(sourceRoute: RouteLocationNormalizedLoaded): boolean {
const matcher = props.keepAlive;
if (typeof matcher === "function") {
return matcher(sourceRoute);
}
return matcher === true;
}
function resolveRouteKey(sourceRoute: RouteLocationNormalizedLoaded): PropertyKey {
const resolver = props.getRouteKey;
return resolver ? resolver(sourceRoute) : sourceRoute.path;
}
function getPageBackground(sourceRoute: RouteLocationNormalizedLoaded): string {
const background = props.pageBackground;
if (typeof background === "function") {
return background(sourceRoute) ?? "var(--td-bg-color-page, transparent)";
}
return background ?? "var(--td-bg-color-page, transparent)";
}
const unregisterGuard = router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
transitionName.value = resolveTransitionName(to, from);
});
watch(
() => props.transition,
() => {
transitionName.value = resolveTransitionName(route, route);
}
);
onUnmounted(() => {
unregisterGuard();
});
</script>
<style scoped lang="less">
@easing: cubic-bezier(.19, 1, .22, 1);
@duration: 520ms;
@brightness: .98;
@shadow-intensity: .15;
@page-offset: 25%;
.page-transition {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
overflow-x: clip;
isolation: isolate;
:deep(.page-transition-page) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: auto;
overflow-x: clip;
background: var(--td-bg-color-page, transparent);
}
:deep(.push-left-enter-active),
:deep(.push-left-leave-active),
:deep(.push-right-enter-active),
:deep(.push-right-leave-active) {
width: 100%;
height: 100%;
position: fixed;
transition: transform @duration @easing, opacity .2s linear;
backface-visibility: hidden;
}
:deep(.push-left-enter-from) {
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
transform: translate3d(100%, 0, 0);
}
:deep(.push-left-enter-to) {
transform: translate3d(0, 0, 0);
}
:deep(.push-left-leave-active) {
transition: all @duration @easing, filter .3s;
}
:deep(.push-left-leave-to) {
filter: brightness(@brightness);
transform: translate3d(-@page-offset, 0, 0);
}
:deep(.push-right-enter-active) {
z-index: 0;
transition: transform @duration @easing, opacity .3s;
}
:deep(.push-right-enter-from) {
transform: translate3d(-@page-offset, 0, 0);
}
:deep(.push-right-enter-to) {
transform: translate3d(0, 0, 0);
}
:deep(.push-right-leave-active) {
z-index: 1;
transition: transform @duration @easing, box-shadow .3s;
}
:deep(.push-right-leave-from) {
transform: translate3d(0, 0, 0);
}
:deep(.push-right-leave-to) {
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
transform: translate3d(105%, 0, 0);
}
:deep(.fade-enter-active),
:deep(.fade-leave-active) {
transition: opacity .2s ease;
}
:deep(.fade-enter-from),
:deep(.fade-leave-to) {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.page-transition.page-transition-reduced-motion {
:deep(.push-left-enter-active),
:deep(.push-left-leave-active),
:deep(.push-right-enter-active),
:deep(.push-right-leave-active),
:deep(.fade-enter-active),
:deep(.fade-leave-active) {
transition: none;
}
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
import type { StyleValue } from "vue";
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from "vue-router";
/** PageTransition 可接收的路由对象。 */
export type PageTransitionRoute = RouteLocationNormalized | RouteLocationNormalizedLoaded;
/** 根据路由计算页面层级,用于决定前进或后退动画。 */
export type PageTransitionRouteDepthResolver = (route: PageTransitionRoute) => number | undefined;
/** 根据路由生成页面 key。 */
export type PageTransitionRouteKeyResolver = (route: PageTransitionRoute) => PropertyKey;
/** 决定当前路由是否进入 keep-alive。 */
export type PageTransitionKeepAliveMatcher = boolean | ((route: PageTransitionRoute) => boolean);
/** 自定义过渡名称;返回 false 或传入 false 时不使用过渡。 */
export type PageTransitionNameResolver =
| "auto"
| string
| false
| ((to: PageTransitionRoute, from: PageTransitionRoute) => string | false);
/** 页面容器 class 的类型。 */
export type PageTransitionClass = string | string[] | Record<string, boolean>;
/** 页面容器的背景色,可以按路由动态计算。 */
export type PageTransitionBackground = string | ((route: PageTransitionRoute) => string | undefined);
/** PageTransition 的公开属性。 */
export interface PageTransitionProps {
/** 过渡名称;auto 会根据路由层级使用 push-left / push-right。 */
transition?: PageTransitionNameResolver;
/** Vue Transition 的模式。 */
transitionMode?: "out-in" | "in-out";
/** 是否在首次渲染时播放过渡。 */
appear?: boolean;
/** 是否尊重系统的减少动态效果设置,默认开启。 */
respectReducedMotion?: boolean;
/** 是否缓存页面,也可以按路由决定。默认不缓存。 */
keepAlive?: PageTransitionKeepAliveMatcher;
/** keep-alive 最多保留的页面数量。 */
keepAliveMax?: number;
/** 自定义路由层级解析器。 */
getRouteDepth?: PageTransitionRouteDepthResolver;
/** 路由 query 中的层级字段名,默认是 transitionDepth。 */
routeDepthQueryKey?: string;
/** 路由 meta 中的固定层级字段名,默认是 depth。 */
routeDepthMetaKey?: string;
/** 动态层级的起始层级字段名,默认是 baseDepth。 */
routeDepthBaseMetaKey?: string;
/** 标记动态层级的 meta 字段名,默认是 dynamicDepth。 */
routeDepthDynamicMetaKey?: string;
/** 自定义路由 key,默认使用 path,避免普通 query 变化重建页面。 */
getRouteKey?: PageTransitionRouteKeyResolver;
/** 页面容器的背景色,默认使用 TDesign 页面背景变量。 */
pageBackground?: PageTransitionBackground;
/** 页面容器 class。 */
pageClass?: PageTransitionClass;
/** 页面容器 style。 */
pageStyle?: StyleValue;
}