From bd1ac6bab8090e0f5ce022bc93c051c2cef01233 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Tue, 6 Oct 2026 22:32:36 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design-system.md | 1 + .../(app)/components/back-to-top-button.tsx | 49 +++++++++++++++ .../(app)/components/library-video-dialog.tsx | 22 +++++-- src/app/(app)/components/library-view.tsx | 63 ++++++++++--------- src/constants/motion.ts | 9 +++ src/hooks/use-scrolled-past.ts | 23 +++++++ 6 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 src/app/(app)/components/back-to-top-button.tsx create mode 100644 src/hooks/use-scrolled-past.ts diff --git a/docs/design-system.md b/docs/design-system.md index b2c0d75..01da782 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -74,6 +74,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx - **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。 - **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。手机宽度下资源库工具栏也用这种叠层浮在滚动容器上方(`max-md:grid`,宽屏仍是 flex 纵向排列),收起时只做 transform,滚动容器尺寸不变;列表从工具栏下方开始(虚拟器 `paddingStart` 加上工具栏高度)。 - **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。标题吸在工具栏露出部分的下沿:`top` 读滚动容器上的 `--toolbar-inset`,由工具栏位移的 MotionValue 逐帧换算,与工具栏同帧移动(宽屏为 0)。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 +- **例外四·返回顶部按钮**:资源库右下角的 `BackToTopButton` 用 `absolute` 贴在滚动容器外层的 `relative` 容器右下角(`right-6 bottom-6`),不随内容滚动;滑入起点的水平偏移小于留白,不会撑出容器、不产生额外滚动条。 - 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 - shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 - **浮层层级**:Dialog、Sheet、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。 diff --git a/src/app/(app)/components/back-to-top-button.tsx b/src/app/(app)/components/back-to-top-button.tsx new file mode 100644 index 0000000..ac6c0cb --- /dev/null +++ b/src/app/(app)/components/back-to-top-button.tsx @@ -0,0 +1,49 @@ +// 资源列表右下角的返回顶部按钮 +"use client"; + +import type { RefObject } from "react"; +import { ArrowUpIcon } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { Button } from "@/components/ui/button"; +import { BACK_TO_TOP } from "@/constants/motion"; +import { useScrolledPast } from "@/hooks/use-scrolled-past"; + +/** 绝对定位在最近的 relative 祖先右下角,不占布局、不随内容滚动 */ +export const BackToTopButton = ({ + scrollRef +}: { + scrollRef: RefObject; +}) => { + const visible = useScrolledPast(scrollRef, BACK_TO_TOP.showOffset); + const reduced = useReducedMotion(); + + return ( + + {visible && ( + + + + )} + + ); +}; diff --git a/src/app/(app)/components/library-video-dialog.tsx b/src/app/(app)/components/library-video-dialog.tsx index f0cbd54..bf4409e 100644 --- a/src/app/(app)/components/library-video-dialog.tsx +++ b/src/app/(app)/components/library-video-dialog.tsx @@ -34,12 +34,17 @@ import { buildHlsUrl, buildStreamUrl, toPotPlayerUrl } from "@/utils/media"; import { findLibraryItem, type LibraryFileTarget } from "./library-rows"; import { VideoPlaylist } from "./video-playlist"; +/** 16:9 铺满主列,尺寸由主列宽度决定。手机宽度下贴边、去掉圆角 */ +const PLAYER_CLASS = "aspect-video w-full max-md:rounded-none"; + /** - * 16:9 铺满主列;宽度上限由视口高度换算,矮屏时等比缩小并居中,给下方标题与信息留位置 - * (sm 起还要让出顶栏与上边距)。手机宽度下贴边、去掉圆角 + * 主列(视频 + 标题信息)的宽度上限由视口高度换算:高度 − 顶栏、边距与标题信息的留白,再按 16:9 折算, + * 矮屏时视频等比缩小,标题与视频始终同宽对齐。 + * 宽屏(lg 起)有下限 40rem,再矮就让主列内容滚动而不是继续缩小; + * 宽屏下主列与右侧播放列表作为一组居中,列表紧贴视频,不会被甩到屏幕边缘 */ -const PLAYER_CLASS = - "mx-auto aspect-video w-full max-w-[calc((100dvh-9rem)*16/9)] sm:max-w-[calc((100dvh-14rem)*16/9)] max-md:rounded-none"; +const COLUMN_CLASS = + "mx-auto w-full max-w-[calc((100dvh-9rem)*16/9)] sm:max-w-[calc((100dvh-14rem)*16/9)] lg:mx-0 lg:max-w-[max(40rem,calc((100dvh-14rem)*16/9))]"; /** 播放器较大,打开时才加载 */ const VideoPlayer = dynamic(importVideoPlayer, { @@ -212,9 +217,14 @@ export const LibraryVideoDialog = ({ className="inset-0 flex max-w-none translate-x-0 translate-y-0 rounded-none p-0 ring-0 sm:top-14.25 sm:max-w-none" > {/* 与顶栏、资源列表同样限宽 1920px 居中 */} -
+
{/* 主列:视频固定在顶部,下方是标题信息,窄屏连同播放列表 */} -
+
{ /> {/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */} - - {content ? ( -
- {content} -
- ) : ( - - )} -
+ {/* relative 容器是返回顶部按钮的定位参照,按钮不随滚动容器的内容移动 */} +
+ + {content ? ( +
+ {content} +
+ ) : ( + + )} +
+ +
); }; diff --git a/src/constants/motion.ts b/src/constants/motion.ts index c8c8655..dfdd6ed 100644 --- a/src/constants/motion.ts +++ b/src/constants/motion.ts @@ -158,6 +158,15 @@ export const TOOLBAR_COLLAPSE = { transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] } } as const; +/** 右下角返回顶部按钮:滚过一屏左右出现,淡入并从右侧滑入 */ +export const BACK_TO_TOP = { + /** 滚动距离超过这个值(px)才显示 */ + showOffset: 480, + /** 滑入起点相对终点的水平偏移(px),小于按钮到容器右缘的留白,不会撑出容器 */ + offsetX: 20, + transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } +} as const; + /** 手机顶栏菜单按钮上的筛选提示点:出现时弹出,消失时收回 */ export const INDICATOR_DOT = { transition: { type: "spring", stiffness: 640, damping: 22 } diff --git a/src/hooks/use-scrolled-past.ts b/src/hooks/use-scrolled-past.ts new file mode 100644 index 0000000..ceef34a --- /dev/null +++ b/src/hooks/use-scrolled-past.ts @@ -0,0 +1,23 @@ +// 容器滚动超过指定距离后返回 true 的 hook +import { useEffect, useState, type RefObject } from "react"; + +/** 容器的 scrollTop 超过 offset(px)时返回 true;只在越过边界时触发渲染 */ +export const useScrolledPast = ( + containerRef: RefObject, + offset: number +) => { + const [past, setPast] = useState(false); + + useEffect(() => { + const node = containerRef.current; + if (!node) return; + + const update = () => setPast(node.scrollTop > offset); + + update(); + node.addEventListener("scroll", update, { passive: true }); + return () => node.removeEventListener("scroll", update); + }, [containerRef, offset]); + + return past; +};