mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 08:37:28 +00:00
Backend (controller/ratio_sync.go): - Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io) - Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config) - Harden HTTP client: - IPv4‑first with IPv6 fallback for github.io - Add ResponseHeaderTimeout - 3 attempts with exponential backoff (200/400/800ms) - Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader) - Robust parsing: support type1 ratio_config map and type2 pricing list - Use net.SplitHostPort for host parsing - Use float tolerance in differences comparison to avoid false mismatches - Remove unused code (tryDirect) and improve warnings Frontend: - UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json - ChannelSelectorModal.jsx: - Pin the official source at the top of the list - Show a green “官方” tag next to the status - Refactor status renderer to accept the full record Notes: - Backward compatible; no API surface changes - Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json
207 lines
6.0 KiB
JavaScript
207 lines
6.0 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 { useState, useEffect, useContext, useCallback, useMemo } from 'react';
|
||
import { useNavigate, useLocation } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { UserContext } from '../../context/User';
|
||
import { StatusContext } from '../../context/Status';
|
||
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
|
||
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
|
||
import { useIsMobile } from './useIsMobile';
|
||
import { useSidebarCollapsed } from './useSidebarCollapsed';
|
||
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
|
||
|
||
export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||
const { t, i18n } = useTranslation();
|
||
const [userState, userDispatch] = useContext(UserContext);
|
||
const [statusState] = useContext(StatusContext);
|
||
const isMobile = useIsMobile();
|
||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||
const navigate = useNavigate();
|
||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||
const location = useLocation();
|
||
|
||
const loading = statusState?.status === undefined;
|
||
const isLoading = useMinimumLoadingTime(loading);
|
||
|
||
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 headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||
|
||
// 使用useMemo确保headerNavModules正确响应statusState变化
|
||
const headerNavModules = useMemo(() => {
|
||
if (headerNavModulesConfig) {
|
||
try {
|
||
const modules = JSON.parse(headerNavModulesConfig);
|
||
|
||
// 处理向后兼容性:如果pricing是boolean,转换为对象格式
|
||
if (typeof modules.pricing === 'boolean') {
|
||
modules.pricing = {
|
||
enabled: modules.pricing,
|
||
requireAuth: false, // 默认不需要登录鉴权
|
||
};
|
||
}
|
||
|
||
return modules;
|
||
} catch (error) {
|
||
console.error('解析顶栏模块配置失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}, [headerNavModulesConfig]);
|
||
|
||
// 获取模型广场权限配置
|
||
const pricingRequireAuth = useMemo(() => {
|
||
if (headerNavModules?.pricing) {
|
||
return typeof headerNavModules.pricing === 'object'
|
||
? headerNavModules.pricing.requireAuth
|
||
: false; // 默认不需要登录
|
||
}
|
||
return false; // 默认不需要登录
|
||
}, [headerNavModules]);
|
||
|
||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||
|
||
const theme = useTheme();
|
||
const actualTheme = useActualTheme();
|
||
const setTheme = useSetTheme();
|
||
|
||
// Logo loading effect
|
||
useEffect(() => {
|
||
setLogoLoaded(false);
|
||
if (!logo) return;
|
||
const img = new Image();
|
||
img.src = logo;
|
||
img.onload = () => setLogoLoaded(true);
|
||
}, [logo]);
|
||
|
||
// Send theme to iframe
|
||
useEffect(() => {
|
||
try {
|
||
const iframe = document.querySelector('iframe');
|
||
const cw = iframe && iframe.contentWindow;
|
||
if (cw) {
|
||
cw.postMessage({ themeMode: actualTheme }, '*');
|
||
}
|
||
} catch (e) {
|
||
// Silently ignore cross-origin or access errors
|
||
}
|
||
}, [actualTheme]);
|
||
|
||
// Language change effect
|
||
useEffect(() => {
|
||
const handleLanguageChanged = (lng) => {
|
||
setCurrentLang(lng);
|
||
try {
|
||
const iframe = document.querySelector('iframe');
|
||
const cw = iframe && iframe.contentWindow;
|
||
if (cw) {
|
||
cw.postMessage({ lang: lng }, '*');
|
||
}
|
||
} catch (e) {
|
||
// Silently ignore cross-origin or access errors
|
||
}
|
||
};
|
||
|
||
i18n.on('languageChanged', handleLanguageChanged);
|
||
return () => {
|
||
i18n.off('languageChanged', handleLanguageChanged);
|
||
};
|
||
}, [i18n]);
|
||
|
||
// Actions
|
||
const logout = useCallback(async () => {
|
||
await API.get('/api/user/logout');
|
||
showSuccess(t('注销成功!'));
|
||
userDispatch({ type: 'logout' });
|
||
localStorage.removeItem('user');
|
||
navigate('/login');
|
||
}, [navigate, t, userDispatch]);
|
||
|
||
const handleLanguageChange = useCallback(
|
||
(lang) => {
|
||
i18n.changeLanguage(lang);
|
||
},
|
||
[i18n],
|
||
);
|
||
|
||
const handleThemeToggle = useCallback(
|
||
(newTheme) => {
|
||
if (
|
||
!newTheme ||
|
||
(newTheme !== 'light' && newTheme !== 'dark' && newTheme !== 'auto')
|
||
) {
|
||
return;
|
||
}
|
||
setTheme(newTheme);
|
||
},
|
||
[setTheme],
|
||
);
|
||
|
||
const handleMobileMenuToggle = useCallback(() => {
|
||
if (isMobile) {
|
||
onMobileMenuToggle();
|
||
} else {
|
||
toggleCollapsed();
|
||
}
|
||
}, [isMobile, onMobileMenuToggle, toggleCollapsed]);
|
||
|
||
return {
|
||
// State
|
||
userState,
|
||
statusState,
|
||
isMobile,
|
||
collapsed,
|
||
logoLoaded,
|
||
currentLang,
|
||
location,
|
||
isLoading,
|
||
systemName,
|
||
logo,
|
||
isNewYear,
|
||
isSelfUseMode,
|
||
docsLink,
|
||
isDemoSiteMode,
|
||
isConsoleRoute,
|
||
theme,
|
||
drawerOpen,
|
||
headerNavModules,
|
||
pricingRequireAuth,
|
||
|
||
// Actions
|
||
logout,
|
||
handleLanguageChange,
|
||
handleThemeToggle,
|
||
handleMobileMenuToggle,
|
||
navigate,
|
||
t,
|
||
};
|
||
};
|