init push
This commit is contained in:
469
web/packages/ui/src/banner/index.tsx
Normal file
469
web/packages/ui/src/banner/index.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'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;
|
||||
135
web/packages/ui/src/basicDoc/index.tsx
Normal file
135
web/packages/ui/src/basicDoc/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, Box, alpha } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import IconWenjian from '@panda-wiki/icons/IconWenjian';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface BasicDocProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items?: {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
emoji?: string;
|
||||
}[];
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
const StyledBasicDocItem = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(3, 2),
|
||||
borderRadius: '8px',
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
borderColor: theme.palette.primary.main,
|
||||
'.basic-doc-item-title': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledBasicDocItemTitle = styled('h3')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.palette.text.primary,
|
||||
width: '100%',
|
||||
}));
|
||||
const StyledBasicDocItemName = styled('span')(({ theme }) => ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
const StyledBasicDocItemSummary = styled('div')(({ theme }) => ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitLineClamp: 4,
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
flex: '1 0 auto',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
height: 80,
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const BasicDocItem: React.FC<{
|
||||
item: any;
|
||||
index: number;
|
||||
basePath: string;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, basePath, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size} key={index}>
|
||||
<StyledBasicDocItem
|
||||
ref={cardRef as React.Ref<HTMLDivElement>}
|
||||
onClick={() => {
|
||||
window.open(`${basePath}/node/${item.id}`, '_blank');
|
||||
}}
|
||||
>
|
||||
<StyledBasicDocItemTitle className='basic-doc-item-title'>
|
||||
{item.emoji ? (
|
||||
<Box>{item.emoji}</Box>
|
||||
) : (
|
||||
<IconWenjian sx={{ fontSize: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
<StyledBasicDocItemName>{item.name}</StyledBasicDocItemName>
|
||||
</StyledBasicDocItemTitle>
|
||||
<StyledBasicDocItemSummary>{item.summary}</StyledBasicDocItemSummary>
|
||||
</StyledBasicDocItem>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
const BasicDoc: React.FC<BasicDocProps> = React.memo(
|
||||
({ title, items = [], mobile, basePath = '' }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean' ? (mobile ? 12 : 4) : { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<BasicDocItem
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
basePath={basePath}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default BasicDoc;
|
||||
113
web/packages/ui/src/blockGrid/index.tsx
Normal file
113
web/packages/ui/src/blockGrid/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha, Stack } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface BlockGridProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items?: {
|
||||
name: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
const StyledBlockGridItem = styled(Stack)(({ theme }) => ({
|
||||
aspectRatio: '1 / 1',
|
||||
position: 'relative',
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(1),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
export const StyledBlockGridItemImgBox = styled('div')(({ theme }) => ({
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
export const StyledBlockGridItemImg = styled('img')(({ theme }) => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '10px',
|
||||
}));
|
||||
|
||||
const StyledBlockGridItemTitle = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
left: '50%',
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: theme.spacing(0.5, 1),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
color: theme.palette.background.default,
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.5),
|
||||
borderRadius: '6px',
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const BlockGridItem: React.FC<{
|
||||
item: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
index: number;
|
||||
}> = React.memo(({ item, index }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
return (
|
||||
<StyledBlockGridItem ref={cardRef as React.Ref<HTMLDivElement>} gap={2}>
|
||||
<StyledBlockGridItemImgBox>
|
||||
<StyledBlockGridItemImg src={item.url} />
|
||||
</StyledBlockGridItemImgBox>
|
||||
|
||||
<StyledBlockGridItemTitle>{item.name}</StyledBlockGridItemTitle>
|
||||
</StyledBlockGridItem>
|
||||
);
|
||||
});
|
||||
|
||||
const BlockGrid: React.FC<BlockGridProps> = React.memo(
|
||||
({ title, items = [], mobile }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 4 }
|
||||
: { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<Grid size={size} key={index}>
|
||||
<BlockGridItem item={item} index={index} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default BlockGrid;
|
||||
19
web/packages/ui/src/carousel/index.css
Normal file
19
web/packages/ui/src/carousel/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.swiper {
|
||||
width: 100%;
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.swiper-slide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
403
web/packages/ui/src/carousel/index.tsx
Normal file
403
web/packages/ui/src/carousel/index.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { memo, useRef, useCallback, useState, useEffect } from 'react';
|
||||
import { styled, alpha, Tabs, Tab, Box, useTheme } from '@mui/material';
|
||||
import { StyledTopicTitle, StyledTopicBox } from '../component/styledCommon';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { useFadeInText } from '../hooks/useGsapAnimation';
|
||||
import { Swiper as SwiperType } from 'swiper';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
import { Pagination, Autoplay } from 'swiper/modules';
|
||||
import './index.css';
|
||||
|
||||
interface CarouselProps {
|
||||
mobile?: boolean;
|
||||
title: string;
|
||||
items: {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const indicatorContainerStyle = {
|
||||
bottom: 0,
|
||||
};
|
||||
|
||||
export const indicatorIconButtonStyle = {
|
||||
width: 6,
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255, 255, 255, 0.20)',
|
||||
height: 6,
|
||||
cursor: 'pointer',
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
export const activeIndicatorIconButtonStyle = {
|
||||
background: 'rgba(255, 255, 255, 1)',
|
||||
};
|
||||
|
||||
const StyledSwiperSlideImg = styled('img')(({ theme }) => ({
|
||||
aspectRatio: '16 / 9',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
width: 440,
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: 880,
|
||||
},
|
||||
objectFit: 'cover',
|
||||
borderRadius: '10px',
|
||||
}));
|
||||
|
||||
const StyledSwiperSlideDesc = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: theme.spacing(0.5, 1),
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.background.default,
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 0,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.5),
|
||||
filter: 'blur(6px)',
|
||||
borderRadius: '12px',
|
||||
zIndex: -1,
|
||||
},
|
||||
}));
|
||||
|
||||
// 样式化的 Tabs 容器 - 浅灰色背景,圆角,阴影
|
||||
const StyledTabsContainer = styled(Box)(({ theme }) => ({
|
||||
maxWidth: '100%',
|
||||
display: 'inline-flex',
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(0.5),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
gap: theme.spacing(0.5),
|
||||
marginBottom: '-20px',
|
||||
}));
|
||||
|
||||
// 样式化的 Tabs 组件 - 移除默认样式
|
||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .MuiTabs-flexContainer': {
|
||||
gap: theme.spacing(0.5),
|
||||
},
|
||||
}));
|
||||
|
||||
// 样式化的 Tab 组件 - 白色背景,圆角,深灰色文字
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
padding: theme.spacing(1, 2),
|
||||
borderRadius: '6px',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
textTransform: 'none',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
const Carousel = ({ title, items }: CarouselProps) => {
|
||||
const theme = useTheme();
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
// 添加Swiper ref
|
||||
const swiperRef = useRef<SwiperType | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>(items[0]?.id || '');
|
||||
// 存储所有描述元素的 ref
|
||||
const descRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
// 存储动画时间线,用于清理
|
||||
const animationTimelines = useRef<gsap.core.Timeline[]>([]);
|
||||
|
||||
// 导航函数
|
||||
const handlePrev = useCallback(() => {
|
||||
if (swiperRef.current) {
|
||||
swiperRef.current.slidePrev();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (swiperRef.current) {
|
||||
swiperRef.current.slideNext();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 触发从左到右的文字出现动画(逐字符显示,容器逐渐撑大)
|
||||
const animateTextFromLeft = useCallback(
|
||||
(index: number) => {
|
||||
const descElement = descRefs.current[index];
|
||||
if (!descElement) return;
|
||||
|
||||
// 清理之前的动画
|
||||
animationTimelines.current.forEach(tl => tl.kill());
|
||||
animationTimelines.current = [];
|
||||
|
||||
const originalText = descElement.textContent || '';
|
||||
if (!originalText) return;
|
||||
|
||||
// 获取容器的 padding 值
|
||||
const computedStyle = window.getComputedStyle(descElement);
|
||||
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||
const paddingRight = parseFloat(computedStyle.paddingRight) || 0;
|
||||
const padding = paddingLeft + paddingRight;
|
||||
|
||||
// 将文字分割成字符
|
||||
const chars = Array.from(originalText);
|
||||
const charElements: HTMLSpanElement[] = [];
|
||||
|
||||
// 清空容器并创建字符元素(初始都隐藏)
|
||||
descElement.innerHTML = '';
|
||||
chars.forEach(char => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char === ' ' ? '\u00A0' : char; // 空格用非断行空格
|
||||
span.style.opacity = '0';
|
||||
span.style.display = 'inline-block';
|
||||
descElement.appendChild(span);
|
||||
charElements.push(span);
|
||||
});
|
||||
|
||||
// 创建一个隐藏的测量容器来准确测量每个字符的宽度
|
||||
const measureContainer = document.createElement('div');
|
||||
measureContainer.style.position = 'absolute';
|
||||
measureContainer.style.visibility = 'hidden';
|
||||
measureContainer.style.whiteSpace = 'nowrap';
|
||||
measureContainer.style.fontSize = computedStyle.fontSize;
|
||||
measureContainer.style.fontWeight = computedStyle.fontWeight;
|
||||
measureContainer.style.fontFamily = computedStyle.fontFamily;
|
||||
document.body.appendChild(measureContainer);
|
||||
|
||||
// 测量每个字符的宽度
|
||||
const charWidths: number[] = [];
|
||||
charElements.forEach(span => {
|
||||
measureContainer.textContent = span.textContent;
|
||||
const charWidth = measureContainer.offsetWidth;
|
||||
charWidths.push(charWidth);
|
||||
});
|
||||
|
||||
document.body.removeChild(measureContainer);
|
||||
|
||||
// 设置容器初始状态(只有 padding,背景色透明)
|
||||
gsap.set(descElement, {
|
||||
width: padding,
|
||||
minWidth: padding,
|
||||
});
|
||||
|
||||
// 创建动画时间线,延迟 0.5 秒开始
|
||||
const tl = gsap.timeline({ delay: 0.5 });
|
||||
let currentWidth = padding;
|
||||
|
||||
// 背景色从透明逐渐加深(与第一个字符同时开始)
|
||||
tl.to(
|
||||
descElement,
|
||||
{
|
||||
duration: 0.4, // 背景色变化稍快一些,在文字显示过程中完成
|
||||
ease: 'power2.out',
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// 逐个显示字符,同时增加容器宽度
|
||||
// 第一个字符在延迟后立即开始显示(时间位置 0)
|
||||
charElements.forEach((span, i) => {
|
||||
const charWidth = charWidths[i];
|
||||
currentWidth += charWidth;
|
||||
|
||||
// 同时显示字符和增加容器宽度
|
||||
// 第一个字符立即显示(i=0 时时间为 0),后续字符依次延迟
|
||||
tl.to(
|
||||
span,
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 0.08,
|
||||
ease: 'none',
|
||||
},
|
||||
i * 0.08,
|
||||
);
|
||||
|
||||
// 同时更新容器宽度
|
||||
tl.to(
|
||||
descElement,
|
||||
{
|
||||
width: currentWidth,
|
||||
duration: 0.08,
|
||||
ease: 'none',
|
||||
},
|
||||
i * 0.08,
|
||||
);
|
||||
});
|
||||
|
||||
// 保存动画时间线
|
||||
animationTimelines.current.push(tl);
|
||||
},
|
||||
[theme],
|
||||
);
|
||||
|
||||
// 监听 Swiper 切换,更新 activeTab 并触发动画
|
||||
const handleSlideChange = useCallback(
|
||||
(swiper: SwiperType) => {
|
||||
const activeIndex = swiper.activeIndex;
|
||||
// 在 centeredSlides 模式下,activeIndex 直接对应 items 数组的索引
|
||||
const activeItem = items[activeIndex];
|
||||
if (activeItem) {
|
||||
setActiveTab(activeItem.id);
|
||||
// 触发当前幻灯片的文字动画
|
||||
animateTextFromLeft(activeIndex);
|
||||
}
|
||||
},
|
||||
[items, animateTextFromLeft],
|
||||
);
|
||||
|
||||
// 当 activeTab 改变时,切换对应的 Swiper 卡片
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
setActiveTab(value);
|
||||
const targetIndex = items.findIndex(item => item.id === value);
|
||||
if (targetIndex !== -1 && swiperRef.current) {
|
||||
swiperRef.current.slideTo(targetIndex);
|
||||
// 触发切换后的文字动画
|
||||
setTimeout(() => {
|
||||
animateTextFromLeft(targetIndex);
|
||||
}, 300); // 等待切换动画完成
|
||||
}
|
||||
},
|
||||
[items, animateTextFromLeft],
|
||||
);
|
||||
|
||||
// 初始加载时触发第一个幻灯片的动画
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && descRefs.current[0]) {
|
||||
// 延迟执行,确保元素已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
animateTextFromLeft(0);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [items.length, animateTextFromLeft]);
|
||||
|
||||
// 组件卸载时清理所有动画
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
animationTimelines.current.forEach(tl => tl.kill());
|
||||
animationTimelines.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 使用事件委托的方式处理点击事件
|
||||
const handleSlideClick = useCallback(
|
||||
(swiper: SwiperType, event: MouseEvent | TouchEvent | PointerEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// 查找最近的swiper-slide元素
|
||||
const slideElement = target.closest('.swiper-slide');
|
||||
|
||||
if (slideElement) {
|
||||
// 检查是否包含swiper-slide-prev类名
|
||||
if (slideElement.classList.contains('swiper-slide-prev')) {
|
||||
handlePrev();
|
||||
}
|
||||
// 检查是否包含swiper-slide-next类名
|
||||
else if (slideElement.classList.contains('swiper-slide-next')) {
|
||||
handleNext();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handlePrev, handleNext],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTopicBox
|
||||
sx={{
|
||||
'.swiper-pagination-bullets': indicatorContainerStyle,
|
||||
'.swiper-pagination-bullet': indicatorIconButtonStyle,
|
||||
'.swiper-pagination-bullet-active': activeIndicatorIconButtonStyle,
|
||||
'.swiper-slide-prev': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.swiper-slide-next': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StyledTopicTitle ref={titleRef} sx={{ color: 'text.primary' }}>
|
||||
{title}
|
||||
</StyledTopicTitle>
|
||||
{items.length > 0 && (
|
||||
<StyledTabsContainer>
|
||||
<StyledTabs
|
||||
value={activeTab}
|
||||
onChange={(_, value) => {
|
||||
handleTabChange(value as string);
|
||||
}}
|
||||
variant='scrollable'
|
||||
scrollButtons={false}
|
||||
>
|
||||
{items.map(item => (
|
||||
<StyledTab key={item.id} label={item.title} value={item.id} />
|
||||
))}
|
||||
</StyledTabs>
|
||||
</StyledTabsContainer>
|
||||
)}
|
||||
<Swiper
|
||||
onSwiper={swiper => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onSlideChange={handleSlideChange}
|
||||
onClick={handleSlideClick}
|
||||
spaceBetween={50}
|
||||
centeredSlides={true}
|
||||
pagination={{
|
||||
clickable: true,
|
||||
}}
|
||||
autoplay={{
|
||||
delay: 5000,
|
||||
disableOnInteraction: false,
|
||||
pauseOnMouseEnter: true,
|
||||
}}
|
||||
modules={[Pagination, Autoplay]}
|
||||
className='mySwiper'
|
||||
>
|
||||
{items?.map((item, index) => (
|
||||
<SwiperSlide key={item.id} style={{ position: 'relative' }}>
|
||||
<StyledSwiperSlideImg src={item.url} alt={item.title} />
|
||||
{item.desc && (
|
||||
<StyledSwiperSlideDesc
|
||||
ref={el => {
|
||||
descRefs.current[index] = el;
|
||||
}}
|
||||
>
|
||||
{item.desc}
|
||||
</StyledSwiperSlideDesc>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Carousel);
|
||||
94
web/packages/ui/src/case/index.tsx
Normal file
94
web/packages/ui/src/case/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha, Stack } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardScaleAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface CaseProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
bgColor?: string;
|
||||
titleColor?: string;
|
||||
items?: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
}
|
||||
const StyledCaseItem = styled('a')(({ theme }) => ({
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(2.5),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
cursor: 'pointer',
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
}));
|
||||
|
||||
const StyledCaseItemTitle = styled('span')(({ theme }) => ({
|
||||
fontSize: 16,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const CaseItem: React.FC<{
|
||||
item: any;
|
||||
index: number;
|
||||
}> = React.memo(({ item, index }) => {
|
||||
const rand = Math.random();
|
||||
const cardRef = useCardScaleAnimation({
|
||||
duration: rand < 0.5 ? rand + 0.5 : rand,
|
||||
});
|
||||
return (
|
||||
<StyledCaseItem
|
||||
ref={cardRef as React.Ref<HTMLAnchorElement>}
|
||||
href={item.link}
|
||||
target='_blank'
|
||||
>
|
||||
<StyledCaseItemTitle>{item.name}</StyledCaseItemTitle>
|
||||
</StyledCaseItem>
|
||||
);
|
||||
});
|
||||
|
||||
const Case: React.FC<CaseProps> = React.memo(
|
||||
({ title = '案例', items = [], mobile }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 4 }
|
||||
: { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Stack
|
||||
gap={1}
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
flexWrap='wrap'
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<CaseItem key={index} item={item} index={index} />
|
||||
))}
|
||||
</Stack>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Case;
|
||||
112
web/packages/ui/src/comment/index.tsx
Normal file
112
web/packages/ui/src/comment/index.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha, Stack, Rating } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface Props {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items: {
|
||||
user_name?: string;
|
||||
profession?: string;
|
||||
avatar?: string;
|
||||
comment?: string;
|
||||
}[];
|
||||
}
|
||||
const StyledItem = styled(Stack)(({ theme }) => ({
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(3),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
height: '100%',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledItemSummary = styled('div')(({ theme }) => ({
|
||||
fontSize: 16,
|
||||
fontWeight: 400,
|
||||
color: alpha(theme.palette.text.primary, 0.85),
|
||||
}));
|
||||
|
||||
const StyledItemUserAvatar = styled('img')(({ theme }) => ({
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
}));
|
||||
|
||||
const StyledItemUser = styled('div')(({ theme }) => ({
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const StyledItemProfession = styled('div')(({ theme }) => ({
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const Item: React.FC<{
|
||||
item: {
|
||||
comment?: string;
|
||||
avatar?: string;
|
||||
user_name?: string;
|
||||
profession?: string;
|
||||
};
|
||||
index: number;
|
||||
}> = React.memo(({ item, index }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
return (
|
||||
<StyledItem ref={cardRef as React.Ref<HTMLDivElement>} gap={3}>
|
||||
<StyledItemSummary>{item.comment}</StyledItemSummary>
|
||||
<Stack direction='row' gap={1}>
|
||||
{item.avatar && (
|
||||
<StyledItemUserAvatar src={item.avatar} alt={item.user_name} />
|
||||
)}
|
||||
<Stack gap={0.5} justifyContent='center'>
|
||||
<StyledItemUser>{item.user_name}</StyledItemUser>
|
||||
<StyledItemProfession>{item.profession}</StyledItemProfession>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</StyledItem>
|
||||
);
|
||||
});
|
||||
|
||||
const Comment: React.FC<Props> = React.memo(({ title, items, mobile }) => {
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 4 }
|
||||
: { xs: 12, md: 4 };
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<Grid size={size} key={index}>
|
||||
<Item item={item} index={index} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Comment;
|
||||
78
web/packages/ui/src/component/styledCommon/index.tsx
Normal file
78
web/packages/ui/src/component/styledCommon/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
maxWidth: 1248,
|
||||
}));
|
||||
|
||||
export const StyledTopicContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing(8),
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(0, 2),
|
||||
}));
|
||||
|
||||
export const StyledTopicInner = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(8),
|
||||
flex: 1,
|
||||
maxWidth: 1740,
|
||||
borderRadius: '20px',
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
export const StyledTopicBox = styled(StyledContainer)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(8),
|
||||
alignItems: 'center',
|
||||
padding: '90px 24px',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 60,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledEllipsis = styled('span')(({ theme }) => ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
export const StyledTopicTitle = styled('h2')(({ theme }) => ({
|
||||
fontSize: 36,
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
fontSize: 32,
|
||||
},
|
||||
[theme.breakpoints.down('md')]: {
|
||||
fontSize: 28,
|
||||
},
|
||||
fontWeight: 700,
|
||||
color: theme.palette.text.primary,
|
||||
overflow: 'hidden',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
export const StyledTopicDes = styled('p')(({ theme }) => ({
|
||||
width: 998,
|
||||
fontSize: 18,
|
||||
fontWeight: 300,
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: 'center',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledCard = styled('div')(({ theme }) => ({
|
||||
padding: `${theme.spacing(2)} `,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
borderRadius: `calc(${theme.shape.borderRadius} * 2)`,
|
||||
}));
|
||||
18
web/packages/ui/src/constants/index.ts
Normal file
18
web/packages/ui/src/constants/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { decodeBase64 } from '../utils';
|
||||
export const DocWidth = {
|
||||
full: {
|
||||
label: '全屏',
|
||||
value: 0,
|
||||
},
|
||||
wide: {
|
||||
label: '超宽',
|
||||
value: 960,
|
||||
},
|
||||
normal: {
|
||||
label: '常规',
|
||||
value: 720,
|
||||
},
|
||||
};
|
||||
|
||||
export const PROJECT_NAME =
|
||||
'5pys572R56uZ55SxIFBhbmRhV2lraSDmj5DkvpvmioDmnK/mlK/mjIE=';
|
||||
174
web/packages/ui/src/dirDoc/index.tsx
Normal file
174
web/packages/ui/src/dirDoc/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, Box, Button, alpha } from '@mui/material';
|
||||
import {
|
||||
StyledTopicBox,
|
||||
StyledTopicTitle,
|
||||
StyledEllipsis,
|
||||
} from '../component/styledCommon';
|
||||
import { IconWenjianjia, IconWenjian } from '@panda-wiki/icons';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
interface DirDocProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
bgColor?: string;
|
||||
titleColor?: string;
|
||||
items?: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
recommend_nodes: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
position?: number;
|
||||
}[];
|
||||
}[];
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
const StyledDirDocItem = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(3.5, 2.5, 2),
|
||||
borderRadius: '8px',
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0px 10px 20px 0px rgba(0,0,5,0.2)',
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
width: '100%',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledDirDocItemTitle = styled('h3')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text.primary,
|
||||
gap: theme.spacing(1),
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledDirDocItemFiles = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flex: '1 0 auto',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing(2),
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
height: 129,
|
||||
width: '100%',
|
||||
lineHeight: 1.5,
|
||||
}));
|
||||
|
||||
const StyledDirDocItemFile = styled('a')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
color: '#717572',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const DirDocItem: React.FC<{
|
||||
item: any;
|
||||
index: number;
|
||||
basePath: string;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, basePath, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size} key={index}>
|
||||
<StyledDirDocItem ref={cardRef as React.Ref<HTMLDivElement>}>
|
||||
<StyledDirDocItemTitle
|
||||
onClick={() => {
|
||||
window.open(`${basePath}/node/${item.id}`, '_blank');
|
||||
}}
|
||||
>
|
||||
{item.emoji ? (
|
||||
<Box>{item.emoji}</Box>
|
||||
) : (
|
||||
<IconWenjianjia sx={{ fontSize: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
<StyledEllipsis>{item.name}</StyledEllipsis>
|
||||
</StyledDirDocItemTitle>
|
||||
<StyledDirDocItemFiles>
|
||||
{item.recommend_nodes.slice(0, 4).map((it: any) => (
|
||||
<StyledDirDocItemFile
|
||||
key={it.id}
|
||||
href={`${basePath}/node/${it.id}`}
|
||||
target='_blank'
|
||||
>
|
||||
{it.emoji ? (
|
||||
<Box>{it.emoji}</Box>
|
||||
) : (
|
||||
<IconWenjian sx={{ fontSize: 14, flexShrink: 0 }} />
|
||||
)}
|
||||
<StyledEllipsis>{it.name}</StyledEllipsis>
|
||||
</StyledDirDocItemFile>
|
||||
))}
|
||||
</StyledDirDocItemFiles>
|
||||
<Button
|
||||
href={`${basePath}/node/${item.recommend_nodes[0]?.id}`}
|
||||
target='_blank'
|
||||
sx={{ gap: 1, alignSelf: 'flex-end' }}
|
||||
variant='text'
|
||||
color='primary'
|
||||
>
|
||||
查看更多
|
||||
</Button>
|
||||
</StyledDirDocItem>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
const DirDoc: React.FC<DirDocProps> = React.memo(
|
||||
({ title, items = [], mobile, basePath = '' }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean' ? (mobile ? 12 : 4) : { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<DirDocItem
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
basePath={basePath}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default React.memo(DirDoc);
|
||||
94
web/packages/ui/src/faq/index.tsx
Normal file
94
web/packages/ui/src/faq/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import { IconLianjiezu } from '@panda-wiki/icons';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface FaqProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items?: {
|
||||
question: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const StyledFaqItem = styled('a')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
padding: theme.spacing(3, 4),
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
color: theme.palette.primary.main,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledFaqItemTitle = styled('span')(({ theme }) => ({
|
||||
fontSize: 16,
|
||||
fontWeight: 400,
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const FaqItem: React.FC<{
|
||||
item: any;
|
||||
index: number;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size} key={index}>
|
||||
<StyledFaqItem
|
||||
ref={cardRef as React.Ref<HTMLAnchorElement>}
|
||||
href={item.url}
|
||||
target='_blank'
|
||||
>
|
||||
<IconLianjiezu sx={{ color: 'primary.main', fontSize: 18 }} />
|
||||
<StyledFaqItemTitle>{item.question}</StyledFaqItemTitle>
|
||||
</StyledFaqItem>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
const Faq: React.FC<FaqProps> = React.memo(({ title, items = [], mobile }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 4 }
|
||||
: { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<FaqItem key={index} item={item} index={index} size={size} />
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
});
|
||||
|
||||
export default Faq;
|
||||
120
web/packages/ui/src/feature/index.tsx
Normal file
120
web/packages/ui/src/feature/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha, Stack } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
import { IconTips } from '@panda-wiki/icons';
|
||||
|
||||
interface FeatureProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items?: {
|
||||
name: string;
|
||||
desc: string;
|
||||
}[];
|
||||
}
|
||||
const StyledFeatureItem = styled(Stack)(({ theme }) => ({
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(2.5),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
export const StyledFeatureItemIcon = styled('div')(({ theme }) => ({
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.06),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '0 0 60px',
|
||||
}));
|
||||
|
||||
const StyledFeatureItemTitle = styled('span')(({ theme }) => ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const StyledFeatureItemSummary = styled('div')(({ theme }) => ({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
height: 60,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const FeatureItem: React.FC<{
|
||||
item: {
|
||||
name: string;
|
||||
desc: string;
|
||||
};
|
||||
index: number;
|
||||
}> = React.memo(({ item, index }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
return (
|
||||
<StyledFeatureItem
|
||||
ref={cardRef as React.Ref<HTMLDivElement>}
|
||||
gap={2.5}
|
||||
direction='row'
|
||||
alignItems='flex-start'
|
||||
>
|
||||
<StyledFeatureItemIcon>
|
||||
<IconTips sx={{ color: 'primary.main', fontSize: 24 }} />
|
||||
</StyledFeatureItemIcon>
|
||||
<Stack gap={1} sx={{ minWidth: 0 }}>
|
||||
<StyledFeatureItemTitle>{item.name}</StyledFeatureItemTitle>
|
||||
<StyledFeatureItemSummary>{item.desc}</StyledFeatureItemSummary>
|
||||
</Stack>
|
||||
</StyledFeatureItem>
|
||||
);
|
||||
});
|
||||
|
||||
const Feature: React.FC<FeatureProps> = React.memo(
|
||||
({ title, items = [], mobile }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 6 }
|
||||
: { xs: 12, md: 6 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<Grid size={size} key={index}>
|
||||
<FeatureItem item={item} index={index} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Feature;
|
||||
49
web/packages/ui/src/footer/Overlay.tsx
Normal file
49
web/packages/ui/src/footer/Overlay.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface OverlayProps {
|
||||
open: boolean;
|
||||
onClose: Dispatch<SetStateAction<boolean>>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Overlay: React.FC<OverlayProps> = ({ open, onClose, children }) => {
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1300,
|
||||
}}
|
||||
onClick={() => onClose(false)}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => onClose(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
zIndex: 1310,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box onClick={e => e.stopPropagation()}>{children}</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
816
web/packages/ui/src/footer/index.tsx
Normal file
816
web/packages/ui/src/footer/index.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Box, Divider, Stack, Link, alpha } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons';
|
||||
import Overlay from './Overlay';
|
||||
import { DocWidth } from '../constants';
|
||||
import { PROJECT_NAME } from '../constants';
|
||||
import { decodeBase64 } from '../utils';
|
||||
|
||||
interface DomainSocialMediaAccount {
|
||||
channel?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
text?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface CustomStyle {
|
||||
allow_theme_switching?: boolean;
|
||||
header_search_placeholder?: string;
|
||||
show_brand_info?: boolean;
|
||||
social_media_accounts?: DomainSocialMediaAccount[];
|
||||
footer_show_intro?: boolean;
|
||||
}
|
||||
|
||||
export interface BrandGroup {
|
||||
name: string;
|
||||
links: {
|
||||
name: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface FooterSetting {
|
||||
footer_style: 'simple' | 'complex';
|
||||
corp_name: string;
|
||||
icp: string;
|
||||
brand_name: string;
|
||||
brand_desc: string;
|
||||
brand_logo: string;
|
||||
brand_groups: BrandGroup[];
|
||||
}
|
||||
|
||||
const Footer = React.memo(
|
||||
({
|
||||
mobile,
|
||||
catalogWidth,
|
||||
showBrand = true,
|
||||
isDocPage = false,
|
||||
docWidth = 'full',
|
||||
customStyle,
|
||||
footerSetting,
|
||||
logo,
|
||||
}: {
|
||||
mobile?: boolean;
|
||||
catalogWidth?: number;
|
||||
showBrand?: boolean;
|
||||
isDocPage?: boolean;
|
||||
docWidth?: string;
|
||||
customStyle?: CustomStyle;
|
||||
footerSetting?: FooterSetting;
|
||||
logo?: string;
|
||||
}) => {
|
||||
const [curOverlayType, setCurOverlayType] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wechatData, setWechatData] = useState<{ src: string; text: string }>(
|
||||
{
|
||||
src: '',
|
||||
text: '',
|
||||
},
|
||||
);
|
||||
const [phoneData, setPhoneData] = useState<{ phone: string; text: string }>(
|
||||
{
|
||||
phone: '',
|
||||
text: '',
|
||||
},
|
||||
);
|
||||
|
||||
if (mobile)
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
id='footer'
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
zIndex: 1,
|
||||
px: 3,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
width: '100%',
|
||||
'.MuiLink-root': {
|
||||
color: 'inherit',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
pt={
|
||||
customStyle?.footer_show_intro
|
||||
? 5
|
||||
: (footerSetting?.brand_groups?.length || 0) > 0
|
||||
? 5
|
||||
: 0
|
||||
}
|
||||
>
|
||||
{customStyle?.footer_show_intro !== false && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={1}>
|
||||
{footerSetting?.brand_logo && (
|
||||
<img
|
||||
src={footerSetting.brand_logo}
|
||||
alt='PandaWiki'
|
||||
height={24}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '32px',
|
||||
fontSize: 24,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{footerSetting?.brand_name}
|
||||
</Box>
|
||||
</Stack>
|
||||
{footerSetting?.brand_desc && (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: 12,
|
||||
lineHeight: '26px',
|
||||
mt: 2,
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
>
|
||||
{footerSetting.brand_desc}
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction={'column'} gap={2.5} mt={2}>
|
||||
{customStyle?.social_media_accounts?.map(
|
||||
(account, index) => {
|
||||
return (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
key={index}
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
gap={1}
|
||||
onClick={() => {
|
||||
setCurOverlayType(account.channel || '');
|
||||
if (account.channel === 'phone') {
|
||||
setPhoneData({
|
||||
phone: account.phone || '',
|
||||
text: account.text || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
if (account.channel === 'wechat_oa') {
|
||||
setWechatData({
|
||||
src: account.icon || '',
|
||||
text: account.text || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{account.channel === 'wechat_oa' && (
|
||||
<IconWeixingongzhonghao
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
/>
|
||||
)}
|
||||
{account.channel === 'phone' && (
|
||||
<IconDianhua
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
></IconDianhua>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
lineHeight: '24px',
|
||||
fontSize: '14px',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
{account.channel === 'wechat_oa' &&
|
||||
(account?.text || account?.icon) && (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
alignItems={'center'}
|
||||
p={1.5}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
transform: 'translateY(-10px)',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
' 0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
gap={1}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.icon && (
|
||||
<img
|
||||
src={account.icon}
|
||||
width={83}
|
||||
height={83}
|
||||
></img>
|
||||
)}
|
||||
{account.text && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
maxWidth: '83px',
|
||||
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction={'row'} flexWrap={'wrap'} gap={2}>
|
||||
{footerSetting?.brand_groups?.map((group, idx) => (
|
||||
<Stack
|
||||
gap={1}
|
||||
key={group.name}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
width: 'calc(50% - 8px)',
|
||||
...(idx > 1 && {
|
||||
mt: 1,
|
||||
}),
|
||||
'& a:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
lineHeight: '24px',
|
||||
mb: 1,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Box>
|
||||
{group.links?.map(link => (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
key={link.name}
|
||||
>
|
||||
<Link
|
||||
href={link?.url || ''}
|
||||
target='_blank'
|
||||
key={link.name}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{!(
|
||||
customStyle?.footer_show_intro === false &&
|
||||
(footerSetting?.brand_groups || [])?.length === 0
|
||||
) && (
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.1),
|
||||
mt: 5,
|
||||
mb: 3,
|
||||
})}
|
||||
></Stack>
|
||||
)}
|
||||
|
||||
{!!footerSetting?.corp_name && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
{footerSetting?.corp_name}
|
||||
</Stack>
|
||||
)}
|
||||
{!!footerSetting?.icp && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
{footerSetting?.icp}
|
||||
</Stack>
|
||||
)}
|
||||
{customStyle?.show_brand_info !== false && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={'https://pandawiki.docs.baizhi.cloud/'}
|
||||
target='_blank'
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Box>{decodeBase64(PROJECT_NAME)}</Box>
|
||||
<img src={logo} alt='PandaWiki' width={0} height={0} />
|
||||
</Stack>
|
||||
</Link>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Overlay open={open} onClose={setOpen}>
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
width: '270px',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
boxShadow:
|
||||
'0px 4px 8px 0px ' + alpha(theme.palette.text.primary, 0.25),
|
||||
bgcolor: theme.palette.background.default,
|
||||
padding: 3,
|
||||
})}
|
||||
gap={2}
|
||||
>
|
||||
{curOverlayType === 'wechat_oa' && (
|
||||
<>
|
||||
<img
|
||||
src={wechatData?.src}
|
||||
width={'222px'}
|
||||
height={'222px'}
|
||||
></img>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
>
|
||||
{wechatData?.text}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{curOverlayType === 'phone' && (
|
||||
<>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
width: '100%',
|
||||
})}
|
||||
onClick={() => {
|
||||
window.location.href = `tel:${phoneData?.phone}`;
|
||||
}}
|
||||
>
|
||||
{phoneData?.phone}
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
width={'100%'}
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
gap={1}
|
||||
>
|
||||
<IconDianhua
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
></IconDianhua>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
>
|
||||
{phoneData?.text}
|
||||
</Box>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
id='footer'
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
sx={theme => ({
|
||||
px: mobile ? 3 : 5,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
zIndex: 1,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
'.MuiLink-root': {
|
||||
color: 'inherit',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
// ...(isDocPage &&
|
||||
// !mobile &&
|
||||
// docWidth !== 'full' && {
|
||||
// width: `calc(${DocWidth[docWidth as keyof typeof DocWidth].value}px + ${catalogWidth}px + 192px + 240px)`,
|
||||
// // width:
|
||||
// // DocWidth[docWidth as keyof typeof DocWidth].value +
|
||||
// // catalogWidth +
|
||||
// // 192 +
|
||||
// // 240,
|
||||
// // maxWidth: `calc(100% - 265px - 192px)`,
|
||||
// // maxWidth: `calc(100vw - 80px)`,
|
||||
// // ...(docWidth !== 'full' && {
|
||||
// // width: `calc(${catalogWidth}px + 192px + 264px + ${DocWidth[docWidth as keyof typeof DocWidth].value}px)`,
|
||||
// // }),
|
||||
// }),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
py={
|
||||
customStyle?.footer_show_intro
|
||||
? 6
|
||||
: (footerSetting?.brand_groups?.length || 0) > 0
|
||||
? 6
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={10}
|
||||
justifyContent={
|
||||
customStyle?.footer_show_intro === false
|
||||
? 'center'
|
||||
: 'flex-start'
|
||||
}
|
||||
>
|
||||
{customStyle?.footer_show_intro !== false && (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
sx={{ width: '30%', minWidth: 200 }}
|
||||
gap={3}
|
||||
>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={1}>
|
||||
{footerSetting?.brand_logo && (
|
||||
<img
|
||||
src={footerSetting.brand_logo}
|
||||
alt='PandaWiki'
|
||||
height={36}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '32px',
|
||||
fontSize: 24,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{footerSetting?.brand_name}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{footerSetting?.brand_desc && (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: 14,
|
||||
lineHeight: '26px',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
>
|
||||
{footerSetting.brand_desc}
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction={'column'} gap={'26px'}>
|
||||
{customStyle?.social_media_accounts?.map(
|
||||
(account, index) => {
|
||||
return (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
key={index}
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&:hover .popup': {
|
||||
display: 'flex !important',
|
||||
},
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
cursor: 'default',
|
||||
})}
|
||||
gap={1}
|
||||
>
|
||||
{account.channel === 'wechat_oa' && (
|
||||
<IconWeixingongzhonghao
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
></IconWeixingongzhonghao>
|
||||
)}
|
||||
{account.channel === 'phone' && (
|
||||
<IconDianhua
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
></IconDianhua>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
lineHeight: '24px',
|
||||
fontSize: '14px',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
{account.channel === 'wechat_oa' &&
|
||||
(account?.text || account?.icon) && (
|
||||
<Stack
|
||||
className={'popup'}
|
||||
direction={'column'}
|
||||
alignItems={'center'}
|
||||
p={1.5}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
' 0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
gap={1}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.icon && (
|
||||
<img
|
||||
src={account.icon}
|
||||
width={120}
|
||||
height={120}
|
||||
></img>
|
||||
)}
|
||||
{account.text && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
maxWidth: '120px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{account.channel === 'phone' && account?.phone && (
|
||||
<Stack
|
||||
className={'popup'}
|
||||
px={1.5}
|
||||
py={1}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
transform: 'translateY(-10px)',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
'0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.phone && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.phone}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction={'row'}
|
||||
width={'100%'}
|
||||
justifyContent={'flex-start'}
|
||||
flexWrap='wrap'
|
||||
>
|
||||
{footerSetting?.brand_groups?.map(group => (
|
||||
<Stack
|
||||
gap={1.5}
|
||||
key={group.name}
|
||||
sx={{
|
||||
flex: '0 0 33.33%',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
minWidth: '100px',
|
||||
'& a:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
lineHeight: '24px',
|
||||
mb: 1,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Box>
|
||||
{group.links?.map(link => (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
key={link.name}
|
||||
>
|
||||
<Link
|
||||
href={link?.url || ''}
|
||||
target='_blank'
|
||||
key={link.name}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{!(
|
||||
customStyle?.footer_show_intro === false &&
|
||||
(footerSetting?.brand_groups || [])?.length === 0
|
||||
) && (
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.1),
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
})}
|
||||
></Stack>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
height: 40,
|
||||
lineHeight: '40px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={1}
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
>
|
||||
{!!footerSetting?.corp_name && (
|
||||
<Box>{footerSetting?.corp_name}</Box>
|
||||
)}
|
||||
{!!footerSetting?.icp && (
|
||||
<>
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={theme => ({
|
||||
mx: 0.5,
|
||||
height: 16,
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
/>
|
||||
<Link href={`https://beian.miit.gov.cn/`} target='_blank'>
|
||||
{footerSetting?.icp}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{customStyle?.show_brand_info !== false && (
|
||||
<>
|
||||
{(footerSetting?.corp_name || footerSetting?.icp) && (
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={theme => ({
|
||||
mx: 0.5,
|
||||
height: 16,
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={'https://pandawiki.docs.baizhi.cloud/'}
|
||||
target='_blank'
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>{decodeBase64(PROJECT_NAME)}</Box>
|
||||
<img
|
||||
src={logo}
|
||||
alt='PandaWiki'
|
||||
width={0}
|
||||
height={0}
|
||||
/>
|
||||
</Stack>
|
||||
</Link>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
125
web/packages/ui/src/header/NavBtns.tsx
Normal file
125
web/packages/ui/src/header/NavBtns.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Box, Button, IconButton, Stack, Link } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconChahao, IconACaidan } from '@panda-wiki/icons';
|
||||
|
||||
export interface NavBtn {
|
||||
id: string;
|
||||
url: string;
|
||||
variant: 'contained' | 'outlined' | 'text';
|
||||
showIcon: boolean;
|
||||
icon: string;
|
||||
text: string;
|
||||
target: '_blank' | '_self';
|
||||
}
|
||||
|
||||
interface NavBtnsProps {
|
||||
logo?: string;
|
||||
title?: string;
|
||||
btns?: NavBtn[];
|
||||
homePath: string;
|
||||
}
|
||||
|
||||
const NavBtns = ({ logo, title, btns, homePath }: NavBtnsProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<IconACaidan />
|
||||
</IconButton>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
transform: 'translateX(100vw) translateY(-100vh)',
|
||||
...(open && {
|
||||
bgcolor: 'background.default',
|
||||
transform: 'translateX(0) translateY(0)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Link href={homePath}>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ py: '14px', cursor: 'pointer', ml: 3 }}
|
||||
>
|
||||
{logo && <img src={logo} alt='logo' width={32} />}
|
||||
<Box sx={{ fontSize: 18 }}>{title}</Box>
|
||||
</Stack>
|
||||
</Link>
|
||||
<Stack gap={4} sx={{ px: 3, mt: 4 }}>
|
||||
{btns?.map((item, index) => (
|
||||
<Link key={index} href={item.url} target={item.target}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={item.variant}
|
||||
startIcon={
|
||||
item.showIcon && item.icon ? (
|
||||
<img src={item.icon} alt='logo' width={36} height={36} />
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
justifyContent: 'flex-start',
|
||||
height: '60px',
|
||||
px: 4,
|
||||
gap: 3,
|
||||
fontSize: 18,
|
||||
'& .MuiButton-startIcon': {
|
||||
ml: 0,
|
||||
mr: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: 'text.primary',
|
||||
width: 40,
|
||||
height: 40,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<IconChahao />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBtns;
|
||||
434
web/packages/ui/src/header/index.tsx
Normal file
434
web/packages/ui/src/header/index.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { IconSousuo } from '@panda-wiki/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import NavBtns, { NavBtn } from './NavBtns';
|
||||
|
||||
// 检测平台类型
|
||||
const isMac = () => /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
const getKeyboardShortcut = () =>
|
||||
typeof navigator !== 'undefined' ? (isMac() ? '⌘K' : 'Ctrl+K') : '';
|
||||
|
||||
interface SearchSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: 'recent' | 'suggestion' | 'trending';
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
isDocPage?: boolean;
|
||||
mobile?: boolean;
|
||||
docWidth?: string;
|
||||
catalogWidth?: number;
|
||||
logo?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
showSearch?: boolean;
|
||||
onSearch?: (value?: string, type?: 'search' | 'chat') => void;
|
||||
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
|
||||
btns?: NavBtn[];
|
||||
children?: React.ReactNode;
|
||||
onQaClick?: () => void;
|
||||
homePath?: string;
|
||||
}
|
||||
const Header = React.memo(
|
||||
({
|
||||
isDocPage = false,
|
||||
mobile = false,
|
||||
docWidth = 'full',
|
||||
homePath = '/',
|
||||
catalogWidth = 0,
|
||||
logo = '',
|
||||
placeholder = '搜索',
|
||||
title,
|
||||
showSearch = true,
|
||||
onSearch,
|
||||
onSearchSuggestions,
|
||||
btns,
|
||||
children,
|
||||
onQaClick,
|
||||
}: HeaderProps) => {
|
||||
const [ctrlKShortcut, setCtrlKShortcut] = useState('');
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
|
||||
useEffect(() => {
|
||||
setCtrlKShortcut(getKeyboardShortcut());
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
// 全局键盘事件监听:⌘K (Mac) 或 Ctrl+K (Windows/Linux)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Mac: Command + K, Windows/Linux: Ctrl + K
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
onQaClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
sx={{
|
||||
transition: 'left 0.2s ease-in-out',
|
||||
position: 'sticky',
|
||||
zIndex: 101,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
...(mobile && {
|
||||
left: 0,
|
||||
}),
|
||||
pl: mobile ? 3 : 5,
|
||||
pr: mobile ? 1.5 : 5,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
justifyContent='space-between'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
// ...(isDocPage &&
|
||||
// !mobile &&
|
||||
// docWidth !== 'full' && {
|
||||
// width: `calc(${DocWidth[docWidth as keyof typeof DocWidth].value}px + ${catalogWidth}px + 192px + 240px)`,
|
||||
// }),
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Link
|
||||
href={homePath}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
color: 'text.primary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
...(mobile && {
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt='logo'
|
||||
height={36}
|
||||
style={{
|
||||
flexShrink: mobile ? 1 : 0,
|
||||
maxWidth: mobile ? '100%' : 'none',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: mobile ? 16 : 20,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
</Link>
|
||||
</Stack>
|
||||
{showSearch &&
|
||||
(mobile ? (
|
||||
// 移动端:显示搜索图标按钮
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
>
|
||||
<IconButton
|
||||
size='small'
|
||||
sx={{ width: 40, height: 40, color: 'text.primary' }}
|
||||
onClick={() => onQaClick?.()}
|
||||
>
|
||||
<IconSousuo sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
// 桌面端:显示搜索框
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
focused={false}
|
||||
onClick={() => onQaClick?.()}
|
||||
sx={{
|
||||
flex: 1,
|
||||
maxWidth: '500px',
|
||||
minWidth: '220px',
|
||||
bgcolor: 'background.paper3',
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
'& .MuiInputBase-input': {
|
||||
fontSize: 14,
|
||||
lineHeight: '19.5px',
|
||||
height: '19.5px',
|
||||
fontFamily: 'Mono',
|
||||
cursor: 'pointer',
|
||||
py: '10.5px',
|
||||
pl: 1,
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
pr: '12px',
|
||||
pl: '12px',
|
||||
'& fieldset': {
|
||||
borderRadius: '10px',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
'&:hover .ai-qa-button-wrapper': {
|
||||
background:
|
||||
'linear-gradient(135deg, #B27BFB 0%, #5A44FA 100%)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<IconSousuo
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
color: 'text.tertiary',
|
||||
fontSize: 20,
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
endAdornment: (
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
{ctrlKShortcut}
|
||||
</Box>
|
||||
<Box
|
||||
className='ai-qa-button-wrapper'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '6px',
|
||||
padding: '1px',
|
||||
background:
|
||||
'linear-gradient(135deg, #B27BFB 0%, #5A44FA 100%)',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
minWidth: 'auto',
|
||||
px: 1,
|
||||
py: '2px',
|
||||
fontSize: 12,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'background.default',
|
||||
color: 'text.primary',
|
||||
boxShadow:
|
||||
'0px 1px 2px 0px rgba(145,158,171,0.16)',
|
||||
}}
|
||||
>
|
||||
智能问答
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!mobile && btns && btns.length > 0 && (
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{btns.slice(0, Math.min(2, btns.length)).map((item, index) => (
|
||||
<Link key={index} href={item.url} target={item.target}>
|
||||
<Button
|
||||
variant={item.variant}
|
||||
startIcon={
|
||||
item.showIcon && item.icon ? (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt='logo'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sx={theme => ({
|
||||
px: 3.5,
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'none',
|
||||
boxSizing: 'border-box',
|
||||
height: 40,
|
||||
...(item.variant === 'outlined' && {
|
||||
borderWidth: 2,
|
||||
}),
|
||||
})}
|
||||
>
|
||||
<Box sx={{ lineHeight: '24px', fontSize: 16 }}>
|
||||
{item.text}
|
||||
</Box>
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{btns.length > 2 && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleMenuClick}
|
||||
sx={theme => ({
|
||||
width: 40,
|
||||
height: 40,
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={menuOpen}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 180,
|
||||
boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{btns.slice(2).map((item, index) => (
|
||||
<MenuItem
|
||||
key={index + 2}
|
||||
onClick={handleMenuClose}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{item.showIcon && item.icon && (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt='logo'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ fontSize: 16 }}>{item.text}</Box>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{mobile && (
|
||||
<NavBtns
|
||||
logo={logo}
|
||||
title={title}
|
||||
btns={btns}
|
||||
homePath={homePath}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Header;
|
||||
462
web/packages/ui/src/hooks/useGsapAnimation.tsx
Normal file
462
web/packages/ui/src/hooks/useGsapAnimation.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
// 文字渐入动画 hook
|
||||
export const useTextAnimation = (
|
||||
delay: number = 0,
|
||||
threshold: number = 0.1,
|
||||
) => {
|
||||
const textRef = useRef<HTMLHeadingElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || hasAnimated) return;
|
||||
|
||||
const text = textRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px', // 提前 50px 触发
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(text);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || !isVisible) return;
|
||||
|
||||
const text = textRef.current;
|
||||
|
||||
// 创建文字分割效果 - 支持表情符号
|
||||
const splitText = (element: HTMLElement) => {
|
||||
const text = element.textContent || '';
|
||||
const chars = Array.from(text).map(char => {
|
||||
return char === ' ' ? ' ' : char;
|
||||
});
|
||||
|
||||
element.innerHTML = chars
|
||||
.map(
|
||||
char =>
|
||||
`<span style="display: inline-block; opacity: 0; transform: translateY(20px);">${char}</span>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return element.querySelectorAll('span');
|
||||
};
|
||||
|
||||
// 分割文字
|
||||
const chars = splitText(text);
|
||||
|
||||
// 设置初始状态
|
||||
gsap.set(chars, {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
rotationX: -90,
|
||||
});
|
||||
|
||||
// 根据文字长度动态调整动画参数
|
||||
const textLength = chars.length;
|
||||
const duration = Math.min(
|
||||
0.5,
|
||||
Math.max(0.2, 0.5 - (textLength - 10) * 0.015),
|
||||
); // 长文本减少单字符时间
|
||||
const stagger = Math.min(
|
||||
0.03,
|
||||
Math.max(0.01, 0.03 - (textLength - 20) * 0.0008),
|
||||
); // 长文本减少间隔时间
|
||||
const easeStrength = Math.min(
|
||||
1.4,
|
||||
Math.max(1.0, 1.4 - (textLength - 15) * 0.015),
|
||||
); // 长文本减少回弹强度
|
||||
|
||||
// 创建动画时间线
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
// 逐个字符动画
|
||||
tl.to(chars, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotationX: 0,
|
||||
duration,
|
||||
stagger,
|
||||
ease: `back.out(${easeStrength})`,
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
tl.kill();
|
||||
};
|
||||
}, [isVisible, delay]);
|
||||
|
||||
return textRef;
|
||||
};
|
||||
|
||||
// 文字淡入动画 hook(更简单的版本)
|
||||
export const useFadeInText = (delay: number = 0, threshold: number = 0.1) => {
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || hasAnimated) return;
|
||||
|
||||
const text = textRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(text);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || !isVisible) return;
|
||||
|
||||
const text = textRef.current;
|
||||
|
||||
// 设置初始状态
|
||||
gsap.set(text, {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
});
|
||||
|
||||
// 创建动画
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
tl.to(text, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
};
|
||||
}, [isVisible, delay]);
|
||||
|
||||
return textRef;
|
||||
};
|
||||
|
||||
// 文字打字机效果 hook
|
||||
export const useTypewriterText = (
|
||||
delay: number = 0,
|
||||
speed: number = 0.05,
|
||||
threshold: number = 0.1,
|
||||
) => {
|
||||
const textRef = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || hasAnimated) return;
|
||||
|
||||
const text = textRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(text);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current || !isVisible) return;
|
||||
|
||||
const text = textRef.current;
|
||||
const originalText = text.textContent || '';
|
||||
|
||||
// 清空文字
|
||||
text.textContent = '';
|
||||
|
||||
// 创建光标元素
|
||||
const cursor = document.createElement('span');
|
||||
cursor.textContent = '|';
|
||||
cursor.style.opacity = '1';
|
||||
cursor.style.animation = 'blink 1s infinite';
|
||||
|
||||
// 添加光标样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
text.appendChild(cursor);
|
||||
|
||||
// 打字机动画
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
for (let i = 0; i <= originalText.length; i++) {
|
||||
tl.to(text, {
|
||||
duration: speed,
|
||||
onUpdate: () => {
|
||||
text.textContent = originalText.slice(0, i);
|
||||
text.appendChild(cursor);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 移除光标
|
||||
tl.call(() => {
|
||||
cursor.remove();
|
||||
});
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
style.remove();
|
||||
};
|
||||
}, [isVisible, delay, speed]);
|
||||
|
||||
return textRef;
|
||||
};
|
||||
|
||||
// 卡片渐入动画 hook
|
||||
export const useCardFadeInAnimation = (
|
||||
delay: number = 0,
|
||||
threshold: number = 0.1,
|
||||
) => {
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || hasAnimated) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(card);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || !isVisible) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 设置初始状态
|
||||
gsap.set(card, {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
});
|
||||
|
||||
// 创建动画
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
tl.to(card, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.4,
|
||||
ease: 'back.out(1.4)',
|
||||
});
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
};
|
||||
}, [isVisible, delay]);
|
||||
|
||||
return cardRef;
|
||||
};
|
||||
|
||||
export const useCardScaleAnimation = ({
|
||||
delay = 0,
|
||||
threshold = 0.1,
|
||||
duration = 0.4,
|
||||
}: {
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || hasAnimated) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(card);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || !isVisible) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 设置初始状态
|
||||
gsap.set(card, {
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
});
|
||||
|
||||
// 创建动画
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
tl.to(card, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration,
|
||||
});
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
};
|
||||
}, [isVisible, delay]);
|
||||
|
||||
return cardRef;
|
||||
};
|
||||
|
||||
export const useCardAnimation = ({
|
||||
delay = 0,
|
||||
threshold = 0.1,
|
||||
initial,
|
||||
to,
|
||||
}: {
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
initial: GSAPTweenVars;
|
||||
to: GSAPTweenVars;
|
||||
}) => {
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || hasAnimated) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 创建 Intersection Observer
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
setIsVisible(true);
|
||||
setHasAnimated(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(card);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, hasAnimated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardRef.current || !isVisible) return;
|
||||
|
||||
const card = cardRef.current;
|
||||
|
||||
// 设置初始状态
|
||||
gsap.set(card, initial);
|
||||
|
||||
// 创建动画
|
||||
const tl = gsap.timeline({ delay });
|
||||
|
||||
tl.to(card, to);
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
};
|
||||
}, [isVisible, delay]);
|
||||
|
||||
return cardRef;
|
||||
};
|
||||
136
web/packages/ui/src/imgText/index.tsx
Normal file
136
web/packages/ui/src/imgText/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { styled, alpha, Stack, Box } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation';
|
||||
|
||||
interface ImgTextProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
direction?: 'row' | 'row-reverse';
|
||||
item: {
|
||||
name: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
};
|
||||
}
|
||||
const StyledImgTextItem = styled(Stack)(({ theme }) => ({}));
|
||||
|
||||
export const StyledImgTextItemImg = styled('img')(({ theme }) => ({
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
flex: '0 0 auto',
|
||||
borderRadius: '10px',
|
||||
}));
|
||||
|
||||
const StyledImgTextItemTitle = styled('h3')(({ theme }) => ({
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
const StyledImgTextItemSummary = styled('div')(({ theme }) => ({
|
||||
fontSize: 16,
|
||||
fontWeight: 400,
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
}));
|
||||
|
||||
const ImgText: React.FC<ImgTextProps> = React.memo(
|
||||
({ title, mobile, item, direction = 'row' }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: 6 }
|
||||
: { xs: 12, md: 6 };
|
||||
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
const cardLeftAnimation = useMemo(
|
||||
() => ({
|
||||
initial: { opacity: 0, x: -250 },
|
||||
to: { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const cardRightAnimation = useMemo(
|
||||
() => ({
|
||||
initial: { opacity: 0, x: 250 },
|
||||
to: { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const cardLeftRef = useCardAnimation(cardLeftAnimation);
|
||||
const cardRightRef = useCardAnimation(cardRightAnimation);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<StyledImgTextItem
|
||||
gap={mobile ? 4 : { xs: 4, sm: 6, md: 16 }}
|
||||
direction={
|
||||
mobile
|
||||
? 'column-reverse'
|
||||
: {
|
||||
xs: 'column-reverse',
|
||||
md: direction,
|
||||
}
|
||||
}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<Box
|
||||
sx={{ width: '100%' }}
|
||||
ref={cardLeftRef as React.Ref<HTMLDivElement>}
|
||||
>
|
||||
<StyledImgTextItemImg src={item.url} alt={item.name} />
|
||||
</Box>
|
||||
<Stack
|
||||
gap={1}
|
||||
sx={{ width: '100%' }}
|
||||
ref={cardRightRef as React.Ref<HTMLDivElement>}
|
||||
alignItems={
|
||||
mobile
|
||||
? 'flex-start'
|
||||
: direction === 'row'
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}
|
||||
>
|
||||
<StyledImgTextItemTitle
|
||||
sx={{
|
||||
textAlign: mobile
|
||||
? 'left'
|
||||
: direction === 'row'
|
||||
? 'left'
|
||||
: 'right',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</StyledImgTextItemTitle>
|
||||
<StyledImgTextItemSummary
|
||||
sx={{
|
||||
textAlign: mobile
|
||||
? 'left'
|
||||
: direction === 'row'
|
||||
? 'left'
|
||||
: 'right',
|
||||
}}
|
||||
>
|
||||
{item.desc}
|
||||
</StyledImgTextItemSummary>
|
||||
</Stack>
|
||||
</StyledImgTextItem>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ImgText;
|
||||
28
web/packages/ui/src/index.tsx
Normal file
28
web/packages/ui/src/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
export { default as Footer } from './footer';
|
||||
export { default as WelcomeFooter } from './welcomeFooter';
|
||||
export { default as Header } from './header';
|
||||
export { default as WelcomeHeader } from './welcomeHeader';
|
||||
export { default as Banner } from './banner';
|
||||
export { default as BasicDoc } from './basicDoc';
|
||||
export { default as DirDoc } from './dirDoc';
|
||||
export { default as NavDoc } from './navDoc';
|
||||
export { default as SimpleDoc } from './simpleDoc';
|
||||
export { default as Carousel } from './carousel';
|
||||
export { default as Faq } from './faq';
|
||||
export { default as Metrics } from './metrics';
|
||||
export { default as Text } from './text';
|
||||
export { default as Case } from './case';
|
||||
export { default as ImgText } from './imgText';
|
||||
export { default as Feature } from './feature';
|
||||
export { default as Comment } from './comment';
|
||||
export { default as Question } from './question';
|
||||
export { default as BlockGrid } from './blockGrid';
|
||||
|
||||
// 导出动画 hooks
|
||||
export {
|
||||
useTextAnimation,
|
||||
useFadeInText,
|
||||
useTypewriterText,
|
||||
useCardFadeInAnimation,
|
||||
useCardAnimation,
|
||||
} from './hooks/useGsapAnimation';
|
||||
100
web/packages/ui/src/metrics/index.tsx
Normal file
100
web/packages/ui/src/metrics/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, alpha, Stack } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface MetricsProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
items?: {
|
||||
name: string;
|
||||
number: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const StyledMetricsContainer = styled('div')(({ theme }) => ({
|
||||
width: '100%',
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(3),
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
}));
|
||||
|
||||
const StyledMetricsItemNumber = styled('div')(({ theme }) => ({
|
||||
fontSize: 48,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.primary,
|
||||
userSelect: 'none',
|
||||
transition: 'color 0.2s ease',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledMetricsItemTitle = styled('span')(({ theme }) => ({
|
||||
fontSize: 16,
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
userSelect: 'none',
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const MetricsItem: React.FC<{
|
||||
item: {
|
||||
name: string;
|
||||
number: string;
|
||||
};
|
||||
index: number;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size} key={index}>
|
||||
<Stack
|
||||
ref={cardRef as React.Ref<HTMLDivElement>}
|
||||
gap={1}
|
||||
alignItems='center'
|
||||
sx={{ opacity: 0 }}
|
||||
>
|
||||
<StyledMetricsItemNumber className='metrics-item-number'>
|
||||
{item.number}
|
||||
</StyledMetricsItemNumber>
|
||||
|
||||
<StyledMetricsItemTitle>{item.name}</StyledMetricsItemTitle>
|
||||
</Stack>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
const Metrics: React.FC<MetricsProps> = React.memo(
|
||||
({ title, items = [], mobile }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean'
|
||||
? mobile
|
||||
? 12
|
||||
: { xs: 12, md: items.length > 3 ? 4 : 12 / items.length }
|
||||
: { xs: 12, md: items.length > 3 ? 4 : 12 / items.length };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<StyledMetricsContainer>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<MetricsItem key={index} item={item} index={index} size={size} />
|
||||
))}
|
||||
</Grid>
|
||||
</StyledMetricsContainer>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Metrics;
|
||||
196
web/packages/ui/src/navDoc/index.tsx
Normal file
196
web/packages/ui/src/navDoc/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, Box, Button, alpha } from '@mui/material';
|
||||
import {
|
||||
StyledTopicBox,
|
||||
StyledTopicTitle,
|
||||
StyledEllipsis,
|
||||
} from '../component/styledCommon';
|
||||
import { IconWenjianjia, IconWenjian, IconMulushu } from '@panda-wiki/icons';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface NavDocProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
bgColor?: string;
|
||||
titleColor?: string;
|
||||
items?: {
|
||||
nav_id: string;
|
||||
nav_name: string;
|
||||
emoji?: string;
|
||||
list: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
type?: number;
|
||||
position?: number;
|
||||
}[];
|
||||
}[];
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
const StyledNavDocItem = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(3.5, 2.5, 2),
|
||||
borderRadius: '8px',
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0px 10px 20px 0px rgba(0,0,5,0.2)',
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
width: '100%',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
/** 顶部目录大图标区域 */
|
||||
const StyledNavDocItemIcon = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '4px',
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
/** 目录标题行 */
|
||||
const StyledNavDocItemTitle = styled('h3')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.text.primary,
|
||||
gap: theme.spacing(1),
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
/** 子文件/文件夹列表容器 */
|
||||
const StyledNavDocItemFiles = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flex: '1 0 auto',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.spacing(2),
|
||||
marginLeft: theme.spacing(0.5),
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
height: 129,
|
||||
width: '100%',
|
||||
lineHeight: 1.5,
|
||||
}));
|
||||
|
||||
/** 单条子节点链接 */
|
||||
const StyledNavDocItemFile = styled('a')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
color: '#717572',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const NavDocItem: React.FC<{
|
||||
item: NonNullable<NavDocProps['items']>[number];
|
||||
index: number;
|
||||
basePath: string;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, basePath, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size}>
|
||||
<StyledNavDocItem ref={cardRef as React.Ref<HTMLDivElement>}>
|
||||
{/* 图标 + 目录名称(同一行) */}
|
||||
<StyledNavDocItemTitle>
|
||||
<StyledNavDocItemIcon>
|
||||
<IconMulushu sx={{ fontSize: 20, color: 'primary.main' }} />
|
||||
</StyledNavDocItemIcon>
|
||||
<StyledEllipsis>{item.nav_name}</StyledEllipsis>
|
||||
</StyledNavDocItemTitle>
|
||||
|
||||
{/* 下级文件/文件夹列表(最多展示 4 条) */}
|
||||
<StyledNavDocItemFiles>
|
||||
{item.list?.slice(0, 4).map(it => (
|
||||
<StyledNavDocItemFile
|
||||
key={it.id}
|
||||
href={`${basePath}/node/${it.id}`}
|
||||
target='_blank'
|
||||
>
|
||||
{it.emoji ? (
|
||||
<Box sx={{ fontSize: 14, lineHeight: 1, flexShrink: 0 }}>
|
||||
{it.emoji}
|
||||
</Box>
|
||||
) : it.type === 1 ? (
|
||||
<IconWenjianjia sx={{ fontSize: 14, flexShrink: 0 }} />
|
||||
) : (
|
||||
<IconWenjian sx={{ fontSize: 14, flexShrink: 0 }} />
|
||||
)}
|
||||
<StyledEllipsis>{it.name}</StyledEllipsis>
|
||||
</StyledNavDocItemFile>
|
||||
))}
|
||||
</StyledNavDocItemFiles>
|
||||
|
||||
{/* 查看更多按钮,跳转到目录页面 */}
|
||||
<Button
|
||||
href={item.list?.[0]?.id ? `${basePath}/node/${item.list[0].id}` : ''}
|
||||
target='_blank'
|
||||
sx={{ gap: 1, alignSelf: 'flex-end' }}
|
||||
variant='text'
|
||||
color='primary'
|
||||
>
|
||||
查看更多
|
||||
</Button>
|
||||
</StyledNavDocItem>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
NavDocItem.displayName = 'NavDocItem';
|
||||
|
||||
const NavDoc: React.FC<NavDocProps> = React.memo(
|
||||
({ title, items = [], mobile, basePath = '' }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean' ? (mobile ? 12 : 4) : { xs: 12, md: 4 };
|
||||
|
||||
// 标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<NavDocItem
|
||||
key={item.nav_id || index}
|
||||
item={item}
|
||||
index={index}
|
||||
basePath={basePath}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NavDoc.displayName = 'NavDoc';
|
||||
|
||||
export default NavDoc;
|
||||
89
web/packages/ui/src/question/index.tsx
Normal file
89
web/packages/ui/src/question/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Stack, alpha } from '@mui/material';
|
||||
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
|
||||
import { IconWenhao } from '@panda-wiki/icons';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface QuestionProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
onSearch: (question: string) => void;
|
||||
items?: {
|
||||
question: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const StyledItem = styled('div')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
padding: theme.spacing(3, 4),
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
color: theme.palette.primary.main,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
},
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: 'pointer',
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledItemTitle = styled('span')(({ theme }) => ({
|
||||
fontSize: 20,
|
||||
fontWeight: 400,
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const Item: React.FC<{
|
||||
item: {
|
||||
question: string;
|
||||
};
|
||||
onSearch: (question: string) => void;
|
||||
index: number;
|
||||
}> = React.memo(({ item, index, onSearch }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<StyledItem
|
||||
ref={cardRef as React.Ref<HTMLDivElement>}
|
||||
onClick={() => onSearch(item.question)}
|
||||
>
|
||||
<IconWenhao sx={{ color: 'primary.main', fontSize: 20 }} />
|
||||
<StyledItemTitle>{item.question}</StyledItemTitle>
|
||||
</StyledItem>
|
||||
);
|
||||
});
|
||||
|
||||
const Question: React.FC<QuestionProps> = React.memo(
|
||||
({ title, items = [], onSearch }) => {
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Stack gap={3} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<Item key={index} item={item} index={index} onSearch={onSearch} />
|
||||
))}
|
||||
</Stack>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Question;
|
||||
120
web/packages/ui/src/simpleDoc/index.tsx
Normal file
120
web/packages/ui/src/simpleDoc/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { styled, Grid, Box, alpha } from '@mui/material';
|
||||
import {
|
||||
StyledTopicInner,
|
||||
StyledTopicContainer,
|
||||
StyledTopicBox,
|
||||
StyledTopicTitle,
|
||||
StyledEllipsis,
|
||||
} from '../component/styledCommon';
|
||||
import IconWenjian from '@panda-wiki/icons/IconWenjian';
|
||||
import ArrowForwardIosRoundedIcon from '@mui/icons-material/ArrowForwardIosRounded';
|
||||
import {
|
||||
useFadeInText,
|
||||
useCardFadeInAnimation,
|
||||
} from '../hooks/useGsapAnimation';
|
||||
|
||||
interface SimpleDocProps {
|
||||
mobile?: boolean;
|
||||
title?: string;
|
||||
bgColor?: string;
|
||||
titleColor?: string;
|
||||
items?: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
}[];
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
const StyledSimpleDocItem = styled('a')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(3.5, 2.5),
|
||||
borderRadius: '8px',
|
||||
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
color: theme.palette.primary.main,
|
||||
boxShadow: '0px 10px 20px 0px rgba(0,0,5,0.2)',
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const StyledSimpleDocItemTitle = styled('h3')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
// 单个卡片组件,带动画效果
|
||||
const SimpleDocItem: React.FC<{
|
||||
item: any;
|
||||
index: number;
|
||||
basePath: string;
|
||||
size: any;
|
||||
}> = React.memo(({ item, index, basePath, size }) => {
|
||||
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
|
||||
|
||||
return (
|
||||
<Grid size={size} key={index}>
|
||||
<StyledSimpleDocItem
|
||||
ref={cardRef as React.Ref<HTMLAnchorElement>}
|
||||
href={`${basePath}/node/${item.id}`}
|
||||
target='_blank'
|
||||
>
|
||||
<StyledSimpleDocItemTitle>
|
||||
{item.emoji ? (
|
||||
<Box>{item.emoji}</Box>
|
||||
) : (
|
||||
<IconWenjian sx={{ fontSize: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
<StyledEllipsis sx={{ flex: 1 }}>{item.name}</StyledEllipsis>
|
||||
<ArrowForwardIosRoundedIcon
|
||||
sx={{ fontSize: 14, color: 'primary.main' }}
|
||||
/>
|
||||
</StyledSimpleDocItemTitle>
|
||||
</StyledSimpleDocItem>
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
const SimpleDoc: React.FC<SimpleDocProps> = React.memo(
|
||||
({ title, items = [], mobile, basePath = '' }) => {
|
||||
const size =
|
||||
typeof mobile === 'boolean' ? (mobile ? 12 : 4) : { xs: 12, md: 4 };
|
||||
|
||||
// 添加标题淡入动画
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
|
||||
return (
|
||||
<StyledTopicBox>
|
||||
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
|
||||
<Grid container spacing={2} sx={{ width: '100%' }}>
|
||||
{items.map((item, index) => (
|
||||
<SimpleDocItem
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
basePath={basePath}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</StyledTopicBox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SimpleDoc;
|
||||
21
web/packages/ui/src/text/index.tsx
Normal file
21
web/packages/ui/src/text/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { StyledTopicBox } from '../component/styledCommon';
|
||||
import { useFadeInText } from '../hooks/useGsapAnimation';
|
||||
|
||||
interface TextProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Text: React.FC<TextProps> = ({ title }) => {
|
||||
const titleRef = useFadeInText(0.2, 0.1);
|
||||
return (
|
||||
<StyledTopicBox
|
||||
ref={titleRef}
|
||||
sx={{ fontSize: 60, color: 'text.primary', fontWeight: 700 }}
|
||||
>
|
||||
{title}
|
||||
</StyledTopicBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Text;
|
||||
31
web/packages/ui/src/utils.ts
Normal file
31
web/packages/ui/src/utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const decodeBase64 = (text: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 客户端逻辑 (使用 atob + 字节转换来处理 UTF-8)
|
||||
try {
|
||||
// 1. atob 解码 Base64 字符串为 Latin-1 字符串 (包含原始字节数据)
|
||||
const binaryString = window.atob(text);
|
||||
|
||||
// 2. 将 Latin-1 字符串转换为字节数组 (Uint8Array)
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// 3. 使用 TextDecoder (浏览器 API) 将字节数组转换为 UTF-8 字符串
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
} catch (e) {
|
||||
console.error('Client-side Base64/UTF-8 decoding failed:', e);
|
||||
return text; // 解码失败时返回原始文本
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端逻辑 (Node.js/Next.js SSR)
|
||||
try {
|
||||
const buff = Buffer.from(text, 'base64');
|
||||
return buff.toString('utf-8');
|
||||
} catch (e) {
|
||||
console.error('Server-side Base64 decoding failed:', e);
|
||||
return text;
|
||||
}
|
||||
};
|
||||
49
web/packages/ui/src/welcomeFooter/Overlay.tsx
Normal file
49
web/packages/ui/src/welcomeFooter/Overlay.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface OverlayProps {
|
||||
open: boolean;
|
||||
onClose: Dispatch<SetStateAction<boolean>>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Overlay: React.FC<OverlayProps> = ({ open, onClose, children }) => {
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1300,
|
||||
}}
|
||||
onClick={() => onClose(false)}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => onClose(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
zIndex: 1310,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box onClick={e => e.stopPropagation()}>{children}</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overlay;
|
||||
791
web/packages/ui/src/welcomeFooter/index.tsx
Normal file
791
web/packages/ui/src/welcomeFooter/index.tsx
Normal file
@@ -0,0 +1,791 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Box, Divider, Stack, Link, alpha } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons';
|
||||
import Overlay from './Overlay';
|
||||
import { decodeBase64 } from '../utils';
|
||||
import { PROJECT_NAME } from '../constants';
|
||||
|
||||
interface DomainSocialMediaAccount {
|
||||
channel?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
text?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface CustomStyle {
|
||||
allow_theme_switching?: boolean;
|
||||
header_search_placeholder?: string;
|
||||
show_brand_info?: boolean;
|
||||
social_media_accounts?: DomainSocialMediaAccount[];
|
||||
footer_show_intro?: boolean;
|
||||
}
|
||||
|
||||
export interface BrandGroup {
|
||||
name: string;
|
||||
links: {
|
||||
name: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface FooterSetting {
|
||||
footer_style: 'simple' | 'complex';
|
||||
corp_name: string;
|
||||
icp: string;
|
||||
brand_name: string;
|
||||
brand_desc: string;
|
||||
brand_logo: string;
|
||||
brand_groups: BrandGroup[];
|
||||
}
|
||||
|
||||
const Footer = React.memo(
|
||||
({
|
||||
mobile,
|
||||
catalogWidth,
|
||||
showBrand = true,
|
||||
isDocPage = false,
|
||||
docWidth = 'full',
|
||||
customStyle,
|
||||
footerSetting,
|
||||
logo,
|
||||
}: {
|
||||
mobile?: boolean;
|
||||
catalogWidth?: number;
|
||||
showBrand?: boolean;
|
||||
isDocPage?: boolean;
|
||||
docWidth?: string;
|
||||
customStyle?: CustomStyle;
|
||||
footerSetting?: FooterSetting;
|
||||
logo?: string;
|
||||
}) => {
|
||||
const [curOverlayType, setCurOverlayType] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [wechatData, setWechatData] = useState<{ src: string; text: string }>(
|
||||
{
|
||||
src: '',
|
||||
text: '',
|
||||
},
|
||||
);
|
||||
const [phoneData, setPhoneData] = useState<{ phone: string; text: string }>(
|
||||
{
|
||||
phone: '',
|
||||
text: '',
|
||||
},
|
||||
);
|
||||
|
||||
if (mobile)
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
id='footer'
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
zIndex: 1,
|
||||
px: 3,
|
||||
width: '100%',
|
||||
'.MuiLink-root': {
|
||||
color: 'inherit',
|
||||
},
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
pt={
|
||||
customStyle?.footer_show_intro
|
||||
? 5
|
||||
: (footerSetting?.brand_groups?.length || 0) > 0
|
||||
? 5
|
||||
: 0
|
||||
}
|
||||
>
|
||||
{customStyle?.footer_show_intro !== false && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={1}>
|
||||
{footerSetting?.brand_logo && (
|
||||
<img
|
||||
src={footerSetting.brand_logo}
|
||||
alt='PandaWiki'
|
||||
height={24}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '32px',
|
||||
fontSize: 24,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{footerSetting?.brand_name}
|
||||
</Box>
|
||||
</Stack>
|
||||
{footerSetting?.brand_desc && (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: 12,
|
||||
lineHeight: '26px',
|
||||
mt: 2,
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
>
|
||||
{footerSetting.brand_desc}
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction={'column'} gap={2.5} mt={2}>
|
||||
{customStyle?.social_media_accounts?.map(
|
||||
(account, index) => {
|
||||
return (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
key={index}
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
gap={1}
|
||||
onClick={() => {
|
||||
setCurOverlayType(account.channel || '');
|
||||
if (account.channel === 'phone') {
|
||||
setPhoneData({
|
||||
phone: account.phone || '',
|
||||
text: account.text || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
if (account.channel === 'wechat_oa') {
|
||||
setWechatData({
|
||||
src: account.icon || '',
|
||||
text: account.text || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{account.channel === 'wechat_oa' && (
|
||||
<IconWeixingongzhonghao
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
/>
|
||||
)}
|
||||
{account.channel === 'phone' && (
|
||||
<IconDianhua
|
||||
sx={{ fontSize: '20px', color: 'inherit' }}
|
||||
></IconDianhua>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
lineHeight: '24px',
|
||||
fontSize: '12px',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
{account.channel === 'wechat_oa' &&
|
||||
(account?.text || account?.icon) && (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
alignItems={'center'}
|
||||
p={1.5}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
' 0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
gap={1}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.icon && (
|
||||
<img
|
||||
src={account.icon}
|
||||
width={83}
|
||||
height={83}
|
||||
></img>
|
||||
)}
|
||||
{account.text && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
maxWidth: '83px',
|
||||
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction={'row'} flexWrap={'wrap'} gap={2}>
|
||||
{footerSetting?.brand_groups?.map((group, idx) => (
|
||||
<Stack
|
||||
gap={1}
|
||||
key={group.name}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
width: 'calc(50% - 8px)',
|
||||
...(idx > 1 && {
|
||||
mt: 1,
|
||||
}),
|
||||
'& a:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
lineHeight: '24px',
|
||||
mb: 1,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Box>
|
||||
{group.links?.map(link => (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
key={link.name}
|
||||
>
|
||||
<Link
|
||||
href={link?.url || ''}
|
||||
target='_blank'
|
||||
key={link.name}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{!(
|
||||
customStyle?.footer_show_intro === false &&
|
||||
(footerSetting?.brand_groups || []).length === 0
|
||||
) && (
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.1),
|
||||
mt: 5,
|
||||
mb: 3,
|
||||
})}
|
||||
></Stack>
|
||||
)}
|
||||
{!!footerSetting?.corp_name && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
{footerSetting?.corp_name}
|
||||
</Stack>
|
||||
)}
|
||||
{!!footerSetting?.icp && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
{footerSetting?.icp}
|
||||
</Stack>
|
||||
)}
|
||||
{customStyle?.show_brand_info !== false && (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={theme => ({
|
||||
minHeight: 40,
|
||||
color: alpha(theme.palette.text.primary, 0.3),
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={'https://pandawiki.docs.baizhi.cloud/'}
|
||||
target='_blank'
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Box>{decodeBase64(PROJECT_NAME)}</Box>
|
||||
<img src={logo} alt='PandaWiki' width={0} height={0} />
|
||||
</Stack>
|
||||
</Link>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Overlay open={open} onClose={setOpen}>
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
width: '270px',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
boxShadow:
|
||||
'0px 4px 8px 0px ' + alpha(theme.palette.text.primary, 0.25),
|
||||
bgcolor: theme.palette.background.default,
|
||||
padding: 3,
|
||||
})}
|
||||
gap={2}
|
||||
>
|
||||
{curOverlayType === 'wechat_oa' && (
|
||||
<>
|
||||
<img
|
||||
src={wechatData?.src}
|
||||
width={'222px'}
|
||||
height={'222px'}
|
||||
></img>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
>
|
||||
{wechatData?.text}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{curOverlayType === 'phone' && (
|
||||
<>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
width: '100%',
|
||||
})}
|
||||
onClick={() => {
|
||||
window.location.href = `tel:${phoneData?.phone}`;
|
||||
}}
|
||||
>
|
||||
{phoneData?.phone}
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
width={'100%'}
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
gap={1}
|
||||
>
|
||||
<IconDianhua
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
></IconDianhua>
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: '24px',
|
||||
lineHeight: '32px',
|
||||
color: theme.palette.text.primary,
|
||||
})}
|
||||
>
|
||||
{phoneData?.text}
|
||||
</Box>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
id='footer'
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
sx={theme => ({
|
||||
px: mobile ? 3 : 5,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
zIndex: 1,
|
||||
color: 'text.primary',
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.05),
|
||||
'.MuiLink-root': {
|
||||
color: 'inherit',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
py={
|
||||
customStyle?.footer_show_intro
|
||||
? 6
|
||||
: (footerSetting?.brand_groups?.length || 0) > 0
|
||||
? 6
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={10}
|
||||
justifyContent={
|
||||
customStyle?.footer_show_intro === false
|
||||
? 'center'
|
||||
: 'flex-start'
|
||||
}
|
||||
>
|
||||
{customStyle?.footer_show_intro !== false && (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
sx={{ width: '30%', minWidth: 200 }}
|
||||
gap={3}
|
||||
>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={1}>
|
||||
{footerSetting?.brand_logo && (
|
||||
<img
|
||||
src={footerSetting.brand_logo}
|
||||
alt='PandaWiki'
|
||||
height={36}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '32px',
|
||||
fontSize: 24,
|
||||
}}
|
||||
>
|
||||
{footerSetting?.brand_name}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{footerSetting?.brand_desc && (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
fontSize: 14,
|
||||
lineHeight: '26px',
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
})}
|
||||
>
|
||||
{footerSetting.brand_desc}
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction={'column'} gap={'26px'}>
|
||||
{customStyle?.social_media_accounts?.map(
|
||||
(account, index) => {
|
||||
return (
|
||||
<Stack
|
||||
direction={'row'}
|
||||
key={index}
|
||||
alignItems='center'
|
||||
sx={theme => ({
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&:hover .popup': {
|
||||
display: 'flex !important',
|
||||
},
|
||||
color: alpha(theme.palette.text.primary, 0.7),
|
||||
cursor: 'default',
|
||||
})}
|
||||
gap={1}
|
||||
>
|
||||
{account.channel === 'wechat_oa' && (
|
||||
<IconWeixingongzhonghao
|
||||
sx={{ fontSize: '18px', color: 'inherit' }}
|
||||
></IconWeixingongzhonghao>
|
||||
)}
|
||||
{account.channel === 'phone' && (
|
||||
<IconDianhua
|
||||
sx={{ fontSize: '16px', color: 'inherit' }}
|
||||
></IconDianhua>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
lineHeight: '24px',
|
||||
fontSize: '14px',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
{account.channel === 'wechat_oa' &&
|
||||
(account?.text || account?.icon) && (
|
||||
<Stack
|
||||
className={'popup'}
|
||||
direction={'column'}
|
||||
alignItems={'center'}
|
||||
p={1.5}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
transform: 'translateY(-10px)',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
' 0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
gap={1}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.icon && (
|
||||
<img
|
||||
src={account.icon}
|
||||
width={120}
|
||||
height={120}
|
||||
></img>
|
||||
)}
|
||||
{account.text && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
maxWidth: '120px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.text}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{account.channel === 'phone' && account?.phone && (
|
||||
<Stack
|
||||
className={'popup'}
|
||||
px={1.5}
|
||||
py={1}
|
||||
sx={theme => ({
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
transform: 'translateY(-10px)',
|
||||
left: 0,
|
||||
boxShadow:
|
||||
'0px 4px 8px 0px ' +
|
||||
alpha(theme.palette.text.primary, 0.25),
|
||||
borderRadius: '4px',
|
||||
bgcolor: theme.palette.background.default,
|
||||
})}
|
||||
display={'none'}
|
||||
zIndex={999}
|
||||
>
|
||||
{account.phone && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '16px',
|
||||
color: 'text.primary',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{account.phone}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction={'row'}
|
||||
width={'100%'}
|
||||
justifyContent={'flex-start'}
|
||||
flexWrap='wrap'
|
||||
>
|
||||
{footerSetting?.brand_groups?.map(group => (
|
||||
<Stack
|
||||
gap={1.5}
|
||||
key={group.name}
|
||||
sx={{
|
||||
flex: '0 0 33.33%',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
minWidth: '100px',
|
||||
'& a:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
lineHeight: '24px',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Box>
|
||||
{group.links?.map(link => (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
key={link.name}
|
||||
>
|
||||
<Link
|
||||
href={link?.url || ''}
|
||||
target='_blank'
|
||||
key={link.name}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{!(
|
||||
customStyle?.footer_show_intro === false &&
|
||||
(footerSetting?.brand_groups || [])?.length === 0
|
||||
) && (
|
||||
<Stack
|
||||
sx={theme => ({
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.1),
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
})}
|
||||
></Stack>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
height: 40,
|
||||
lineHeight: '40px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={1}
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
>
|
||||
{!!footerSetting?.corp_name && (
|
||||
<Box>{footerSetting?.corp_name}</Box>
|
||||
)}
|
||||
{!!footerSetting?.icp && (
|
||||
<>
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={theme => ({
|
||||
mx: 0.5,
|
||||
height: 16,
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
/>
|
||||
<Link href={`https://beian.miit.gov.cn/`} target='_blank'>
|
||||
{footerSetting?.icp}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{customStyle?.show_brand_info !== false && (
|
||||
<>
|
||||
{(footerSetting?.corp_name || footerSetting?.icp) && (
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={theme => ({
|
||||
mx: 0.5,
|
||||
height: 16,
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={theme => ({
|
||||
color: alpha(theme.palette.text.primary, 0.5),
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href={'https://pandawiki.docs.baizhi.cloud/'}
|
||||
target='_blank'
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>{decodeBase64(PROJECT_NAME)}</Box>
|
||||
<img src={logo} alt='PandaWiki' width={0} />
|
||||
</Stack>
|
||||
</Link>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
125
web/packages/ui/src/welcomeHeader/NavBtns.tsx
Normal file
125
web/packages/ui/src/welcomeHeader/NavBtns.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Box, Button, IconButton, Stack, Link } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconChahao, IconACaidan } from '@panda-wiki/icons';
|
||||
|
||||
export interface NavBtn {
|
||||
id: string;
|
||||
url: string;
|
||||
variant: 'contained' | 'outlined' | 'text';
|
||||
showIcon: boolean;
|
||||
icon: string;
|
||||
text: string;
|
||||
target: '_blank' | '_self';
|
||||
}
|
||||
|
||||
interface NavBtnsProps {
|
||||
logo?: string;
|
||||
title?: string;
|
||||
btns?: NavBtn[];
|
||||
homePath: string;
|
||||
}
|
||||
|
||||
const NavBtns = ({ logo, title, btns, homePath }: NavBtnsProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<IconACaidan />
|
||||
</IconButton>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
transform: 'translateX(100vw) translateY(-100vh)',
|
||||
...(open && {
|
||||
bgcolor: 'background.default',
|
||||
transform: 'translateX(0) translateY(0)',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Link href={homePath}>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ py: '14px', cursor: 'pointer', ml: 3 }}
|
||||
>
|
||||
{logo && <img src={logo} alt='logo' width={32} />}
|
||||
<Box sx={{ fontSize: 18 }}>{title}</Box>
|
||||
</Stack>
|
||||
</Link>
|
||||
<Stack gap={4} sx={{ px: 3, mt: 4 }}>
|
||||
{btns?.map((item, index) => (
|
||||
<Link key={index} href={item.url} target={item.target}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={item.variant}
|
||||
startIcon={
|
||||
item.showIcon && item.icon ? (
|
||||
<img src={item.icon} alt='logo' width={36} height={36} />
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
justifyContent: 'flex-start',
|
||||
height: '60px',
|
||||
px: 4,
|
||||
gap: 3,
|
||||
fontSize: 18,
|
||||
'& .MuiButton-startIcon': {
|
||||
ml: 0,
|
||||
mr: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
color: 'text.primary',
|
||||
width: 40,
|
||||
height: 40,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<IconChahao />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBtns;
|
||||
428
web/packages/ui/src/welcomeHeader/index.tsx
Normal file
428
web/packages/ui/src/welcomeHeader/index.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconSousuo } from '@panda-wiki/icons';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
Link,
|
||||
alpha,
|
||||
styled,
|
||||
lighten,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import NavBtns, { NavBtn } from './NavBtns';
|
||||
import { DocWidth } from '../constants';
|
||||
|
||||
const StyledButton = styled(Button)(({ theme }) => ({
|
||||
textTransform: 'none',
|
||||
padding: '2px 12px',
|
||||
fontSize: 12,
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0px 1px 2px 0px rgba(145,158,171,0.16)',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
|
||||
// 检测平台类型
|
||||
const isMac = () => /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
const getKeyboardShortcut = () =>
|
||||
typeof navigator !== 'undefined' ? (isMac() ? '⌘K' : 'Ctrl+K') : '';
|
||||
|
||||
interface SearchSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: 'recent' | 'suggestion' | 'trending';
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
isDocPage?: boolean;
|
||||
mobile?: boolean;
|
||||
docWidth?: string;
|
||||
catalogWidth?: number;
|
||||
logo?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
showSearch?: boolean;
|
||||
onSearch?: (value?: string, type?: 'search' | 'chat') => void;
|
||||
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
|
||||
btns?: NavBtn[];
|
||||
children?: React.ReactNode;
|
||||
onQaClick?: () => void;
|
||||
homePath?: string;
|
||||
}
|
||||
const Header = React.memo(
|
||||
({
|
||||
isDocPage = false,
|
||||
mobile = false,
|
||||
docWidth = 'full',
|
||||
catalogWidth = 0,
|
||||
logo = '',
|
||||
placeholder = '搜索',
|
||||
title,
|
||||
showSearch = true,
|
||||
onSearch,
|
||||
onSearchSuggestions,
|
||||
btns,
|
||||
children,
|
||||
onQaClick,
|
||||
homePath = '/',
|
||||
}: HeaderProps) => {
|
||||
const [ctrlKShortcut, setCtrlKShortcut] = useState('');
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const menuOpen = Boolean(anchorEl);
|
||||
|
||||
useEffect(() => {
|
||||
setCtrlKShortcut(getKeyboardShortcut());
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const [isAtTop, setIsAtTop] = useState(true);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsAtTop(window.scrollY <= 0);
|
||||
};
|
||||
handleScroll();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// 全局键盘事件监听:⌘K (Mac) 或 Ctrl+K (Windows/Linux)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Mac: Command + K, Windows/Linux: Ctrl + K
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
onQaClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
sx={theme => ({
|
||||
transition: 'left 0.2s ease-in-out',
|
||||
position: 'sticky',
|
||||
zIndex: 10,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
backgroundColor: isAtTop
|
||||
? 'transparent'
|
||||
: theme.palette.background.default,
|
||||
boxShadow: isAtTop
|
||||
? 'none'
|
||||
: `0 2px 8px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
...(mobile && {
|
||||
left: 0,
|
||||
}),
|
||||
pl: mobile ? 3 : 5,
|
||||
pr: mobile ? 1.5 : 5,
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
justifyContent='space-between'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
// ...(isDocPage &&
|
||||
// !mobile &&
|
||||
// docWidth !== 'full' && {
|
||||
// width: `calc(${DocWidth[docWidth as keyof typeof DocWidth].value}px + ${catalogWidth}px + 192px + 240px)`,
|
||||
// }),
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Link
|
||||
href={homePath}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
color: 'text.primary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
...(mobile && {
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt='logo'
|
||||
height={36}
|
||||
style={{
|
||||
flexShrink: mobile ? 1 : 0,
|
||||
maxWidth: mobile ? '100%' : 'none',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: mobile ? 16 : 20,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
</Link>
|
||||
</Stack>
|
||||
{showSearch &&
|
||||
(mobile ? (
|
||||
// 移动端:显示搜索图标按钮
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
>
|
||||
<IconButton
|
||||
size='small'
|
||||
sx={{ width: 40, height: 40, color: 'text.primary' }}
|
||||
onClick={() => onQaClick?.()}
|
||||
>
|
||||
<IconSousuo sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
// 桌面端:显示搜索框
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
focused={false}
|
||||
onClick={() => onQaClick?.()}
|
||||
sx={theme => ({
|
||||
flex: 1,
|
||||
maxWidth: '500px',
|
||||
minWidth: '220px',
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
'.MuiOutlinedInput-input': {
|
||||
py: 1,
|
||||
},
|
||||
'& .MuiOutlinedInput-root': {
|
||||
height: 40,
|
||||
pr: '12px',
|
||||
pl: '12px',
|
||||
'& fieldset': {
|
||||
borderRadius: '10px',
|
||||
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
},
|
||||
})}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<IconSousuo
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
color: 'text.tertiary',
|
||||
fontSize: 20,
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
endAdornment: (
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
gap={1.5}
|
||||
sx={{ flexShrink: 0, ml: 1 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
{ctrlKShortcut}
|
||||
</Box>
|
||||
<StyledButton variant='contained'>
|
||||
智能问答
|
||||
</StyledButton>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!mobile && btns && btns.length > 0 && (
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
alignItems='center'
|
||||
justifyContent='flex-end'
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{btns.slice(0, Math.min(2, btns.length)).map((item, index) => (
|
||||
<Link key={index} href={item.url} target={item.target}>
|
||||
<Button
|
||||
variant={item.variant}
|
||||
sx={theme => ({
|
||||
px: 3.5,
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'none',
|
||||
boxSizing: 'border-box',
|
||||
height: 40,
|
||||
...(item.variant === 'outlined' && {
|
||||
borderWidth: 2,
|
||||
bgcolor: theme.palette.background.default,
|
||||
borderColor: alpha(theme.palette.primary.main, 0.8),
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
}),
|
||||
})}
|
||||
startIcon={
|
||||
item.showIcon && item.icon ? (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt='logo'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Box sx={{ lineHeight: '24px', fontSize: 18 }}>
|
||||
{item.text}
|
||||
</Box>
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
{btns.length > 2 && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleMenuClick}
|
||||
sx={theme => ({
|
||||
width: 40,
|
||||
height: 40,
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={menuOpen}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
mt: 1,
|
||||
minWidth: 180,
|
||||
boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{btns.slice(2).map((item, index) => (
|
||||
<MenuItem
|
||||
key={index + 2}
|
||||
onClick={handleMenuClose}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{item.showIcon && item.icon && (
|
||||
<img
|
||||
src={item.icon}
|
||||
alt='logo'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ fontSize: 16 }}>{item.text}</Box>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{mobile && (
|
||||
<NavBtns
|
||||
logo={logo}
|
||||
title={title}
|
||||
btns={btns}
|
||||
homePath={homePath}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Header;
|
||||
Reference in New Issue
Block a user