MainLayout.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { useState, useEffect, useRef } from 'react';
  2. import { Navbar } from '../components/layout/Navbar';
  3. import type { TabId } from '../components/layout/Navbar';
  4. interface MainLayoutProps {
  5. children: (activeTab: TabId) => React.ReactNode;
  6. }
  7. // 这里的顺序决定了滑动的顺序,必须是 6 个!
  8. const TAB_ORDER: TabId[] = ['dashboard', 'relations', 'requirements', 'capabilities', 'tools', 'knowledge'];
  9. const MIN_SWITCH_INTERVAL = 1000;
  10. export function MainLayout({ children }: MainLayoutProps) {
  11. const [activeTab, setActiveTab] = useState<TabId>('dashboard');
  12. const lastSwitchTime = useRef(0);
  13. const accumX = useRef(0);
  14. const touchStartX = useRef(0);
  15. const wheelTimeout = useRef<NodeJS.Timeout>();
  16. useEffect(() => {
  17. const handleTabSwitch = (direction: 1 | -1) => {
  18. const now = Date.now();
  19. if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
  20. setActiveTab(prev => {
  21. const currentIndex = TAB_ORDER.indexOf(prev);
  22. const nextIndex = currentIndex + direction;
  23. if (nextIndex >= 0 && nextIndex < TAB_ORDER.length) {
  24. lastSwitchTime.current = now;
  25. return TAB_ORDER[nextIndex];
  26. }
  27. return prev;
  28. });
  29. };
  30. const isInsideHorizontallyScrollable = (targetNode: EventTarget | null) => {
  31. let target = targetNode as HTMLElement | null;
  32. while (target && target !== document.body) {
  33. if (target.scrollWidth > target.clientWidth) {
  34. const style = window.getComputedStyle(target);
  35. if (style.overflowX === 'auto' || style.overflowX === 'scroll' || style.overflowX === 'overlay') {
  36. return true;
  37. }
  38. }
  39. target = target.parentElement;
  40. }
  41. return false;
  42. };
  43. const handleWheel = (e: WheelEvent) => {
  44. if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) return;
  45. if (isInsideHorizontallyScrollable(e.target)) return;
  46. if (e.cancelable) e.preventDefault();
  47. const now = Date.now();
  48. if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) {
  49. accumX.current = 0;
  50. return;
  51. }
  52. accumX.current += e.deltaX;
  53. clearTimeout(wheelTimeout.current);
  54. wheelTimeout.current = setTimeout(() => { accumX.current = 0; }, 150);
  55. if (accumX.current > 120) {
  56. handleTabSwitch(1);
  57. accumX.current = 0;
  58. } else if (accumX.current < -120) {
  59. handleTabSwitch(-1);
  60. accumX.current = 0;
  61. }
  62. };
  63. const handleTouchStart = (e: TouchEvent) => {
  64. if (isInsideHorizontallyScrollable(e.target)) return;
  65. touchStartX.current = e.touches[0].clientX;
  66. };
  67. const handleTouchEnd = (e: TouchEvent) => {
  68. if (isInsideHorizontallyScrollable(e.target)) return;
  69. const now = Date.now();
  70. if (now - lastSwitchTime.current < MIN_SWITCH_INTERVAL) return;
  71. const diffX = touchStartX.current - e.changedTouches[0].clientX;
  72. if (diffX > 60) handleTabSwitch(1);
  73. else if (diffX < -60) handleTabSwitch(-1);
  74. };
  75. window.addEventListener('wheel', handleWheel, { passive: false });
  76. window.addEventListener('touchstart', handleTouchStart, { passive: true });
  77. window.addEventListener('touchend', handleTouchEnd, { passive: true });
  78. return () => {
  79. window.removeEventListener('wheel', handleWheel);
  80. window.removeEventListener('touchstart', handleTouchStart);
  81. window.removeEventListener('touchend', handleTouchEnd);
  82. clearTimeout(wheelTimeout.current);
  83. };
  84. }, []);
  85. const currentIndex = TAB_ORDER.indexOf(activeTab);
  86. const totalTabs = TAB_ORDER.length;
  87. return (
  88. <div className="min-h-screen bg-slate-50 flex flex-col overflow-x-hidden" style={{ overscrollBehaviorX: 'none' }}>
  89. <Navbar activeTab={activeTab} onTabChange={setActiveTab} />
  90. <main className="flex-1 w-full overflow-x-hidden relative">
  91. <div
  92. className="flex h-full will-change-transform"
  93. style={{
  94. // 轨道总宽度由标签页数量动态决定,6个页面就是 600%
  95. width: `${totalTabs * 100}%`,
  96. // 偏移量
  97. transform: `translateX(-${(currentIndex / totalTabs) * 100}%)`,
  98. transition: 'transform 0.7s cubic-bezier(0.34, 1.3, 0.64, 1)'
  99. }}
  100. >
  101. {TAB_ORDER.map((tab) => (
  102. <div
  103. key={tab}
  104. className="shrink-0 flex justify-center pb-12"
  105. // 每个页面的宽度是总宽度的 1/6
  106. style={{ width: `${100 / totalTabs}%` }}
  107. >
  108. <div className="w-full max-w-[1600px] px-6 py-6">
  109. {children(tab)}
  110. </div>
  111. </div>
  112. ))}
  113. </div>
  114. </main>
  115. </div>
  116. );
  117. }