feat: implement new layout components and routing structure

- Added HeaderLayout component for the application header.
- Introduced KeepAliveViewport for managing tab states and rendering.
- Created TabNav for tab navigation with animated indicator.
- Removed old Tabs component in favor of new layout structure.
- Updated routing with AppRouter and defined appTabs for navigation.
- Enhanced theme context to manage dark mode styles.
- Added new UI components: Badge, Button, Separator, and Tabs.
- Refactored pages to utilize new layout components and improve responsiveness.
- Updated global styles for better theming and layout consistency.
- Introduced TypeScript path aliases for cleaner imports.
This commit is contained in:
ash66
2026-05-25 16:19:18 +08:00
parent 10a034e294
commit 987cc097da
43 changed files with 5099 additions and 265 deletions

View File

@@ -0,0 +1,125 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { AppTabConfig, TabId } from '../../router/tabs';
import { appTabs } from '../../router/tabs';
interface TabNavProps {
activeTab: AppTabConfig;
}
interface IndicatorStyle {
opacity: number;
transform: string;
width: number;
}
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
export function TabNav({ activeTab }: TabNavProps) {
const navigate = useNavigate();
const trackRef = useRef<HTMLDivElement | null>(null);
const buttonRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
perception: null,
docs: null,
compliance: null,
status: null,
rag: null,
});
const [indicatorStyle, setIndicatorStyle] = useState<IndicatorStyle>({
opacity: 0,
transform: 'translateX(0px)',
width: 0,
});
const [reducedMotion, setReducedMotion] = useState(false);
const handleValueChange = (value: string) => {
const nextTab = appTabs.find((tab) => tab.id === value);
if (nextTab && nextTab.path !== activeTab.path) {
navigate(nextTab.path);
}
};
useEffect(() => {
const mediaQuery = window.matchMedia(reducedMotionQuery);
const updateMotionPreference = () => {
setReducedMotion(mediaQuery.matches);
};
updateMotionPreference();
mediaQuery.addEventListener('change', updateMotionPreference);
return () => {
mediaQuery.removeEventListener('change', updateMotionPreference);
};
}, []);
useLayoutEffect(() => {
const updateIndicator = () => {
const trackNode = trackRef.current;
const activeNode = buttonRefs.current[activeTab.id];
if (!trackNode || !activeNode) {
return;
}
const trackRect = trackNode.getBoundingClientRect();
const activeRect = activeNode.getBoundingClientRect();
setIndicatorStyle({
opacity: 1,
transform: `translateX(${activeRect.left - trackRect.left}px)`,
width: activeRect.width,
});
};
updateIndicator();
window.addEventListener('resize', updateIndicator);
return () => {
window.removeEventListener('resize', updateIndicator);
};
}, [activeTab.id]);
return (
<nav className="flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
<div
ref={trackRef}
className="relative flex h-full min-w-max flex-nowrap items-stretch gap-3 pr-6"
>
<div
aria-hidden="true"
className={[
'pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-primary',
reducedMotion
? 'transition-none'
: 'transition-[transform,width,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]',
].join(' ')}
style={indicatorStyle}
/>
{appTabs.map((tab) => (
<button
key={tab.id}
ref={(node) => {
buttonRefs.current[tab.id] = node;
}}
data-shell-tab="true"
type="button"
onClick={() => handleValueChange(tab.id satisfies TabId)}
aria-current={tab.id === activeTab.id ? 'page' : undefined}
className={[
'inline-flex h-full shrink-0 appearance-none items-center justify-center border-0 border-b-2 border-transparent bg-transparent px-5 pt-1 text-[0.95rem] font-medium tracking-[0.02em] outline-none',
reducedMotion
? 'transition-none'
: 'transition-[color,opacity] duration-200 ease-out',
tab.id === activeTab.id
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
].join(' ')}
>
{tab.label}
</button>
))}
</div>
</nav>
);
}