add PageTransition
This commit is contained in:
@@ -36,6 +36,13 @@ export interface ComponentMeta {
|
||||
|
||||
export const COMPONENT_LIST: ComponentMeta[] = [
|
||||
// ---------- 布局组件 ----------
|
||||
{
|
||||
name: "PageTransition",
|
||||
title: "PageTransition 页面过渡",
|
||||
description: "为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。",
|
||||
category: "布局组件",
|
||||
entry: "src/components/page-transition/index.vue"
|
||||
},
|
||||
{
|
||||
name: "RootLayout",
|
||||
title: "RootLayout 根布局",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# 组件总览
|
||||
|
||||
共 15 个组件,分为布局、编辑器、交互与 AI 对话四类。
|
||||
共 16 个组件,分为布局、编辑器、交互与 AI 对话四类。
|
||||
|
||||
## 布局组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
| --- | --- |
|
||||
| [PageTransition](/components/page-transition) | 为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。 |
|
||||
| [RootLayout](/components/root-layout) | 移动端页面骨架,主体内容之外自动渲染版权与备案信息。 |
|
||||
| [Space](/components/space) | 控制子元素之间的间距与排列方向,支持分隔符。 |
|
||||
|
||||
|
||||
@@ -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" />
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 导出所有组件 */
|
||||
import RootLayout from "./root-layout";
|
||||
import PageTransition from "./page-transition";
|
||||
import DurationPicker from "./duration-picker";
|
||||
import CodeEditor from "./code-editor";
|
||||
import TiptapEditor from "./tiptap-editor";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
|
||||
export default [
|
||||
RootLayout,
|
||||
PageTransition,
|
||||
DurationPicker,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
@@ -37,6 +39,7 @@ export default [
|
||||
|
||||
export {
|
||||
RootLayout,
|
||||
PageTransition,
|
||||
DurationPicker,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
@@ -88,6 +91,17 @@ export type {
|
||||
SpaceSlots
|
||||
} from "./space";
|
||||
|
||||
export type {
|
||||
PageTransitionBackground,
|
||||
PageTransitionClass,
|
||||
PageTransitionKeepAliveMatcher,
|
||||
PageTransitionNameResolver,
|
||||
PageTransitionProps,
|
||||
PageTransitionRoute,
|
||||
PageTransitionRouteDepthResolver,
|
||||
PageTransitionRouteKeyResolver
|
||||
} from "./page-transition";
|
||||
|
||||
export type {
|
||||
CodeEditorExpose
|
||||
} from "./code-editor";
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user