♻️ refactor(components): refactor the components folder structure and related imports
This commit is contained in:
114
web/src/components/layout/Footer.js
Normal file
114
web/src/components/layout/Footer.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const FooterBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const [footer, setFooter] = useState(getFooterHTML());
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const loadFooter = () => {
|
||||
let footer_html = localStorage.getItem('footer_html');
|
||||
if (footer_html) {
|
||||
setFooter(footer_html);
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const customFooter = useMemo(() => (
|
||||
<footer className="relative bg-gray-900 dark:bg-[#1C1F23] h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
|
||||
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
|
||||
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
|
||||
<div className="text-left">
|
||||
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('关于我们')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('功能特性')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('文档')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('API 文档')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('相关项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">neko-api-key-tool</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('基于New API的项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography.Text className="text-sm !text-[#d9dbe1]">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="text-sm">
|
||||
<span className="!text-[#d9dbe1]">{t('设计与开发由')} </span>
|
||||
<span className="!text-[#01ffc3]">Douyin FE</span>
|
||||
<span className="!text-[#d9dbe1]"> & </span>
|
||||
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-[#01ffc3] hover:!text-[#01ffc3]">QuantumNous</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
), [logo, systemName, t, currentYear, isDemoSiteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFooter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{footer ? (
|
||||
<div
|
||||
className="custom-footer"
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBar;
|
||||
536
web/src/components/layout/HeaderBar.js
Normal file
536
web/src/components/layout/HeaderBar.js
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
|
||||
import fireworks from 'react-fireworks';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
import NoticeModal from './NoticeModal.js';
|
||||
|
||||
import {
|
||||
IconClose,
|
||||
IconMenu,
|
||||
IconLanguage,
|
||||
IconChevronDown,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconExit,
|
||||
IconUserSetting,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
IconBell,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Typography,
|
||||
Skeleton,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
|
||||
const HeaderBar = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
let navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const currentDate = new Date();
|
||||
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
|
||||
|
||||
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const mainNavLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
itemKey: 'home',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
text: t('控制台'),
|
||||
itemKey: 'console',
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('定价'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
...(docsLink
|
||||
? [
|
||||
{
|
||||
text: t('文档'),
|
||||
itemKey: 'docs',
|
||||
isExternal: true,
|
||||
externalLink: docsLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: t('关于'),
|
||||
itemKey: 'about',
|
||||
to: '/about',
|
||||
},
|
||||
];
|
||||
|
||||
async function logout() {
|
||||
await API.get('/api/user/logout');
|
||||
showSuccess(t('注销成功!'));
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
const handleNewYearClick = () => {
|
||||
fireworks.init('root', {});
|
||||
fireworks.start();
|
||||
setTimeout(() => {
|
||||
fireworks.stop();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.body.setAttribute('theme-mode', 'dark');
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.body.removeAttribute('theme-mode');
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
|
||||
}
|
||||
|
||||
}, [theme, isNewYear]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
setCurrentLang(lng);
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ lang: lng }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleLanguageChange = (lang) => {
|
||||
i18n.changeLanguage(lang);
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavLinkClick = (itemKey) => {
|
||||
if (itemKey === 'home') {
|
||||
styleDispatch(styleActions.setSider(false));
|
||||
}
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const renderNavLinks = (isMobileView = false, isLoading = false) => {
|
||||
if (isLoading) {
|
||||
const skeletonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
return Array(4)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return mainNavLinks.map((link) => {
|
||||
const commonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
|
||||
: 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold';
|
||||
|
||||
const linkContent = (
|
||||
<span>{link.text}</span>
|
||||
);
|
||||
|
||||
if (link.isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={link.itemKey}
|
||||
href={link.externalLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let targetPath = link.to;
|
||||
if (link.itemKey === 'console' && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.itemKey}
|
||||
to={targetPath}
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderUserArea = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
|
||||
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
|
||||
<div className="ml-1.5 mr-1">
|
||||
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('API令牌')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('钱包')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
>
|
||||
<Avatar
|
||||
size="extra-small"
|
||||
color={stringToColor(userState.user.username)}
|
||||
className="mr-1"
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className="hidden md:inline">
|
||||
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
|
||||
|
||||
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
|
||||
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (styleState.isMobile) {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-l-full !rounded-r-none";
|
||||
}
|
||||
registerButtonClasses += " !rounded-r-full !rounded-l-none";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>
|
||||
{t('登录')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className="hidden md:block">
|
||||
<Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>
|
||||
{t('注册')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前路由是否以/console开头
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={styleState.isMobile}
|
||||
/>
|
||||
<div className="w-full px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={
|
||||
isConsoleRoute
|
||||
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
}
|
||||
aria-label={
|
||||
isConsoleRoute
|
||||
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
|
||||
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
|
||||
}
|
||||
onClick={() => {
|
||||
if (isConsoleRoute) {
|
||||
// 控制侧边栏的显示/隐藏,无论是否移动设备
|
||||
styleDispatch(styleActions.toggleSider());
|
||||
} else {
|
||||
// 控制HeaderBar自己的移动菜单
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}
|
||||
}}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
|
||||
) : (
|
||||
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
|
||||
)}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Title style={{ width: 120, height: 24 }} />
|
||||
) : (
|
||||
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
|
||||
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
|
||||
bg-clip-text text-transparent">
|
||||
{systemName}
|
||||
</Typography.Title>
|
||||
)}
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<div className="md:hidden">
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
|
||||
{renderNavLinks(false, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{isNewYear && (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
|
||||
Happy New Year!!! 🎉
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={<span className="text-xl">🎉</span>}
|
||||
aria-label="New Year"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={() => setNoticeVisible(true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||
aria-label={t('切换主题')}
|
||||
onClick={() => setTheme(theme === 'dark' ? false : true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<CN title="中文" className="!w-5 !h-auto" />
|
||||
<span>中文</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<GB title="English" className="!w-5 !h-auto" />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<IconLanguage className="text-lg" />}
|
||||
aria-label={t('切换语言')}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{renderUserArea()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={`
|
||||
absolute top-16 left-0 right-0 bg-semi-color-bg-0
|
||||
shadow-lg p-3
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
|
||||
`}
|
||||
>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{renderNavLinks(true, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
94
web/src/components/layout/NoticeModal.js
Normal file
94
web/src/components/layout/NoticeModal.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('notice_close_date', today);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const displayNotice = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data !== '') {
|
||||
const htmlNotice = marked.parse(data);
|
||||
setNoticeContent(htmlNotice);
|
||||
} else {
|
||||
setNoticeContent('');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
displayNotice();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
}
|
||||
|
||||
if (!noticeContent) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="max-h-[60vh] overflow-y-auto pr-2"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--semi-color-tertiary) transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('系统公告')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
|
||||
</div>
|
||||
)}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticeModal;
|
||||
164
web/src/components/layout/PageLayout.js
Normal file
164
web/src/components/layout/PageLayout.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import HeaderBar from './HeaderBar.js';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import SiderBar from './SiderBar.js';
|
||||
import App from '../../App.js';
|
||||
import FooterBar from './Footer.js';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useStyle } from '../../context/Style/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
|
||||
const PageLayout = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const { state: styleState } = useStyle();
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
location.pathname !== '/console/playground';
|
||||
|
||||
const loadUser = () => {
|
||||
let user = localStorage.getItem('user');
|
||||
if (user) {
|
||||
let data = JSON.parse(user);
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (success) {
|
||||
statusDispatch({ type: 'set', payload: data });
|
||||
setStatusData(data);
|
||||
} else {
|
||||
showError('Unable to connect to server');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load status');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
loadStatus().catch(console.error);
|
||||
let systemName = getSystemName();
|
||||
if (systemName) {
|
||||
document.title = systemName;
|
||||
}
|
||||
let logo = getLogo();
|
||||
if (logo) {
|
||||
let linkElement = document.querySelector("link[rel~='icon']");
|
||||
if (linkElement) {
|
||||
linkElement.href = logo;
|
||||
}
|
||||
}
|
||||
// 从localStorage获取上次使用的语言
|
||||
const savedLang = localStorage.getItem('i18nextLng');
|
||||
if (savedLang) {
|
||||
i18n.changeLanguage(savedLang);
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: styleState.isMobile ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
lineHeight: 'normal',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
>
|
||||
<HeaderBar />
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
marginTop: '64px',
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: styleState.isMobile ? 'visible' : 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{styleState.showSider && (
|
||||
<Sider
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: '64px',
|
||||
zIndex: 99,
|
||||
border: 'none',
|
||||
paddingRight: '0',
|
||||
height: 'calc(100vh - 64px)',
|
||||
}}
|
||||
>
|
||||
<SiderBar />
|
||||
</Sider>
|
||||
)}
|
||||
<Layout
|
||||
style={{
|
||||
marginLeft: styleState.isMobile
|
||||
? '0'
|
||||
: styleState.showSider
|
||||
? styleState.siderCollapsed
|
||||
? '60px'
|
||||
: '200px'
|
||||
: '0',
|
||||
transition: 'margin-left 0.3s ease',
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
style={{
|
||||
flex: '1 0 auto',
|
||||
overflowY: styleState.isMobile ? 'visible' : 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: shouldInnerPadding ? '24px' : '0',
|
||||
position: 'relative',
|
||||
marginTop: styleState.isMobile ? '2px' : '0',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</Content>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<FooterBar />
|
||||
</Layout.Footer>
|
||||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageLayout;
|
||||
504
web/src/components/layout/SiderBar.js
Normal file
504
web/src/components/layout/SiderBar.js
Normal file
@@ -0,0 +1,504 @@
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
isAdmin,
|
||||
showError
|
||||
} from '../../helpers/index.js';
|
||||
|
||||
import {
|
||||
IconCalendarClock,
|
||||
IconChecklistStroked,
|
||||
IconComment,
|
||||
IconTerminal,
|
||||
IconCreditCard,
|
||||
IconGift,
|
||||
IconHistogram,
|
||||
IconImage,
|
||||
IconKey,
|
||||
IconLayers,
|
||||
IconSetting,
|
||||
IconUser,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
Nav,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
// 自定义侧边栏按钮样式
|
||||
const navItemStyle = {
|
||||
borderRadius: '6px',
|
||||
margin: '4px 8px',
|
||||
};
|
||||
|
||||
// 自定义侧边栏按钮悬停样式
|
||||
const navItemHoverStyle = {
|
||||
backgroundColor: 'var(--semi-color-primary-light-default)',
|
||||
color: 'var(--semi-color-primary)',
|
||||
};
|
||||
|
||||
// 自定义侧边栏按钮选中样式
|
||||
const navItemSelectedStyle = {
|
||||
backgroundColor: 'var(--semi-color-primary-light-default)',
|
||||
color: 'var(--semi-color-primary)',
|
||||
fontWeight: '600',
|
||||
};
|
||||
|
||||
// 自定义图标样式
|
||||
const iconStyle = (itemKey, selectedKeys) => {
|
||||
return {
|
||||
fontSize: '18px',
|
||||
color: selectedKeys.includes(itemKey)
|
||||
? 'var(--semi-color-primary)'
|
||||
: 'var(--semi-color-text-2)',
|
||||
};
|
||||
};
|
||||
|
||||
// Define routerMap as a constant outside the component
|
||||
const routerMap = {
|
||||
home: '/',
|
||||
channel: '/console/channel',
|
||||
token: '/console/token',
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
about: '/about',
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
|
||||
const SiderBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
const [openedKeys, setOpenedKeys] = useState([]);
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
const location = useLocation();
|
||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
||||
|
||||
// 预先计算所有可能的图标样式
|
||||
const allItemKeys = useMemo(() => {
|
||||
const keys = [
|
||||
'home',
|
||||
'channel',
|
||||
'token',
|
||||
'redemption',
|
||||
'topup',
|
||||
'user',
|
||||
'log',
|
||||
'midjourney',
|
||||
'setting',
|
||||
'about',
|
||||
'chat',
|
||||
'detail',
|
||||
'pricing',
|
||||
'task',
|
||||
'playground',
|
||||
'personal',
|
||||
];
|
||||
// 添加聊天项的keys
|
||||
for (let i = 0; i < chatItems.length; i++) {
|
||||
keys.push('chat' + i);
|
||||
}
|
||||
return keys;
|
||||
}, [chatItems]);
|
||||
|
||||
// 使用useMemo一次性计算所有图标样式
|
||||
const iconStyles = useMemo(() => {
|
||||
const styles = {};
|
||||
allItemKeys.forEach((key) => {
|
||||
styles[key] = iconStyle(key, selectedKeys);
|
||||
});
|
||||
return styles;
|
||||
}, [allItemKeys, selectedKeys]);
|
||||
|
||||
const workspaceItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
to: '/detail',
|
||||
icon: <IconCalendarClock />,
|
||||
className:
|
||||
localStorage.getItem('enable_data_export') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('API令牌'),
|
||||
itemKey: 'token',
|
||||
to: '/token',
|
||||
icon: <IconKey />,
|
||||
},
|
||||
{
|
||||
text: t('使用日志'),
|
||||
itemKey: 'log',
|
||||
to: '/log',
|
||||
icon: <IconHistogram />,
|
||||
},
|
||||
{
|
||||
text: t('绘图日志'),
|
||||
itemKey: 'midjourney',
|
||||
to: '/midjourney',
|
||||
icon: <IconImage />,
|
||||
className:
|
||||
localStorage.getItem('enable_drawing') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('任务日志'),
|
||||
itemKey: 'task',
|
||||
to: '/task',
|
||||
icon: <IconChecklistStroked />,
|
||||
className:
|
||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const financeItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('钱包'),
|
||||
itemKey: 'topup',
|
||||
to: '/topup',
|
||||
icon: <IconCreditCard />,
|
||||
},
|
||||
{
|
||||
text: t('个人设置'),
|
||||
itemKey: 'personal',
|
||||
to: '/personal',
|
||||
icon: <IconUser />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const adminItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('渠道'),
|
||||
itemKey: 'channel',
|
||||
to: '/channel',
|
||||
icon: <IconLayers />,
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码'),
|
||||
itemKey: 'redemption',
|
||||
to: '/redemption',
|
||||
icon: <IconGift />,
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('用户管理'),
|
||||
itemKey: 'user',
|
||||
to: '/user',
|
||||
icon: <IconUser />,
|
||||
},
|
||||
{
|
||||
text: t('系统设置'),
|
||||
itemKey: 'setting',
|
||||
to: '/setting',
|
||||
icon: <IconSetting />,
|
||||
},
|
||||
],
|
||||
[isAdmin(), t],
|
||||
);
|
||||
|
||||
const chatMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('操练场'),
|
||||
itemKey: 'playground',
|
||||
to: '/playground',
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
{
|
||||
text: t('聊天'),
|
||||
itemKey: 'chat',
|
||||
items: chatItems,
|
||||
icon: <IconComment />,
|
||||
},
|
||||
],
|
||||
[chatItems, t],
|
||||
);
|
||||
|
||||
// Function to update router map with chat routes
|
||||
const updateRouterMapWithChats = (chats) => {
|
||||
const newRouterMap = { ...routerMap };
|
||||
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
newRouterMap['chat' + i] = '/console/chat/' + i;
|
||||
}
|
||||
}
|
||||
|
||||
setRouterMapState(newRouterMap);
|
||||
return newRouterMap;
|
||||
};
|
||||
|
||||
// Update the useEffect for chat items
|
||||
useEffect(() => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
try {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats)) {
|
||||
let chatItems = [];
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let chat = {};
|
||||
for (let key in chats[i]) {
|
||||
chat.text = key;
|
||||
chat.itemKey = 'chat' + i;
|
||||
chat.to = '/console/chat/' + i;
|
||||
}
|
||||
chatItems.push(chat);
|
||||
}
|
||||
setChatItems(chatItems);
|
||||
|
||||
// Update router map with chat routes
|
||||
updateRouterMapWithChats(chats);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError('聊天数据解析失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update the useEffect for route selection
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
let matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
// Handle chat routes
|
||||
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
|
||||
const chatIndex = currentPath.split('/').pop();
|
||||
if (!isNaN(chatIndex)) {
|
||||
matchingKey = 'chat' + chatIndex;
|
||||
} else {
|
||||
matchingKey = 'chat';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching key, update the selected keys
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
}
|
||||
}, [location.pathname, routerMapState]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(styleState.siderCollapsed);
|
||||
}, [styleState.siderCollapsed]);
|
||||
|
||||
// Custom divider style
|
||||
const dividerStyle = {
|
||||
margin: '8px 0',
|
||||
opacity: 0.6,
|
||||
};
|
||||
|
||||
// Custom group label style
|
||||
const groupLabelStyle = {
|
||||
padding: '8px 16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav
|
||||
className='custom-sidebar-nav'
|
||||
style={{
|
||||
width: isCollapsed ? '60px' : '200px',
|
||||
borderRight: '1px solid var(--semi-color-border)',
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
|
||||
position: 'relative',
|
||||
zIndex: 95,
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
|
||||
}}
|
||||
defaultIsCollapsed={styleState.siderCollapsed}
|
||||
isCollapsed={isCollapsed}
|
||||
onCollapseChange={(collapsed) => {
|
||||
setIsCollapsed(collapsed);
|
||||
styleDispatch(styleActions.setSiderCollapsed(collapsed));
|
||||
|
||||
// 确保在收起侧边栏时有选中的项目,避免不必要的计算
|
||||
if (selectedKeys.length === 0) {
|
||||
const currentPath = location.pathname;
|
||||
const matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
} else if (currentPath.startsWith('/console/chat/')) {
|
||||
setSelectedKeys(['chat']);
|
||||
} else {
|
||||
setSelectedKeys(['detail']); // 默认选中首页
|
||||
}
|
||||
}
|
||||
}}
|
||||
selectedKeys={selectedKeys}
|
||||
itemStyle={navItemStyle}
|
||||
hoverStyle={navItemHoverStyle}
|
||||
selectedStyle={navItemSelectedStyle}
|
||||
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={routerMapState[props.itemKey] || routerMap[props.itemKey]}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
>
|
||||
{/* Chat Section - Only show if there are chat items */}
|
||||
{chatMenuItems.map((item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
return (
|
||||
<Nav.Sub
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={item.text}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
style: iconStyles[item.itemKey],
|
||||
})}
|
||||
>
|
||||
{item.items.map((subItem) => (
|
||||
<Nav.Item
|
||||
key={subItem.itemKey}
|
||||
itemKey={subItem.itemKey}
|
||||
text={subItem.text}
|
||||
/>
|
||||
))}
|
||||
</Nav.Sub>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={item.text}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
style: iconStyles[item.itemKey],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<Divider style={dividerStyle} />
|
||||
|
||||
{/* Workspace Section */}
|
||||
{!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
|
||||
{workspaceItems.map((item) => (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={item.text}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
style: iconStyles[item.itemKey],
|
||||
})}
|
||||
className={item.className}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin() && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<Divider style={dividerStyle} />
|
||||
|
||||
{/* Admin Section */}
|
||||
{!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
|
||||
{adminItems.map((item) => (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={item.text}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
style: iconStyles[item.itemKey],
|
||||
})}
|
||||
className={item.className}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<Divider style={dividerStyle} />
|
||||
|
||||
{/* Finance Management Section */}
|
||||
{!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
|
||||
{financeItems.map((item) => (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={item.text}
|
||||
icon={React.cloneElement(item.icon, {
|
||||
style: iconStyles[item.itemKey],
|
||||
})}
|
||||
className={item.className}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Nav.Footer
|
||||
collapseButton={true}
|
||||
collapseText={(collapsed) => {
|
||||
if (collapsed) {
|
||||
return t('展开侧边栏');
|
||||
}
|
||||
return t('收起侧边栏');
|
||||
}}
|
||||
/>
|
||||
</Nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiderBar;
|
||||
Reference in New Issue
Block a user