Files
testing-tool/components/TopBar.tsx
T
LingandRX 373fc0496c Develop (#26)
统一简化多个页面里的CopyButton调用,删除不再需要的空回调参数

* feat: 添加clearCookies函数的单元测试并修复cookie域名处理逻辑

* feat: 增强 data URI 处理,支持带参数的前缀并更新相关测试

* fix: 修正 AGENTS.md 中 TypeScript 类型检查命令的描述

* feat: 添加 settings.local.json 文件以配置 Bash 权限

* perf: 预设背景色避免 Popup 弹窗白屏闪烁

* perf: 避免图标过早实例化,传递组件引用而非 JSX 节点

* feat: 添加 useLazyTranslation 和 preloadNamespaces 函数以支持动态加载 i18n 命名空间

* feat: 使用 useLazyTranslation 替换 useTranslation 以支持懒加载翻译

* feat: 添加 PageSkeleton 组件及其测试用例以支持页面加载骨架屏

* feat: 使用骨架屏替换加载状态指示器,优化用户体验

* feat: 优化 CopyButton 组件的复制功能,添加定时器管理复制状态

* feat: 调整 chunk 大小警告阈值以优化构建性能
2026-05-20 20:07:44 +08:00

394 lines
14 KiB
TypeScript

import { useState, useEffect, useRef, useMemo } from 'react';
import {
Box,
IconButton,
Stack,
Tooltip,
Typography,
InputBase,
Paper,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ClickAwayListener,
} from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import SearchIcon from '@mui/icons-material/Search';
import HistoryIcon from '@mui/icons-material/History';
import CloseIcon from '@mui/icons-material/Close';
import LanguageIcon from '@mui/icons-material/Language';
import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness';
import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FEATURES, FeatureConfig } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs';
import { useTranslation } from 'react-i18next';
import { alpha } from '@mui/material/styles';
import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n';
const topBarStyles = {
SEARCH_MAX_WIDTH: 400,
DROPDOWN_MAX_HEIGHT: 300,
Z_INDEX: 1100,
DROPDOWN_Z_INDEX: 1200,
SEARCH_HISTORY_LIMIT: 10,
SEARCH_HISTORY_DISPLAY: 5,
};
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const { t, i18n } = useTranslation(['common', 'features']);
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
// 加载搜索历史
useEffect(() => {
storageUtil
.get('app/searchHistory', [])
.then((history) => {
setSearchHistory(history || []);
})
.catch((error) => {
console.error('加载搜索历史失败:', error);
});
}, []);
// 模糊搜索逻辑
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false;
const label = t(f.labelKey).toLowerCase();
const desc = t(f.descriptionKey).toLowerCase();
return label.includes(query) || desc.includes(query);
});
}, [searchQuery, t]);
const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return [];
return searchHistory.slice(0, topBarStyles.SEARCH_HISTORY_DISPLAY);
}, [searchHistory, searchQuery]);
const handleOpenInTab = async () => {
await openExtensionPage('popup.html', { mode: 'tab' });
window.close();
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowResults(true);
setSelectedIndex(-1);
};
const saveToHistory = async (query: string) => {
if (!query.trim()) return;
setSearchHistory((prev) => {
const newHistory = [query, ...prev.filter((h) => h !== query)].slice(
0,
topBarStyles.SEARCH_HISTORY_LIMIT,
);
return newHistory;
});
};
// 副作用:搜索历史变化后持久化到 storage
useEffect(() => {
storageUtil.set('app/searchHistory', searchHistory).catch((error) => {
console.error('保存搜索历史失败:', error);
});
}, [searchHistory]);
const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key);
saveToHistory(t(feature.labelKey));
setSearchQuery('');
setShowResults(false);
};
const toggleLanguage = async () => {
const currentLng = normalizeLanguage(i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
const nextIndex = (currentIndex + 1) % SUPPORTED_LANGUAGES.length;
const newLng = SUPPORTED_LANGUAGES[nextIndex];
await i18n.changeLanguage(newLng);
await storageUtil.set('app/language', newLng);
};
const cycleThemeMode = () => {
const next = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(next[mode]);
};
const ThemeIcon =
mode === 'light' ? LightModeIcon : mode === 'dark' ? DarkModeIcon : SettingsBrightnessIcon;
const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') {
if (selectedIndex >= 0) {
if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]);
} else {
const selectedQuery = displayedHistory[selectedIndex];
setSearchQuery(selectedQuery);
setSelectedIndex(-1);
// 触发搜索:如果匹配到功能则跳转,否则保持搜索词展示结果
const matchedFeature = FEATURES.find(
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
);
if (matchedFeature) {
handleSelectFeature(matchedFeature);
}
}
} else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]);
}
} else if (e.key === 'Escape') {
setShowResults(false);
inputRef.current?.blur();
}
};
const isDashboard = currentPage === 'dashboard';
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
px: { xs: 1, sm: 2 },
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: topBarStyles.Z_INDEX,
position: 'relative',
}}
>
<Box sx={{ width: { xs: 32, sm: 40 } }}>
{!isDashboard && (
<IconButton
size="small"
onClick={goBack}
aria-label={t('common:buttons.back')}
sx={{
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.50',
'&:hover': {
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200',
},
}}
>
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem',
color: 'text.secondary',
ml: 1,
display: { xs: 'none', md: 'block' },
}}
>
{t('common:appName')}
</Typography>
<Box sx={{ flex: 1, mx: { xs: 1, sm: 2 }, position: 'relative', maxWidth: 400 }}>
<ClickAwayListener onClickAway={() => setShowResults(false)}>
<Box>
<InputBase
ref={inputRef}
placeholder={t('common:buttons.search')}
value={searchQuery}
onChange={handleSearchChange}
onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown}
inputProps={{ 'aria-label': t('common:buttons.search') }}
startAdornment={<SearchIcon sx={{ color: 'text.disabled', mr: 1, fontSize: 20 }} />}
endAdornment={
searchQuery && (
<IconButton
size="small"
onClick={() => {
setSearchQuery('');
setSelectedIndex(-1);
}}
aria-label={t('common:buttons.clearSearch')}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
)
}
sx={{
width: '100%',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.50',
px: 1.5,
py: 0.5,
borderRadius: 2,
fontSize: '0.875rem',
border: '1px solid',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'transparent',
transition: 'all 0.2s',
'&:hover': {
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'grey.100',
},
'&.Mui-focused': {
bgcolor: 'background.paper',
borderColor: 'primary.main',
boxShadow: (theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.15)}`,
},
}}
/>
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<Paper
elevation={8}
sx={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
mt: 1,
maxHeight: topBarStyles.DROPDOWN_MAX_HEIGHT,
overflow: 'auto',
borderRadius: 2,
zIndex: topBarStyles.DROPDOWN_Z_INDEX,
}}
>
<List disablePadding role="listbox">
{searchQuery.trim() ? (
searchResults.length > 0 ? (
searchResults.map((feature, index) => (
<ListItemButton
key={feature.key}
selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)}
role="option"
aria-selected={selectedIndex === index}
sx={{ py: 1 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>
{feature.icon && <feature.icon sx={{ fontSize: 20 }} />}
</ListItemIcon>
<ListItemText
primary={t(feature.labelKey)}
secondary={t(feature.descriptionKey)}
primaryTypographyProps={{ variant: 'body2', fontWeight: 600 }}
secondaryTypographyProps={{ variant: 'caption', noWrap: true }}
/>
</ListItemButton>
))
) : (
<Box sx={{ py: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{t('common:buttons.noResults')}
</Typography>
</Box>
)
) : (
<>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" fontWeight={700} color="text.disabled">
{t('common:buttons.recentSearch')}
</Typography>
</Box>
{displayedHistory.map((item, index) => (
<ListItemButton
key={item}
selected={selectedIndex === index}
onClick={() => {
setSearchQuery(item);
setSelectedIndex(-1);
}}
role="option"
aria-selected={selectedIndex === index}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<HistoryIcon sx={{ fontSize: 18, color: 'text.disabled' }} />
</ListItemIcon>
<ListItemText
primary={item}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
))}
</>
)}
</List>
</Paper>
)}
</Box>
</ClickAwayListener>
</Box>
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end', flexShrink: 0 }}>
<Tooltip title={t('common:buttons.toggleLanguage')}>
<IconButton
size="small"
onClick={toggleLanguage}
aria-label={t('common:buttons.toggleLanguage')}
>
<LanguageIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title={t(`common:buttons.themeMode.${mode}`)}>
<IconButton
size="small"
onClick={cycleThemeMode}
aria-label={t('common:buttons.toggleTheme')}
>
<ThemeIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title={t('common:buttons.openInTab')}>
<IconButton
size="small"
onClick={handleOpenInTab}
aria-label={t('common:buttons.openInTab')}
>
<OpenInNewIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title={t('common:buttons.settings')}>
<IconButton
size="small"
onClick={onOpenOptions}
aria-label={t('common:buttons.settings')}
>
<SettingsIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Stack>
);
}