Files
YouduWiki/web/packages/ui/src/banner/index.tsx
2026-05-21 19:52:45 +08:00

470 lines
13 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTextAnimation } from '../hooks/useGsapAnimation';
import {
ButtonProps,
styled,
TextField,
Button,
Stack,
Box,
alpha,
lighten,
} from '@mui/material';
import { StyledTopicBox } from '../component/styledCommon';
const StyledBanner = styled('div')(({ theme }) => ({
backgroundColor: alpha(theme.palette.primary.main, 0.03),
backgroundImage: `radial-gradient(${alpha(theme.palette.primary.main, 0.08)} 2px, transparent 1px)`,
backgroundSize: '36px 36px', // dot spacing
backgroundPosition: '0 0',
backgroundRepeat: 'repeat',
marginTop: theme.spacing(-10),
}));
const StyledTitle = styled('h1')(({ theme }) => ({
fontSize: 60,
fontWeight: 700,
wordBreak: 'break-all',
color: theme.palette.primary.main,
marginBottom: theme.spacing(3),
[theme.breakpoints.down('md')]: {
fontSize: 50,
},
[theme.breakpoints.down('sm')]: {
fontSize: 40,
},
}));
const StyledSubTitle = styled('h2')(({ theme }) => ({
fontWeight: 400,
marginBottom: theme.spacing(5),
color: theme.palette.text.primary,
}));
const StyledSearchBox = styled(Box)(({ theme }) => ({
position: 'relative',
width: '100%',
padding: theme.spacing(2),
boxShadow: `0 2px 10px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
border: `1px solid transparent`,
borderRadius: '10px',
backgroundColor: theme.palette.background.default,
'&:hover': {
borderColor: alpha(theme.palette.primary.main, 0.4),
},
'&:focus-within': {
borderColor: theme.palette.primary.main,
},
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
'.MuiInputBase-root': {
padding: 0,
},
fieldset: {
border: 'none',
},
'& input::placeholder, & textarea::placeholder': {
color: alpha(theme.palette.text.primary, 0.5),
opacity: 1,
},
}));
// 闪烁光标样式
const blinkAnimation = `
@keyframes blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
`;
const StyledCursor = styled('span')(({ theme }) => ({
display: 'inline-block',
width: '1px',
height: '18px',
backgroundColor: alpha(theme.palette.text.primary, 1),
marginLeft: '2px',
animation: 'blink 1s infinite',
flexShrink: 0,
}));
const StyledHotItem = styled(Box)(({ theme }) => ({
color: theme.palette.text.primary,
padding: theme.spacing(0.75, 2),
borderRadius: '16px',
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
fontSize: 12,
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
},
}));
interface SearchSuggestion {
id: string;
title: string;
description?: string;
type?: 'recent' | 'suggestion' | 'trending';
}
interface BannerProps {
title: {
text: string;
fontSize: string;
color: string;
};
subtitle: {
text: string;
fontSize: string;
color: string;
};
bg_url?: string;
search: {
placeholder: string;
hot: string[];
};
btns: {
type: ButtonProps['variant'];
text: string;
href: string;
}[];
onSearch?: (value: string, type?: 'search' | 'chat') => void;
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
basePath?: string;
}
const Banner = React.memo(
({
title,
subtitle,
bg_url,
search,
btns = [],
onSearch,
onSearchSuggestions,
basePath = '',
}: BannerProps) => {
const [searchText, setSearchText] = useState('');
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [anchorElWidth, setAnchorElWidth] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);
const [typedText, setTypedText] = useState('');
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const typewriterTimer = useRef<NodeJS.Timeout | null>(null);
// 添加文字动画效果
const titleRef = useTextAnimation(0, 0.1);
const subtitleRef = useTextAnimation(0.2, 0.1);
// 打字机效果
useEffect(() => {
if (isFocused || !search.hot || search.hot.length === 0) {
return;
}
let currentIndex = 0;
let currentCharIndex = 0;
let isDeleting = false;
let isPaused = false;
const typeWriter = () => {
const currentWord = search.hot[currentIndex];
if (isPaused) {
typewriterTimer.current = setTimeout(() => {
isPaused = false;
typeWriter();
}, 1000); // 暂停1秒
return;
}
if (!isDeleting) {
// 打字阶段
if (currentCharIndex < currentWord.length) {
setTypedText(currentWord.substring(0, currentCharIndex + 1));
currentCharIndex++;
typewriterTimer.current = setTimeout(typeWriter, 100); // 打字速度(调慢)
} else {
// 打完了,暂停后开始删除
isPaused = true;
isDeleting = true;
typeWriter();
}
} else {
// 删除阶段
if (currentCharIndex > 0) {
currentCharIndex--;
setTypedText(currentWord.substring(0, currentCharIndex));
typewriterTimer.current = setTimeout(typeWriter, 80); // 删除速度(调慢)
} else {
// 删完了,切换到下一个词
isDeleting = false;
currentIndex = (currentIndex + 1) % search.hot.length;
typewriterTimer.current = setTimeout(typeWriter, 200); // 切换词之间的延迟
}
}
};
typeWriter();
return () => {
if (typewriterTimer.current) {
clearTimeout(typewriterTimer.current);
}
};
}, [isFocused, search.hot]);
// 防抖搜索
const debouncedSearch = useCallback(
(query: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(async () => {
if (query.trim() && onSearchSuggestions) {
setIsLoading(true);
try {
const results = await onSearchSuggestions(query);
setSuggestions(results);
} catch (error) {
console.error('搜索建议获取失败:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
} else {
setSuggestions([]);
}
}, 300);
},
[onSearchSuggestions],
);
// 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchText(value);
setSelectedIndex(-1);
if (value.trim()) {
debouncedSearch(value);
if (onSearch) {
setAnchorEl(e.currentTarget.parentElement);
setAnchorElWidth(e.currentTarget.parentElement?.offsetWidth || 0);
}
} else {
setSuggestions([]);
setAnchorEl(null);
}
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
// e.preventDefault();
// if (selectedIndex >= 0 && suggestions[selectedIndex]) {
// const selectedSuggestion = suggestions[selectedIndex];
// setSearchText(selectedSuggestion.title);
// onSearch?.(selectedSuggestion.title);
// } else {
// onSearch?.(searchText);
// }
onSearch?.(searchText, 'chat');
setSearchText('');
setAnchorEl(null);
setSelectedIndex(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : prev,
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setAnchorEl(null);
setSelectedIndex(-1);
}
};
// 处理输入框聚焦
const handleInputFocus = (e: React.FocusEvent) => {
setIsFocused(true);
setTypedText(''); // 清空打字机文本
if (searchText.trim()) {
setAnchorEl(e.currentTarget.parentElement);
setAnchorElWidth(e.currentTarget.parentElement?.offsetWidth || 0);
}
};
// 处理输入框失焦
const handleInputBlur = () => {
setIsFocused(false);
};
// 清理定时器
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, []);
return (
<StyledBanner
sx={{
...(bg_url
? {
backgroundImage: `url(${bg_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}
: {}),
}}
>
<StyledTopicBox
sx={{
alignItems: 'flex-start',
gap: 0,
py: { xs: 8, md: '200px' },
pt: { xs: 16 },
}}
>
<StyledTitle ref={titleRef}>{title.text}</StyledTitle>
{/* {subtitle.text && ( */}
<StyledSubTitle
ref={subtitleRef}
sx={{
fontSize: `${subtitle.fontSize || 16}px`,
}}
>
{subtitle.text}
</StyledSubTitle>
{/* )} */}
<StyledSearchBox className='banner-search-box'>
<Box sx={{ position: 'relative' }}>
<style>{blinkAnimation}</style>
{!isFocused && !searchText && typedText && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
color: theme => alpha(theme.palette.text.primary, 0.85),
fontSize: '16px',
lineHeight: 1.5,
display: 'inline-flex',
alignItems: 'center',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
<span>{typedText}</span>
<StyledCursor />
</Box>
)}
<StyledTextField
fullWidth
placeholder={isFocused || searchText ? search.placeholder : ''}
value={searchText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
multiline
rows={3}
/>
</Box>
<Stack direction='row' alignItems='center' gap={1} flexWrap='wrap'>
<Stack direction='row' gap='8px 16px' flexWrap='wrap'>
{search.hot?.map(hot => (
<StyledHotItem key={hot} onClick={() => onSearch?.(hot)}>
{hot}
</StyledHotItem>
))}
</Stack>
<Button
variant='contained'
size='small'
sx={{
fontSize: 12,
borderRadius: 4,
flexShrink: 0,
ml: 'auto',
}}
onClick={() => onSearch?.(searchText, 'chat')}
>
AI
</Button>
</Stack>
</StyledSearchBox>
{btns.length > 0 && (
<Stack
direction='row'
gap={{
xs: '16px 24px',
md: '16px 40px',
}}
sx={{ mt: 5 }}
flexWrap='wrap'
>
{btns.map(btn => (
<Button
key={btn.text}
variant={btn.type}
href={btn.href}
target='_blank'
size='large'
color='primary'
sx={theme => ({
...(btn.type === 'outlined' && {
borderWidth: 2,
bgcolor: theme.palette.background.default,
borderColor: alpha(theme.palette.primary.main, 0.8),
'&:hover': {
borderColor: theme.palette.primary.main,
},
}),
lineHeight: 1.5,
fontSize: {
xs: 14,
md: 18,
},
px: {
xs: 3,
md: '69px',
},
py: {
xs: 1,
md: '12px',
},
})}
>
{btn.text}
</Button>
))}
</Stack>
)}
</StyledTopicBox>
</StyledBanner>
);
},
);
export default Banner;