- SubTabContainer: 新增 isActive 和 activationKey props 传递给子组件 - SubTabContainer: 修复 Tab 切换时页面滚动位置跳转问题 - TabPanelContainer: 新增 skeleton prop 支持自定义骨架屏 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
/**
|
||
* SubTabContainer - 二级导航容器组件
|
||
*
|
||
* 深空 FUI 设计风格(Glassmorphism + Ash Thorp + James Turrell)
|
||
* - 玻璃态导航栏,漂浮感
|
||
* - 选中态发光效果,科幻数据终端感
|
||
* - 流畅的过渡动画
|
||
*
|
||
* @example
|
||
* ```tsx
|
||
* <SubTabContainer
|
||
* tabs={[
|
||
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
|
||
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
|
||
* ]}
|
||
* componentProps={{ stockCode: '000001' }}
|
||
* onTabChange={(index, key) => console.log('切换到', key)}
|
||
* />
|
||
* ```
|
||
*/
|
||
|
||
import React, { useState, useCallback, memo, Suspense } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Tabs,
|
||
TabList,
|
||
TabPanels,
|
||
Tab,
|
||
TabPanel,
|
||
Icon,
|
||
HStack,
|
||
Text,
|
||
Center,
|
||
Spinner,
|
||
} from '@chakra-ui/react';
|
||
import type { ComponentType } from 'react';
|
||
import type { IconType } from 'react-icons';
|
||
|
||
/**
|
||
* Tab 配置项
|
||
*/
|
||
export interface SubTabConfig {
|
||
key: string;
|
||
name: string;
|
||
icon?: IconType | ComponentType;
|
||
component?: ComponentType<any>;
|
||
/** 自定义 Suspense fallback(如骨架屏) */
|
||
fallback?: React.ReactNode;
|
||
}
|
||
|
||
/**
|
||
* 深空 FUI 主题配置
|
||
*/
|
||
const DEEP_SPACE = {
|
||
// 背景
|
||
bgGlass: 'rgba(12, 14, 28, 0.6)',
|
||
bgGlassHover: 'rgba(18, 22, 42, 0.7)',
|
||
|
||
// 边框
|
||
borderGold: 'rgba(212, 175, 55, 0.2)',
|
||
borderGoldHover: 'rgba(212, 175, 55, 0.5)',
|
||
borderGlass: 'rgba(255, 255, 255, 0.06)',
|
||
|
||
// 发光
|
||
glowGold: '0 0 30px rgba(212, 175, 55, 0.25), 0 4px 20px rgba(0, 0, 0, 0.3)',
|
||
innerGlow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
|
||
|
||
// 文字
|
||
textWhite: 'rgba(255, 255, 255, 0.95)',
|
||
textMuted: 'rgba(255, 255, 255, 0.6)',
|
||
textGold: '#F4D03F',
|
||
textDark: '#0A0A14',
|
||
|
||
// 选中态
|
||
selectedBg: 'linear-gradient(135deg, rgba(212, 175, 55, 0.95) 0%, rgba(184, 150, 12, 0.95) 100%)',
|
||
|
||
// 圆角
|
||
radius: '12px',
|
||
radiusLG: '16px',
|
||
|
||
// 动画
|
||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||
};
|
||
|
||
/**
|
||
* 主题配置
|
||
*/
|
||
export interface SubTabTheme {
|
||
bg: string;
|
||
borderColor: string;
|
||
tabSelectedBg: string;
|
||
tabSelectedColor: string;
|
||
tabUnselectedColor: string;
|
||
tabHoverBg: string;
|
||
}
|
||
|
||
/**
|
||
* 尺寸配置
|
||
*/
|
||
const SIZE_CONFIG = {
|
||
sm: { fontSize: '13px', px: 4, py: 2, gap: 1.5, iconSize: 3.5 },
|
||
md: { fontSize: '15px', px: 6, py: 3, gap: 2, iconSize: 4 },
|
||
} as const;
|
||
|
||
export type SubTabSize = keyof typeof SIZE_CONFIG;
|
||
|
||
/**
|
||
* 预设主题 - 深空 FUI 风格
|
||
*/
|
||
const THEME_PRESETS: Record<string, SubTabTheme> = {
|
||
blackGold: {
|
||
bg: DEEP_SPACE.bgGlass,
|
||
borderColor: DEEP_SPACE.borderGold,
|
||
tabSelectedBg: DEEP_SPACE.selectedBg,
|
||
tabSelectedColor: DEEP_SPACE.textDark,
|
||
tabUnselectedColor: DEEP_SPACE.textWhite,
|
||
tabHoverBg: DEEP_SPACE.bgGlassHover,
|
||
},
|
||
default: {
|
||
bg: 'white',
|
||
borderColor: 'gray.200',
|
||
tabSelectedBg: 'blue.500',
|
||
tabSelectedColor: 'white',
|
||
tabUnselectedColor: 'gray.600',
|
||
tabHoverBg: 'gray.100',
|
||
},
|
||
};
|
||
|
||
export interface SubTabContainerProps {
|
||
/** Tab 配置数组 */
|
||
tabs: SubTabConfig[];
|
||
/** 传递给 Tab 内容组件的 props */
|
||
componentProps?: Record<string, any>;
|
||
/** 默认选中的 Tab 索引 */
|
||
defaultIndex?: number;
|
||
/** 受控模式下的当前索引 */
|
||
index?: number;
|
||
/** Tab 变更回调 */
|
||
onTabChange?: (index: number, tabKey: string) => void;
|
||
/** 主题预设 */
|
||
themePreset?: 'blackGold' | 'default';
|
||
/** 自定义主题(优先级高于预设) */
|
||
theme?: Partial<SubTabTheme>;
|
||
/** 内容区内边距 */
|
||
contentPadding?: number;
|
||
/** 是否懒加载 */
|
||
isLazy?: boolean;
|
||
/** TabList 右侧自定义内容 */
|
||
rightElement?: React.ReactNode;
|
||
/** 紧凑模式 - 移除 TabList 的外边距 */
|
||
compact?: boolean;
|
||
/** Tab 尺寸: sm=小号(二级导航), md=正常(一级导航) */
|
||
size?: SubTabSize;
|
||
}
|
||
|
||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||
tabs,
|
||
componentProps = {},
|
||
defaultIndex = 0,
|
||
index: controlledIndex,
|
||
onTabChange,
|
||
themePreset = 'blackGold',
|
||
theme: customTheme,
|
||
contentPadding = 4,
|
||
isLazy = true,
|
||
rightElement,
|
||
compact = false,
|
||
size = 'md',
|
||
}) => {
|
||
// 获取尺寸配置
|
||
const sizeConfig = SIZE_CONFIG[size];
|
||
// 内部状态(非受控模式)
|
||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||
|
||
// 当前索引
|
||
const currentIndex = controlledIndex ?? internalIndex;
|
||
|
||
// 记录已访问的 Tab 索引(用于真正的懒加载)
|
||
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
|
||
() => new Set([controlledIndex ?? defaultIndex])
|
||
);
|
||
|
||
// 记录每个 Tab 的激活次数(用于支持特定 Tab 切换时重新请求)
|
||
const [activationCounts, setActivationCounts] = useState<Record<number, number>>(
|
||
() => ({ [controlledIndex ?? defaultIndex]: 1 })
|
||
);
|
||
|
||
// 合并主题
|
||
const theme: SubTabTheme = {
|
||
...THEME_PRESETS[themePreset],
|
||
...customTheme,
|
||
};
|
||
|
||
/**
|
||
* 处理 Tab 切换
|
||
*/
|
||
const handleTabChange = useCallback(
|
||
(newIndex: number) => {
|
||
// 保存当前滚动位置,防止 Tab 切换时页面跳转
|
||
const scrollY = window.scrollY;
|
||
|
||
const tabKey = tabs[newIndex]?.key || '';
|
||
onTabChange?.(newIndex, tabKey);
|
||
|
||
// 记录已访问的 Tab(用于懒加载)
|
||
setVisitedTabs(prev => {
|
||
if (prev.has(newIndex)) return prev;
|
||
return new Set(prev).add(newIndex);
|
||
});
|
||
|
||
// 更新激活计数(用于触发特定 Tab 的数据刷新)
|
||
setActivationCounts(prev => ({
|
||
...prev,
|
||
[newIndex]: (prev[newIndex] || 0) + 1,
|
||
}));
|
||
|
||
if (controlledIndex === undefined) {
|
||
setInternalIndex(newIndex);
|
||
}
|
||
|
||
// 恢复滚动位置,阻止浏览器自动滚动
|
||
requestAnimationFrame(() => {
|
||
window.scrollTo(0, scrollY);
|
||
});
|
||
},
|
||
[tabs, onTabChange, controlledIndex]
|
||
);
|
||
|
||
return (
|
||
<Box>
|
||
<Tabs
|
||
isLazy={isLazy}
|
||
lazyBehavior="keepMounted"
|
||
variant="unstyled"
|
||
index={currentIndex}
|
||
onChange={handleTabChange}
|
||
>
|
||
{/* 导航栏容器:左侧 Tab 可滚动,右侧元素固定 */}
|
||
<Flex
|
||
bg={theme.bg}
|
||
backdropFilter="blur(20px)"
|
||
borderBottom="1px solid"
|
||
borderColor={theme.borderColor}
|
||
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
|
||
mx={compact ? 0 : 2}
|
||
mb={compact ? 0 : 2}
|
||
position="relative"
|
||
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
|
||
alignItems="center"
|
||
>
|
||
{/* 顶部金色光条 */}
|
||
<Box
|
||
position="absolute"
|
||
top={0}
|
||
left="50%"
|
||
transform="translateX(-50%)"
|
||
width="50%"
|
||
height="1px"
|
||
background={`linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.4), transparent)`}
|
||
/>
|
||
|
||
{/* 左侧:可滚动的 Tab 区域 */}
|
||
<Box
|
||
flex="1"
|
||
minW={0}
|
||
overflowX="auto"
|
||
css={{
|
||
'&::-webkit-scrollbar': { display: 'none' },
|
||
scrollbarWidth: 'none',
|
||
}}
|
||
>
|
||
<TabList
|
||
border="none"
|
||
px={3}
|
||
py={compact ? 2 : sizeConfig.py}
|
||
flexWrap="nowrap"
|
||
gap={sizeConfig.gap}
|
||
>
|
||
{tabs.map((tab, idx) => {
|
||
const isSelected = idx === currentIndex;
|
||
|
||
return (
|
||
<Tab
|
||
key={tab.key}
|
||
color={theme.tabUnselectedColor}
|
||
borderRadius={DEEP_SPACE.radius}
|
||
px={sizeConfig.px}
|
||
py={sizeConfig.py}
|
||
fontSize={sizeConfig.fontSize}
|
||
fontWeight="500"
|
||
whiteSpace="nowrap"
|
||
flexShrink={0}
|
||
border="1px solid transparent"
|
||
position="relative"
|
||
letterSpacing="0.03em"
|
||
transition={DEEP_SPACE.transition}
|
||
_before={{
|
||
content: '""',
|
||
position: 'absolute',
|
||
bottom: '-1px',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
width: isSelected ? '70%' : '0%',
|
||
height: '2px',
|
||
bg: '#D4AF37',
|
||
borderRadius: 'full',
|
||
transition: 'width 0.3s ease',
|
||
boxShadow: isSelected ? '0 0 10px rgba(212, 175, 55, 0.5)' : 'none',
|
||
}}
|
||
_selected={{
|
||
bg: theme.tabSelectedBg,
|
||
color: theme.tabSelectedColor,
|
||
fontWeight: '700',
|
||
boxShadow: DEEP_SPACE.glowGold,
|
||
border: `1px solid ${DEEP_SPACE.borderGoldHover}`,
|
||
transform: 'translateY(-2px)',
|
||
}}
|
||
_hover={{
|
||
bg: isSelected ? undefined : theme.tabHoverBg,
|
||
border: isSelected ? undefined : `1px solid ${DEEP_SPACE.borderGold}`,
|
||
transform: 'translateY(-1px)',
|
||
}}
|
||
_active={{
|
||
transform: 'translateY(0)',
|
||
}}
|
||
>
|
||
<HStack spacing={size === 'sm' ? 1.5 : 2}>
|
||
{tab.icon && (
|
||
<Icon
|
||
as={tab.icon}
|
||
boxSize={sizeConfig.iconSize}
|
||
opacity={isSelected ? 1 : 0.7}
|
||
transition="opacity 0.2s"
|
||
/>
|
||
)}
|
||
<Text>{tab.name}</Text>
|
||
</HStack>
|
||
</Tab>
|
||
);
|
||
})}
|
||
</TabList>
|
||
</Box>
|
||
|
||
{/* 右侧:固定的自定义元素(如期数选择器) */}
|
||
{rightElement && (
|
||
<Box
|
||
flexShrink={0}
|
||
pr={3}
|
||
pl={2}
|
||
py={compact ? 2 : sizeConfig.py}
|
||
borderLeft="1px solid"
|
||
borderColor={DEEP_SPACE.borderGold}
|
||
>
|
||
{rightElement}
|
||
</Box>
|
||
)}
|
||
</Flex>
|
||
|
||
<TabPanels p={contentPadding}>
|
||
{tabs.map((tab, idx) => {
|
||
const Component = tab.component;
|
||
// 懒加载:只渲染已访问过的 Tab
|
||
const shouldRender = !isLazy || visitedTabs.has(idx);
|
||
// 判断是否为当前激活的 Tab(用于控制数据加载)
|
||
const isActive = idx === currentIndex;
|
||
|
||
return (
|
||
<TabPanel key={tab.key} p={0}>
|
||
{shouldRender && Component ? (
|
||
<Suspense
|
||
fallback={
|
||
tab.fallback || (
|
||
<Center py={20}>
|
||
<Spinner
|
||
size="lg"
|
||
color={DEEP_SPACE.textGold}
|
||
thickness="3px"
|
||
speed="0.8s"
|
||
/>
|
||
</Center>
|
||
)
|
||
}
|
||
>
|
||
<Component
|
||
{...componentProps}
|
||
isActive={isActive}
|
||
activationKey={activationCounts[idx] || 0}
|
||
/>
|
||
</Suspense>
|
||
) : null}
|
||
</TabPanel>
|
||
);
|
||
})}
|
||
</TabPanels>
|
||
</Tabs>
|
||
</Box>
|
||
);
|
||
});
|
||
|
||
SubTabContainer.displayName = 'SubTabContainer';
|
||
|
||
export default SubTabContainer;
|