chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
/**
* 窄屏下的「页面覆盖」容器。
*
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
* 这样做的两个实际好处:
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
* 能让旧页面往右滑出去
*
* 因此这里必须区分「逻辑上是否打开」(open)与「是否还在 DOM 里」(mounted):
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
*/
export default function NarrowStack({
base,
overlay,
open
}: {
base: React.ReactNode;
overlay: React.ReactNode;
open: boolean;
}) {
// mounted:是否在 DOM 里。entered:是否已滑到位(用于触发 transition)
const [mounted, setMounted] = useState(open);
const [entered, setEntered] = useState(open);
const timer = useRef<number | null>(null);
useEffect(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) {
setMounted(true);
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
// translate-x-0 是同一帧内完成的,transition 不会触发。
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
return () => cancelAnimationFrame(raf);
}
setEntered(false);
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
timer.current = window.setTimeout(() => {
setMounted(false);
timer.current = null;
}, 200);
return () => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
};
}, [open]);
return (
<div className="flex-1 min-h-0 relative overflow-hidden">
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
否则屏幕阅读器会读到两层内容。
`isolate`(isolation: isolate)是必需的:它让底层**自成一个层叠上下文**。
不加的后果在日历上实测到过:月视图的星期表头是 `sticky top-0 z-10`,
而覆盖层没有 z-index(= auto = 0)—— 两者在同一个层叠上下文里比,
`z-10` 赢过 `auto`,于是底层的表头穿透到二级页面之上,把日程内容遮住一条。
为何不只给覆盖层加 z-10 就完事:那只能治当下这一处。底层是任意业务组件,
下一个人在里面写个 `z-20` 就又复现,而这类 bug 只能肉眼看见。
isolate 把边界定在容器上,底层写多少 z-index 都出不来。 */}
<div className="absolute inset-0 flex isolate" aria-hidden={open ? 'true' : undefined}>
{base}
</div>
{mounted && (
<div
className={`absolute inset-0 z-10 flex bg-white border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full'
}`}
>
{overlay}
</div>
)}
</div>
);
}