413 lines
12 KiB
JavaScript
413 lines
12 KiB
JavaScript
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import HeaderBar from './headerbar';
|
||
import { Layout, Button, Space } from '@douyinfe/semi-ui';
|
||
import { IconClose } from '@douyinfe/semi-icons';
|
||
import SiderBar from './SiderBar';
|
||
import App from '../../App';
|
||
import FooterBar from './Footer';
|
||
import OnboardingWizard from '../onboarding/OnboardingWizard';
|
||
import { ToastContainer } from 'react-toastify';
|
||
import React, { useContext, useEffect, useState } from 'react';
|
||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { API, getLogo, showError, setStatusData } from '../../helpers';
|
||
import { UserContext } from '../../context/User';
|
||
import { StatusContext } from '../../context/Status';
|
||
import { useLocation } from 'react-router-dom';
|
||
import {
|
||
normalizeLanguage,
|
||
pickPrimaryNavigatorLanguage,
|
||
isSupportedUiLanguage,
|
||
I18N_ANON_LANG_INITIALIZED_KEY,
|
||
I18N_BROWSER_LANG_MISMATCH_PROMPT_KEY,
|
||
I18N_BROWSER_LANG_BANNER_PENDING_KEY,
|
||
} from '../../i18n/language';
|
||
const { Sider, Content, Header } = Layout;
|
||
|
||
const PageLayout = () => {
|
||
const [userState, userDispatch] = useContext(UserContext);
|
||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||
const isMobile = useIsMobile();
|
||
const [collapsed, , setCollapsed] = useSidebarCollapsed();
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [browserLangBanner, setBrowserLangBanner] = useState(null);
|
||
const { i18n, t } = useTranslation();
|
||
const location = useLocation();
|
||
|
||
const cardProPages = [
|
||
'/',
|
||
'/console/channel',
|
||
'/console/log',
|
||
'/console/redemption',
|
||
'/console/user',
|
||
'/console/token',
|
||
'/console/midjourney',
|
||
'/console/task',
|
||
'/console/models',
|
||
'/console/supplier/apply',
|
||
'/console/distributor/apply',
|
||
'/console/supplier/channel',
|
||
'/console/supplier/models',
|
||
'/console/supplier-application',
|
||
'/console/suppliers',
|
||
'/console/playground',
|
||
'/pricing',
|
||
'/console/model-heat'
|
||
];
|
||
|
||
const shouldHideFooter = cardProPages.includes(location.pathname);
|
||
|
||
const pricingNaturalScroll = location.pathname === '/pricing';
|
||
|
||
const shouldInnerPadding =
|
||
location.pathname.includes('/console') &&
|
||
!location.pathname.startsWith('/console/chat') &&
|
||
location.pathname !== '/console/playground';
|
||
|
||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
||
|
||
useEffect(() => {
|
||
if (isMobile && drawerOpen && collapsed) {
|
||
setCollapsed(false);
|
||
}
|
||
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
|
||
|
||
const loadUser = () => {
|
||
let user = localStorage.getItem('user');
|
||
if (user) {
|
||
let data = JSON.parse(user);
|
||
userDispatch({ type: 'login', payload: data });
|
||
}
|
||
};
|
||
|
||
/** 与 localStorage 中的 status 派生字段一致(须在 setStatusData 之后调用) */
|
||
const syncDocumentBrandingFromStorage = () => {
|
||
// 仅用显式缓存:避免用 getSystemName() 的占位文案抢在 /api/status 之前改标题,
|
||
// 也避免旧缓存里的默认名(如 TokenFactory)闪一下。
|
||
const systemName = localStorage.getItem('system_name');
|
||
if (systemName) {
|
||
document.title = systemName;
|
||
}
|
||
const logo = getLogo();
|
||
if (logo) {
|
||
const linkElement = document.querySelector("link[rel~='icon']");
|
||
if (linkElement) {
|
||
linkElement.href = logo;
|
||
}
|
||
}
|
||
};
|
||
|
||
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);
|
||
syncDocumentBrandingFromStorage();
|
||
} else {
|
||
showError('Unable to connect to server');
|
||
syncDocumentBrandingFromStorage();
|
||
}
|
||
} catch (error) {
|
||
showError('Failed to load status');
|
||
syncDocumentBrandingFromStorage();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadUser();
|
||
loadStatus().catch(console.error);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const status = statusState?.status;
|
||
if (!status) return;
|
||
|
||
let userLang;
|
||
if (userState?.user?.setting) {
|
||
try {
|
||
const settings = JSON.parse(userState.user.setting);
|
||
userLang = normalizeLanguage(settings.language);
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
if (userLang && isSupportedUiLanguage(userLang)) {
|
||
localStorage.setItem('i18nextLng', userLang);
|
||
if (userLang !== normalizeLanguage(i18n.language)) {
|
||
i18n.changeLanguage(userLang);
|
||
}
|
||
setBrowserLangBanner(null);
|
||
return;
|
||
}
|
||
|
||
if (localStorage.getItem(I18N_ANON_LANG_INITIALIZED_KEY) === '1') {
|
||
const saved = normalizeLanguage(localStorage.getItem('i18nextLng') || '');
|
||
if (
|
||
saved &&
|
||
isSupportedUiLanguage(saved) &&
|
||
saved !== normalizeLanguage(i18n.language)
|
||
) {
|
||
i18n.changeLanguage(saved);
|
||
}
|
||
if (localStorage.getItem(I18N_BROWSER_LANG_MISMATCH_PROMPT_KEY) !== 'done') {
|
||
try {
|
||
const raw = sessionStorage.getItem(I18N_BROWSER_LANG_BANNER_PENDING_KEY);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed?.browserLang && parsed?.siteLang) {
|
||
setBrowserLangBanner(parsed);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const siteDefaultRaw = normalizeLanguage(
|
||
status.default_site_language || 'zh-CN',
|
||
);
|
||
const siteDefault = isSupportedUiLanguage(siteDefaultRaw)
|
||
? siteDefaultRaw
|
||
: 'zh-CN';
|
||
|
||
localStorage.setItem(I18N_ANON_LANG_INITIALIZED_KEY, '1');
|
||
localStorage.setItem('i18nextLng', siteDefault);
|
||
if (normalizeLanguage(i18n.language) !== siteDefault) {
|
||
i18n.changeLanguage(siteDefault);
|
||
}
|
||
|
||
if (localStorage.getItem(I18N_BROWSER_LANG_MISMATCH_PROMPT_KEY) === 'done') {
|
||
return;
|
||
}
|
||
|
||
const browserLang = pickPrimaryNavigatorLanguage();
|
||
if (browserLang && browserLang !== siteDefault) {
|
||
const payload = { browserLang, siteLang: siteDefault };
|
||
try {
|
||
sessionStorage.setItem(
|
||
I18N_BROWSER_LANG_BANNER_PENDING_KEY,
|
||
JSON.stringify(payload),
|
||
);
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
setBrowserLangBanner(payload);
|
||
}
|
||
}, [statusState?.status, userState?.user?.setting, userState?.user?.id, i18n]);
|
||
|
||
const resolveLangLabel = (code) => {
|
||
try {
|
||
const loc = normalizeLanguage(i18n.language) || 'zh-CN';
|
||
const dn = new Intl.DisplayNames([loc], { type: 'language' });
|
||
return dn.of(code) || code;
|
||
} catch (e) {
|
||
return code;
|
||
}
|
||
};
|
||
|
||
const dismissBrowserLangBanner = (mode) => {
|
||
if (mode === 'browser' && browserLangBanner) {
|
||
const { browserLang } = browserLangBanner;
|
||
i18n.changeLanguage(browserLang);
|
||
localStorage.setItem('i18nextLng', browserLang);
|
||
}
|
||
localStorage.setItem(I18N_BROWSER_LANG_MISMATCH_PROMPT_KEY, 'done');
|
||
try {
|
||
sessionStorage.removeItem(I18N_BROWSER_LANG_BANNER_PENDING_KEY);
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
setBrowserLangBanner(null);
|
||
};
|
||
|
||
return (
|
||
<Layout
|
||
className='app-layout'
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: isMobile ? 'visible' : 'hidden',
|
||
}}
|
||
>
|
||
{browserLangBanner ? (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: isMobile ? 68 : 72,
|
||
right: isMobile ? 8 : 16,
|
||
left: isMobile ? 8 : 'auto',
|
||
zIndex: 99,
|
||
maxWidth: isMobile ? 'none' : 300,
|
||
width: isMobile ? 'auto' : 300,
|
||
padding: 1,
|
||
borderRadius: 14,
|
||
background:
|
||
'linear-gradient(135deg, var(--semi-color-primary), #8b5cf6 45%, #22d3ee)',
|
||
boxShadow:
|
||
'0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255,255,255,0.06) inset',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'relative',
|
||
borderRadius: 13,
|
||
background: 'var(--semi-color-bg-2)',
|
||
padding: '10px 32px 10px 10px',
|
||
}}
|
||
>
|
||
<Button
|
||
aria-label={t('取消')}
|
||
theme='borderless'
|
||
type='tertiary'
|
||
size='small'
|
||
icon={<IconClose />}
|
||
onClick={() => dismissBrowserLangBanner('site')}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 2,
|
||
right: 2,
|
||
minWidth: 28,
|
||
}}
|
||
/>
|
||
<p
|
||
style={{
|
||
margin: '0 0 8px 0',
|
||
fontSize: 12,
|
||
lineHeight: 1.45,
|
||
color: 'var(--semi-color-text-1)',
|
||
}}
|
||
>
|
||
{t('检测到浏览器语言与站点默认不同描述', {
|
||
browser: resolveLangLabel(browserLangBanner.browserLang),
|
||
site: resolveLangLabel(browserLangBanner.siteLang),
|
||
})}
|
||
</p>
|
||
<Space spacing='tight' wrap>
|
||
<Button
|
||
type='primary'
|
||
size='small'
|
||
onClick={() => dismissBrowserLangBanner('browser')}
|
||
>
|
||
{t('确认')}
|
||
</Button>
|
||
<Button size='small' onClick={() => dismissBrowserLangBanner('site')}>
|
||
{t('取消')}
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{location.pathname !== '/' && (
|
||
<Header
|
||
style={{
|
||
padding: 0,
|
||
height: 'auto',
|
||
lineHeight: 'normal',
|
||
position: 'fixed',
|
||
width: '100%',
|
||
top: 0,
|
||
zIndex: 100,
|
||
}}
|
||
>
|
||
<HeaderBar
|
||
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
|
||
drawerOpen={drawerOpen}
|
||
/>
|
||
</Header>
|
||
)}
|
||
<Layout
|
||
style={{
|
||
overflow: isMobile ? 'visible' : 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
{showSider && (
|
||
<Sider
|
||
className='app-sider'
|
||
style={{
|
||
position: 'fixed',
|
||
left: 0,
|
||
top: '64px',
|
||
zIndex: 99,
|
||
border: 'none',
|
||
paddingRight: '0',
|
||
width: 'var(--sidebar-current-width)',
|
||
}}
|
||
>
|
||
<SiderBar
|
||
onNavigate={() => {
|
||
if (isMobile) setDrawerOpen(false);
|
||
}}
|
||
/>
|
||
</Sider>
|
||
)}
|
||
<Layout
|
||
style={{
|
||
marginLeft: isMobile
|
||
? '0'
|
||
: showSider
|
||
? 'var(--sidebar-current-width)'
|
||
: '0',
|
||
flex: '1 1 auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
<Content
|
||
style={{
|
||
flex: '1 0 auto',
|
||
overflowY: isMobile ? 'visible' : 'hidden',
|
||
WebkitOverflowScrolling: 'touch',
|
||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
<App />
|
||
</Content>
|
||
{!shouldHideFooter && (
|
||
<Layout.Footer
|
||
style={{
|
||
flex: '0 0 auto',
|
||
width: '100%',
|
||
}}
|
||
>
|
||
<FooterBar />
|
||
</Layout.Footer>
|
||
)}
|
||
</Layout>
|
||
</Layout>
|
||
<OnboardingWizard />
|
||
<ToastContainer />
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default PageLayout;
|