init push

This commit is contained in:
2026-05-21 19:52:45 +08:00
commit e3f75311ab
1280 changed files with 179173 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;
}

View 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);

View 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;

View 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;

View 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)`,
}));

View 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=';

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 === ' ' ? '&nbsp;' : 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;
};

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}
};

View 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;

View 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;

View 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;

View 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;