Compare commits

...

18 Commits
v1.4.4 ... dev

Author SHA1 Message Date
LTbinglingfeng
6c2cd761ba refactor(core): harden API parsing and improve type safety 2026-02-08 09:42:00 +08:00
LTbinglingfeng
3783bec983 fix(auth-files): refresh OAuth excluded/model-alias state when returning to Auth Files page 2026-02-07 22:37:12 +08:00
Supra4E8C
b90239d39c Merge pull request #95 from router-for-me/revert-93-claude-quota
Revert "feat(ui): added claude quota display"
2026-02-07 14:01:16 +08:00
Supra4E8C
f8d66917fd Revert "feat(ui): added claude quota display" 2026-02-07 14:00:50 +08:00
LTbinglingfeng
36bfd0fa6a chore(i18n): align en/ru OAuth disablement wording with updated zh-CN copy 2026-02-07 12:43:56 +08:00
LTbinglingfeng
709ce4c8dd feat(config): warn restart required when commercial mode changes 2026-02-07 12:31:17 +08:00
LTbinglingfeng
525b152a76 fix(config): preserve mobile scroll after API key modal close and add one-click key copy 2026-02-07 12:22:16 +08:00
LTbinglingfeng
e053854544 feat(system): redesign system info page and move request-log controls from layout footer 2026-02-07 12:03:40 +08:00
LTbinglingfeng
0b54b6de64 fix(auth-files): add Kimi to OAuth quick-fill provider tags 2026-02-07 10:57:52 +08:00
hkfires
0c8686cefa feat(i18n): update OAuth exclusion terminology to "禁用" for clarity 2026-02-07 07:52:12 +08:00
LTbinglingfeng
385117d01a fix(i18n): switch language via popover menu and complete Russian Kimi translations 2026-02-07 01:13:11 +08:00
LTbinglingfeng
700bff1d03 fix(i18n): harden language switching and enforce language list consistency 2026-02-07 00:43:36 +08:00
Supra4E8C
680b24026c Merge pull request #91 from unchase/feat/ru-localization
Feat: Add Russian localization
2026-02-07 00:24:35 +08:00
Chebotov Nickolay
50ab96c3ed feat: add language dropdown 2026-02-06 15:20:25 +03:00
Chebotov Nickolay
0bb8090686 fix: address language review feedback 2026-02-06 15:08:53 +03:00
Chebotov Nickolay
d5ccef8b24 chore: restore package lock 2026-02-06 12:29:23 +03:00
Chebotov Nickolay
ad6a3bd732 feat: expand Russian localization 2026-02-06 12:26:46 +03:00
Chebotov Nickolay
ad1387d076 feat(i18n): add Russian locale and enable 'ru' language; translate core keys to Russian 2026-02-06 11:26:32 +03:00
55 changed files with 2508 additions and 653 deletions

View File

@@ -1,14 +1,13 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import './PageTransition.scss';
interface PageTransitionProps {
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
location: Location;
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
export function PageTransition({
render,
getRouteOrder,

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
export type LayerStatus = 'current' | 'exiting' | 'stacked';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}

View File

@@ -6,6 +6,7 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import type {
PayloadFilterRule,
@@ -201,6 +202,7 @@ function ApiKeysCardEditor({
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const apiKeys = useMemo(
() =>
value
@@ -263,6 +265,34 @@ function ApiKeysCardEditor({
closeModal();
};
const handleCopy = async (apiKey: string) => {
const copyByExecCommand = () => {
const textarea = document.createElement('textarea');
textarea.value = apiKey;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) throw new Error('copy_failed');
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(apiKey);
} else {
copyByExecCommand();
}
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification(t('notification.copy_failed'), 'error');
}
};
return (
<div className="form-group" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
@@ -294,6 +324,9 @@ function ApiKeysCardEditor({
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
{t('common.copy')}
</Button>
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
{t('config_management.visual.common.edit')}
</Button>

View File

@@ -10,8 +10,6 @@ import {
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
@@ -33,8 +31,10 @@ import {
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
@@ -172,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
};
export function MainLayout() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
@@ -241,7 +233,7 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏)对齐到内容区而非整窗
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
@@ -269,6 +261,7 @@ export function MainLayout() {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
document.documentElement.style.removeProperty('--content-center-x');
};
}, []);
@@ -286,18 +279,30 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
if (!languageMenuOpen) {
return;
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
const handlePointerDown = (event: MouseEvent) => {
if (!languageMenuRef.current?.contains(event.target as Node)) {
setLanguageMenuOpen(false);
}
};
}, []);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLanguageMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [languageMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
@@ -312,59 +317,20 @@ export function MainLayout() {
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
return;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
setLanguage(nextLanguage);
setLanguageMenuOpen(false);
},
[setLanguage]
);
useEffect(() => {
fetchConfig().catch(() => {
@@ -475,7 +441,8 @@ export function MainLayout() {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
@@ -493,8 +460,11 @@ export function MainLayout() {
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: any) {
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
@@ -566,9 +536,36 @@ export function MainLayout() {
>
{headerIcons.update}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
@@ -612,57 +609,8 @@ export function MainLayout() {
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);

View File

@@ -1,7 +1,7 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -135,8 +135,9 @@ export function ProviderNav() {
window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
handleScroll();
const raf = requestAnimationFrame(handleScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll);
@@ -168,7 +169,8 @@ export function ProviderNav() {
useLayoutEffect(() => {
if (!shouldShow) return;
updateIndicator(activeProvider);
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
return () => cancelAnimationFrame(raf);
}, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.

View File

@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const scrollLockSnapshot = {
scrollY: 0,
contentScrollTop: 0,
contentEl: null as HTMLElement | null,
bodyPosition: '',
bodyTop: '',
bodyLeft: '',
bodyRight: '',
bodyWidth: '',
bodyOverflow: '',
htmlOverflow: '',
};
const resolveContentScrollContainer = () => {
if (typeof document === 'undefined') return null;
const contentEl = document.querySelector('.content');
return contentEl instanceof HTMLElement ? contentEl : null;
};
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const contentEl = resolveContentScrollContainer();
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
scrollLockSnapshot.contentEl = contentEl;
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
scrollLockSnapshot.bodyPosition = body.style.position;
scrollLockSnapshot.bodyTop = body.style.top;
scrollLockSnapshot.bodyLeft = body.style.left;
scrollLockSnapshot.bodyRight = body.style.right;
scrollLockSnapshot.bodyWidth = body.style.width;
scrollLockSnapshot.bodyOverflow = body.style.overflow;
scrollLockSnapshot.htmlOverflow = html.style.overflow;
body.classList.add(MODAL_LOCK_CLASS);
html.classList.add(MODAL_LOCK_CLASS);
body.style.position = 'fixed';
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
}
activeModalCount += 1;
};
@@ -29,8 +71,31 @@ const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const scrollY = scrollLockSnapshot.scrollY;
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
const contentEl = scrollLockSnapshot.contentEl;
body.classList.remove(MODAL_LOCK_CLASS);
html.classList.remove(MODAL_LOCK_CLASS);
body.style.position = scrollLockSnapshot.bodyPosition;
body.style.top = scrollLockSnapshot.bodyTop;
body.style.left = scrollLockSnapshot.bodyLeft;
body.style.right = scrollLockSnapshot.bodyRight;
body.style.width = scrollLockSnapshot.bodyWidth;
body.style.overflow = scrollLockSnapshot.bodyOverflow;
html.style.overflow = scrollLockSnapshot.htmlOverflow;
if (contentEl) {
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
}
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
scrollLockSnapshot.scrollY = 0;
scrollLockSnapshot.contentScrollTop = 0;
scrollLockSnapshot.contentEl = null;
}
};

View File

@@ -45,8 +45,8 @@ export function useUsageData(): UseUsageDataReturn {
setError('');
try {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
const payload = (data?.usage ?? data) as unknown;
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);

View File

@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
successMessage?: string;
}
export function useApi<T = any, Args extends any[] = any[]>(
export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
apiFunction: (...args: Args) => Promise<T>,
options: UseApiOptions<T> = {}
) {
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
options.onSuccess?.(result);
return result;
} catch (err) {
const errorObj = err as Error;
} catch (err: unknown) {
const errorObj =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
setError(errorObj);
if (options.showErrorNotification !== false) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type {
PayloadFilterRule,
@@ -123,20 +123,47 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp
return { valueType: 'string', value: String(raw ?? '') };
}
const PAYLOAD_PROTOCOL_VALUES = [
'openai',
'openai-response',
'gemini',
'claude',
'codex',
'antigravity',
] as const;
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
if (typeof raw !== 'string') return undefined;
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
? (raw as PayloadProtocol)
: undefined;
}
function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: (rule as any)?.params
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => {
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRecord = asRecord(record.params);
const params = paramsRecord
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
const parsedValue = parsePayloadParamValue(value);
return {
id: `param-${index}-${pIndex}`,
@@ -145,41 +172,55 @@ function parsePayloadRules(rules: unknown): PayloadRule[] {
value: parsedValue.value,
};
})
: [],
}));
: [];
return { id: `payload-rule-${index}`, models, params };
});
}
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-filter-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `filter-model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
}));
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `filter-model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRaw = record.params;
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
return { id: `payload-filter-rule-${index}`, models, params };
});
}
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params: Record<string, any> = {};
const params: Record<string, unknown> = {};
for (const param of rule.params || []) {
if (!param.path?.trim()) continue;
let value: any = param.value;
let value: unknown = param.value;
if (param.valueType === 'number') {
const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value;
@@ -200,13 +241,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
.filter((rule) => rule.models.length > 0);
}
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
function serializePayloadFilterRulesForYaml(
rules: PayloadFilterRule[]
): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
@@ -225,33 +268,45 @@ export function useVisualConfig() {
...DEFAULT_VISUAL_VALUES,
});
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
}, [visualValues]);
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
}, [baselineValues, visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const parsed: any = parseYaml(yamlContent) || {};
const parsedRaw: unknown = parseYaml(yamlContent) || {};
const parsed = asRecord(parsedRaw) ?? {};
const tls = asRecord(parsed.tls);
const remoteManagement = asRecord(parsed['remote-management']);
const quotaExceeded = asRecord(parsed['quota-exceeded']);
const routing = asRecord(parsed.routing);
const payload = asRecord(parsed.payload);
const streaming = asRecord(parsed.streaming);
const newValues: VisualConfigValues = {
host: parsed.host || '',
host: typeof parsed.host === 'string' ? parsed.host : '',
port: String(parsed.port ?? ''),
tlsEnable: Boolean(parsed.tls?.enable),
tlsCert: parsed.tls?.cert || '',
tlsKey: parsed.tls?.key || '',
tlsEnable: Boolean(tls?.enable),
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey:
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmPanelRepo:
parsed['remote-management']?.['panel-github-repository'] ??
parsed['remote-management']?.['panel-repo'] ??
'',
typeof remoteManagement?.['panel-github-repository'] === 'string'
? remoteManagement['panel-github-repository']
: typeof remoteManagement?.['panel-repo'] === 'string'
? remoteManagement['panel-repo']
: '',
authDir: parsed['auth-dir'] || '',
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: parseApiKeysText(parsed['api-keys']),
debug: Boolean(parsed.debug),
@@ -260,35 +315,36 @@ export function useVisualConfig() {
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: parsed['proxy-url'] || '',
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
quotaExceeded?.['switch-preview-model'] ?? true
),
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
routingStrategy:
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadOverrideRules: parsePayloadRules(payload?.override),
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
streaming: {
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
},
};
setVisualValuesState(newValues);
baselineValues.current = deepClone(newValues);
setBaselineValues(deepClone(newValues));
} catch {
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
}
}, []);
@@ -331,7 +387,7 @@ export function useVisualConfig() {
}
setString(parsed, 'auth-dir', values.authDir);
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
@@ -419,7 +475,7 @@ export function useVisualConfig() {
return currentYaml;
}
},
[visualValues]
[baselineValues, visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
@@ -444,6 +500,7 @@ export function useVisualConfig() {
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'openai-response', label: 'OpenAI Response' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },

View File

@@ -6,12 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
en: { translation: en }
en: { translation: en },
ru: { translation: ru }
},
lng: getInitialLanguage(),
fallbackLng: 'zh-CN',

View File

@@ -405,8 +405,8 @@
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
"models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth",
"models_excluded_badge": "Disabled",
"models_excluded_hint": "This OAuth model is disabled",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
@@ -490,43 +490,43 @@
"result_file": "Persisted file"
},
"oauth_excluded": {
"title": "OAuth Excluded Models",
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Exclusion",
"add_title": "Add provider exclusion",
"edit_title": "Edit exclusions for {{provider}}",
"title": "OAuth Model Disablement",
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Disablement",
"add_title": "Add provider model disablement",
"edit_title": "Edit model disablement for {{provider}}",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"provider_label": "Provider",
"provider_auto": "Follow current filter",
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_label": "Models to disable",
"models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"models_loaded": "{{count}} models loaded. Check the models to disable.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",
"save_failed": "Failed to update excluded models",
"save_success": "Model disablement updated",
"save_failed": "Failed to update model disablement",
"delete": "Delete Provider",
"delete_confirm": "Delete the exclusion list for {{provider}}?",
"delete_success": "Exclusion list removed",
"delete_failed": "Failed to delete exclusion list",
"delete_confirm": "Delete model disablement for {{provider}}?",
"delete_success": "Provider model disablement removed",
"delete_failed": "Failed to delete model disablement",
"deleting": "Deleting...",
"no_models": "No excluded models",
"model_count": "{{count}} models excluded",
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
"disconnected": "Connect to the server to view exclusions",
"load_failed": "Failed to load exclusion list",
"no_models": "No disabled models configured",
"model_count": "{{count}} models disabled",
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
"disconnected": "Connect to the server to view model disablement",
"load_failed": "Failed to load model disablement",
"provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers",
"scope_provider": "Scope: {{provider}}",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
},
"oauth_model_alias": {
"title": "OAuth Model Aliases",
@@ -887,9 +887,9 @@
"debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode",
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency",
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to rotating files",
"logging_to_file_desc": "Save logs to files",
"usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)"
@@ -989,6 +989,7 @@
},
"system_info": {
"title": "Management Center Info",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "Connection Status",
"api_status_label": "API Status:",
"config_status_label": "Config Status:",
@@ -1093,12 +1094,15 @@
"gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key",
"claude_api_key": "Claude API key",
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
"copy_failed": "Copy failed",
"link_copied": "Link copied to clipboard"
},
"language": {
"switch": "Language",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "Theme",

1130
src/i18n/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -405,8 +405,8 @@
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
"models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除",
"models_excluded_badge": "已禁用",
"models_excluded_hint": "此 OAuth 模型已被禁用",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
@@ -490,43 +490,43 @@
"result_file": "存储文件"
},
"oauth_excluded": {
"title": "OAuth 排除列表",
"title": "OAuth 模型禁用",
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
"add": "新增排除",
"add_title": "新增提供商排除列表",
"edit_title": "编辑 {{provider}} 的排除列表",
"add": "新增禁用",
"add_title": "新增提供商模型禁用",
"edit_title": "编辑 {{provider}} 的模型禁用",
"refresh": "刷新",
"refreshing": "刷新中...",
"provider_label": "提供商",
"provider_auto": "跟随当前过滤",
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_label": "禁用的模型",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
"save_failed": "更新排除列表失败",
"save_success": "模型禁用已更新",
"save_failed": "更新模型禁用失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
"delete_success": "已删除该提供商的排除列表",
"delete_failed": "删除排除列表失败",
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
"delete_success": "已删除该提供商的模型禁用",
"delete_failed": "删除模型禁用失败",
"deleting": "正在删除...",
"no_models": "未配置排除模型",
"model_count": "排除 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
"disconnected": "请先连接服务器以查看排除列表",
"load_failed": "加载排除列表失败",
"no_models": "未配置禁用模型",
"model_count": "禁用 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
"disconnected": "请先连接服务器以查看模型禁用",
"load_failed": "加载模型禁用失败",
"provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{{provider}}",
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"oauth_model_alias": {
"title": "OAuth 模型别名",
@@ -887,9 +887,9 @@
"debug": "调试模式",
"debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式",
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到滚动文件",
"logging_to_file_desc": "将日志保存到文件",
"usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)"
@@ -989,6 +989,7 @@
},
"system_info": {
"title": "管理中心信息",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "连接状态",
"api_status_label": "API 状态:",
"config_status_label": "配置状态:",
@@ -1093,12 +1094,15 @@
"gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥",
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
"copy_failed": "复制失败",
"link_copied": "已复制"
},
"language": {
"switch": "语言",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "主题",

View File

@@ -243,7 +243,7 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, loading, testModel]);
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {

View File

@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];

View File

@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];

View File

@@ -785,7 +785,7 @@
border-radius: $radius-md;
}
// OAuth 排除列表
// OAuth 模型禁用
.excludedList {
display: flex;
flex-direction: column;
@@ -833,7 +833,7 @@
flex-shrink: 0;
}
// OAuth 排除列表表单:提供商快捷标签
// OAuth 模型禁用表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;

View File

@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -251,6 +252,8 @@ export function AuthFilesPage() {
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const navigate = useNavigate();
const [files, setFiles] = useState<AuthFileItem[]>([]);
@@ -507,7 +510,7 @@ export function AuthFilesPage() {
}
}, []);
// 加载 OAuth 排除列表
// 加载 OAuth 模型禁用
const loadExcluded = useCallback(async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
@@ -566,14 +569,15 @@ export function AuthFilesPage() {
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
if (!isCurrentLayer) return;
loadFiles();
loadKeyStats();
loadExcluded();
loadModelAlias();
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
// 提取所有存在的类型
const existingTypes = useMemo(() => {
@@ -1471,11 +1475,14 @@ export function AuthFilesPage() {
return GEMINI_CLI_CONFIG;
};
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
if (type === 'antigravity') return antigravityQuota[fileName];
if (type === 'codex') return codexQuota[fileName];
return geminiCliQuota[fileName];
};
const getQuotaState = useCallback(
(type: QuotaProviderType, fileName: string) => {
if (type === 'antigravity') return antigravityQuota[fileName];
if (type === 'codex') return codexQuota[fileName];
return geminiCliQuota[fileName];
},
[antigravityQuota, codexQuota, geminiCliQuota]
);
const updateQuotaState = useCallback(
(
@@ -1872,7 +1879,7 @@ export function AuthFilesPage() {
)}
</Card>
{/* OAuth 排除列表卡片 */}
{/* OAuth 模型禁用卡片 */}
<Card
title={t('oauth_excluded.title')}
extra={
@@ -2106,7 +2113,7 @@ export function AuthFilesPage() {
title={
isExcluded
? t('auth_files.models_excluded_hint', {
defaultValue: '此模型已被 OAuth 排除',
defaultValue: '此 OAuth 模型已被禁用',
})
: t('common.copy', { defaultValue: '点击复制' })
}
@@ -2118,7 +2125,7 @@ export function AuthFilesPage() {
{model.type && <span className={styles.modelType}>{model.type}</span>}
{isExcluded && (
<span className={styles.modelExcludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
</div>

View File

@@ -5,6 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml } from 'yaml';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -17,6 +18,16 @@ import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
} catch {
return false;
}
}
export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
@@ -78,13 +89,19 @@ export function ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
const previousCommercialMode = readCommercialModeFromYaml(content);
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
await configFileApi.saveConfigYaml(nextContent);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setContent(latestContent);
loadVisualValuesFromYaml(latestContent);
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');

View File

@@ -62,14 +62,23 @@ export function DashboardPage() {
apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]);
const normalizeApiKeyList = (input: any): string[] => {
const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
const record =
item !== null && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const value =
typeof item === 'string'
? item
: record
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
: '';
const trimmed = String(value ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);

View File

@@ -167,9 +167,24 @@
font-size: 14px;
}
// 语言切换按钮
.languageBtn {
// 语言下拉选择
.languageSelect {
white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
// 连接信息框

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import type { ApiError } from '@/types';
import styles from './LoginPage.module.scss';
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
/**
* 将 API 错误转换为本地化的用户友好消息
*/
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
const apiError = error as ApiError;
const status = apiError?.status;
const code = apiError?.code;
const message = apiError?.message || '';
type RedirectState = { from?: { pathname?: string } };
function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
const apiError = error as Partial<ApiError>;
const status = typeof apiError.status === 'number' ? apiError.status : undefined;
const code = typeof apiError.code === 'string' ? apiError.code : undefined;
const message =
error instanceof Error
? error.message
: typeof apiError.message === 'string'
? apiError.message
: typeof error === 'string'
? error
: '';
// 根据 HTTP 状态码判断
if (status === 401) {
@@ -59,7 +70,7 @@ export function LoginPage() {
const location = useLocation();
const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
@@ -78,7 +89,16 @@ export function LoginPage() {
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
setLanguage(selectedLanguage);
},
[setLanguage]
);
useEffect(() => {
const init = async () => {
@@ -88,7 +108,7 @@ export function LoginPage() {
setAutoLoginSuccess(true);
// 延迟跳转,让用户看到成功动画
setTimeout(() => {
const redirect = (location.state as any)?.from?.pathname || '/';
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}, 1500);
} else {
@@ -124,7 +144,7 @@ export function LoginPage() {
});
showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true });
} catch (err: any) {
} catch (err: unknown) {
const message = getLocalizedErrorMessage(err, t);
setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
@@ -144,7 +164,7 @@ export function LoginPage() {
);
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
const redirect = (location.state as any)?.from?.pathname || '/';
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
@@ -185,17 +205,19 @@ export function LoginPage() {
<div className={styles.loginHeader}>
<div className={styles.titleRow}>
<div className={styles.title}>{t('title.login')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.languageBtn}
onClick={toggleLanguage}
<select
className={styles.languageSelect}
value={language}
onChange={handleLanguageChange}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{nextLanguageLabel}
</Button>
{LANGUAGE_ORDER.map((lang) => (
<option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
</div>
<div className={styles.subtitle}>{t('login.subtitle')}</div>
</div>

View File

@@ -56,6 +56,21 @@ interface VertexImportState {
result?: VertexImportResult;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (isRecord(error) && typeof error.message === 'string') return error.message;
return typeof error === 'string' ? error : '';
}
function getErrorStatus(error: unknown): number | undefined {
if (!isRecord(error)) return undefined;
return typeof error.status === 'number' ? error.status : undefined;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
@@ -127,8 +142,8 @@ export function OAuthPage() {
window.clearInterval(timer);
delete timers.current[provider];
}
} catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
} catch (err: unknown) {
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
window.clearInterval(timer);
delete timers.current[provider];
}
@@ -159,9 +174,13 @@ export function OAuthPage() {
if (res.state) {
startPolling(provider, res.state);
}
} catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const message = getErrorMessage(err);
updateProviderState(provider, { status: 'error', error: message, polling: false });
showNotification(
`${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`,
'error'
);
}
};
@@ -190,13 +209,15 @@ export function OAuthPage() {
await oauthApi.submitCallback(provider, redirectUrl);
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
} catch (err: any) {
} catch (err: unknown) {
const status = getErrorStatus(err);
const message = getErrorMessage(err);
const errorMessage =
err?.status === 404
status === 404
? t('auth_login.oauth_callback_upgrade_hint', {
defaultValue: 'Please update CLI Proxy API or check the connection.'
})
: err?.message;
: message || undefined;
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
@@ -236,15 +257,19 @@ export function OAuthPage() {
}));
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
}
} catch (err: any) {
if (err?.status === 409) {
} catch (err: unknown) {
if (getErrorStatus(err) === 409) {
const message = t('auth_login.iflow_cookie_config_duplicate');
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
showNotification(message, 'warning');
return;
}
setIflowCookie((prev) => ({ ...prev, loading: false, error: err?.message, errorType: 'error' }));
showNotification(`${t('auth_login.iflow_cookie_start_error')} ${err?.message || ''}`, 'error');
const message = getErrorMessage(err);
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
showNotification(
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
'error'
);
}
};
@@ -292,8 +317,8 @@ export function OAuthPage() {
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
} catch (err: unknown) {
const message = getErrorMessage(err);
setVertexState((prev) => ({
...prev,
loading: false,

View File

@@ -15,6 +15,108 @@
gap: $spacing-xl;
}
.aboutCard {
overflow: hidden;
}
.aboutHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: $spacing-md;
padding: $spacing-lg 0 $spacing-xl;
}
.aboutLogo {
width: 108px;
height: 108px;
border-radius: 26px;
object-fit: cover;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.aboutTitle {
width: min(100%, 920px);
font-size: clamp(28px, 4.2vw, 44px);
font-weight: 800;
line-height: 1.12;
color: var(--text-primary);
letter-spacing: -0.02em;
text-align: center;
text-wrap: balance;
white-space: normal;
overflow-wrap: anywhere;
}
.aboutInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $spacing-md;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.infoTile {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 120px;
padding: $spacing-md $spacing-lg;
border-radius: $radius-lg;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
text-align: left;
}
.tapTile {
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
color: inherit;
padding: $spacing-md $spacing-lg;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
}
&:active {
transform: translateY(0);
}
}
.tileLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.tileValue {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
word-break: break-word;
}
.tileSub {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.aboutActions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-lg;
}
.section {
display: flex;
flex-direction: column;
@@ -231,3 +333,29 @@
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.aboutLogo {
width: 92px;
height: 92px;
border-radius: 22px;
}
.aboutTitle {
width: min(100%, 24ch);
font-size: clamp(22px, 6.6vw, 34px);
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.012em;
}
}
@media (max-width: 520px) {
.aboutTitle {
width: min(100%, 19ch);
font-size: clamp(20px, 7.2vw, 28px);
font-weight: 600;
line-height: 1.22;
letter-spacing: -0.006em;
}
}

View File

@@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
import { configApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import iconGemini from '@/assets/icons/gemini.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -39,6 +43,8 @@ export function SystemPage() {
const auth = useAuthStore();
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const models = useModelsStore((state) => state.models);
const modelsLoading = useModelsStore((state) => state.loading);
@@ -46,14 +52,29 @@ export function SystemPage() {
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const apiKeysCache = useRef<string[]>([]);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const otherLabel = useMemo(
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
[i18n.language]
);
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config);
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
const buildTime = auth.serverBuildDate
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown');
const getIconForCategory = (categoryId: string): string | null => {
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
@@ -62,14 +83,23 @@ export function SystemPage() {
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
};
const normalizeApiKeyList = (input: any): string[] => {
const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
const record =
item !== null && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const value =
typeof item === 'string'
? item
: record
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
: '';
const trimmed = String(value ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);
@@ -130,9 +160,12 @@ export function SystemPage() {
type: hasModels ? 'success' : 'warning',
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
});
} catch (err: any) {
const message = `${t('system_info.models_error')}: ${err?.message || ''}`;
setModelStatus({ type: 'error', message });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const suffix = message ? `: ${message}` : '';
const text = `${t('system_info.models_error')}${suffix}`;
setModelStatus({ type: 'error', message: text });
}
};
@@ -152,12 +185,85 @@ export function SystemPage() {
});
};
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleInfoVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
versionTapTimer.current = null;
openRequestLogModal();
return;
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
versionTapTimer.current = null;
}, 1500);
}, [openRequestLogModal]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
updateConfigValue('request-log', previous);
showNotification(
`${t('notification.update_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore
});
}, [fetchConfig]);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
useEffect(() => {
fetchModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -167,33 +273,43 @@ export function SystemPage() {
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
<div className={styles.content}>
<Card
title={t('system_info.connection_status_title')}
extra={
<Card className={styles.aboutCard}>
<div className={styles.aboutHeader}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
</div>
<div className={styles.aboutInfoGrid}>
<button
type="button"
className={`${styles.infoTile} ${styles.tapTile}`}
onClick={handleInfoVersionTap}
>
<div className={styles.tileLabel}>{t('footer.version')}</div>
<div className={styles.tileValue}>{appVersion}</div>
</button>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<div className={styles.tileValue}>{apiVersion}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
<div className={styles.tileValue}>{buildTime}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('connection.status')}</div>
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
</div>
</div>
<div className={styles.aboutActions}>
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
}
>
<div className="grid cols-2">
<div className="stat-card">
<div className="stat-label">{t('connection.server_address')}</div>
<div className="stat-value">{auth.apiBase || '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.api_version')}</div>
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.build_date')}</div>
<div className="stat-value">
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('connection.status')}</div>
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
</div>
</div>
</Card>
@@ -312,6 +428,40 @@ export function SystemPage() {
</div>
</Card>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -19,7 +19,7 @@ export const ampcodeApi = {
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
const data = await apiClient.get('/ampcode/model-mappings');
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
return normalizeAmpcodeModelMappings(list);
},

View File

@@ -13,14 +13,14 @@ export interface ApiCallRequest {
data?: string;
}
export interface ApiCallResult<T = any> {
export interface ApiCallResult<T = unknown> {
statusCode: number;
header: Record<string, string[]>;
bodyText: string;
body: T | null;
}
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
const normalizeBody = (input: unknown): { bodyText: string; body: unknown | null } => {
if (input === undefined || input === null) {
return { bodyText: '', body: null };
}
@@ -46,13 +46,24 @@ const normalizeBody = (input: unknown): { bodyText: string; body: any | null } =
};
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
const status = result.statusCode;
const body = result.body;
const bodyText = result.bodyText;
let message = '';
if (body && typeof body === 'object') {
message = body?.error?.message || body?.error || body?.message || '';
if (isRecord(body)) {
const errorValue = body.error;
if (isRecord(errorValue) && typeof errorValue.message === 'string') {
message = errorValue.message;
} else if (typeof errorValue === 'string') {
message = errorValue;
}
if (!message && typeof body.message === 'string') {
message = body.message;
}
} else if (typeof body === 'string') {
message = body;
}
@@ -71,7 +82,7 @@ export const apiCallApi = {
payload: ApiCallRequest,
config?: AxiosRequestConfig
): Promise<ApiCallResult> => {
const response = await apiClient.post('/api-call', payload, config);
const response = await apiClient.post<Record<string, unknown>>('/api-call', payload, config);
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
const { bodyText, body } = normalizeBody(response?.body);

View File

@@ -6,9 +6,9 @@ import { apiClient } from './client';
export const apiKeysApi = {
async list(): Promise<string[]> {
const data = await apiClient.get('/api-keys');
const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown;
return Array.isArray(keys) ? (keys as string[]) : [];
const data = await apiClient.get<Record<string, unknown>>('/api-keys');
const keys = data['api-keys'] ?? data.apiKeys;
return Array.isArray(keys) ? keys.map((key) => String(key)) : [];
},
replace: (keys: string[]) => apiClient.put('/api-keys', keys),

View File

@@ -171,15 +171,25 @@ export const authFilesApi = {
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
const data = await apiClient.get<Record<string, unknown>>(
`/auth-files/models?name=${encodeURIComponent(name)}`
);
const models = data.models ?? data['models'];
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
if (!normalizedChannel) return [];
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
const data = await apiClient.get<Record<string, unknown>>(
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
);
const models = data.models ?? data['models'];
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
}
};

View File

@@ -62,7 +62,10 @@ class ApiClient {
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
private readHeader(
headers: Record<string, unknown> | undefined,
keys: string[]
): string | null {
if (!headers) return null;
const normalizeValue = (value: unknown): string | null => {
@@ -75,7 +78,7 @@ class ApiClient {
return text ? text : null;
};
const headerGetter = (headers as { get?: (name: string) => any }).get;
const headerGetter = (headers as { get?: (name: string) => unknown }).get;
if (typeof headerGetter === 'function') {
for (const key of keys) {
const match = normalizeValue(headerGetter.call(headers, key));
@@ -84,8 +87,8 @@ class ApiClient {
}
const entries =
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
typeof (headers as { entries?: () => Iterable<[string, unknown]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, unknown]> }).entries())
: Object.entries(headers);
const normalized = Object.fromEntries(
@@ -147,10 +150,22 @@ class ApiClient {
/**
* 错误处理
*/
private handleError(error: any): ApiError {
private handleError(error: unknown): ApiError {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
if (axios.isAxiosError(error)) {
const responseData = error.response?.data as any;
const message = responseData?.error || responseData?.message || error.message || 'Request failed';
const responseData: unknown = error.response?.data;
const responseRecord = isRecord(responseData) ? responseData : null;
const errorValue = responseRecord?.error;
const message =
typeof errorValue === 'string'
? errorValue
: isRecord(errorValue) && typeof errorValue.message === 'string'
? errorValue.message
: typeof responseRecord?.message === 'string'
? responseRecord.message
: error.message || 'Request failed';
const apiError = new Error(message) as ApiError;
apiError.name = 'ApiError';
apiError.status = error.response?.status;
@@ -166,7 +181,9 @@ class ApiClient {
return apiError;
}
const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError;
const fallbackMessage =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error occurred';
const fallback = new Error(fallbackMessage) as ApiError;
fallback.name = 'ApiError';
return fallback;
}
@@ -174,7 +191,7 @@ class ApiClient {
/**
* GET 请求
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.get<T>(url, config);
return response.data;
}
@@ -182,7 +199,7 @@ class ApiClient {
/**
* POST 请求
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.post<T>(url, data, config);
return response.data;
}
@@ -190,7 +207,7 @@ class ApiClient {
/**
* PUT 请求
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.put<T>(url, data, config);
return response.data;
}
@@ -198,7 +215,7 @@ class ApiClient {
/**
* PATCH 请求
*/
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.patch<T>(url, data, config);
return response.data;
}
@@ -206,7 +223,7 @@ class ApiClient {
/**
* DELETE 请求
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.delete<T>(url, config);
return response.data;
}
@@ -221,7 +238,11 @@ class ApiClient {
/**
* 发送 FormData
*/
async postForm<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
async postForm<T = unknown>(
url: string,
formData: FormData,
config?: AxiosRequestConfig
): Promise<T> {
const response = await this.instance.post<T>(url, formData, {
...config,
headers: {

View File

@@ -72,8 +72,10 @@ export const configApi = {
* 获取日志总大小上限MB
*/
async getLogsMaxTotalSizeMb(): Promise<number> {
const data = await apiClient.get('/logs-max-total-size-mb');
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
const data = await apiClient.get<Record<string, unknown>>('/logs-max-total-size-mb');
const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
},
/**
@@ -91,8 +93,8 @@ export const configApi = {
* 获取强制模型前缀开关
*/
async getForceModelPrefix(): Promise<boolean> {
const data = await apiClient.get('/force-model-prefix');
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
const data = await apiClient.get<Record<string, unknown>>('/force-model-prefix');
return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false);
},
/**
@@ -104,8 +106,9 @@ export const configApi = {
* 获取路由策略
*/
async getRoutingStrategy(): Promise<string> {
const data = await apiClient.get('/routing/strategy');
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
const data = await apiClient.get<Record<string, unknown>>('/routing/strategy');
const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy;
return typeof strategy === 'string' ? strategy : 'round-robin';
},
/**

View File

@@ -10,7 +10,7 @@ export const configFileApi = {
responseType: 'text',
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
});
const data = response.data as any;
const data: unknown = response.data;
if (typeof data === 'string') return data;
if (data === undefined || data === null) return '';
return String(data);

View File

@@ -18,12 +18,22 @@ import type {
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
if (Array.isArray(data)) return data;
if (!isRecord(data)) return [];
const candidate = data[key] ?? data.items ?? data.data ?? data;
return Array.isArray(candidate) ? candidate : [];
};
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
if (!model?.name) return null;
const payload: Record<string, any> = { name: model.name };
const payload: Record<string, unknown> = { name: model.name };
if (model.alias && model.alias !== model.name) {
payload.alias = model.alias;
}
@@ -39,7 +49,7 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
: undefined;
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const payload: Record<string, any> = { 'api-key': entry.apiKey };
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
const headers = serializeHeaders(entry.headers);
if (headers) payload.headers = headers;
@@ -47,7 +57,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
};
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -74,7 +84,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
: undefined;
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -86,7 +96,7 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
};
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
const headers = serializeHeaders(config.headers);
@@ -98,7 +108,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
};
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
const payload: Record<string, any> = {
const payload: Record<string, unknown> = {
name: provider.name,
'base-url': provider.baseUrl,
'api-key-entries': Array.isArray(provider.apiKeyEntries)
@@ -118,8 +128,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
export const providersApi = {
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
const data = await apiClient.get('/gemini-api-key');
const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'gemini-api-key');
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
},
@@ -134,8 +143,7 @@ export const providersApi = {
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'codex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -150,8 +158,7 @@ export const providersApi = {
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'claude-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -166,8 +173,7 @@ export const providersApi = {
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'vertex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -182,8 +188,7 @@ export const providersApi = {
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'openai-compatibility');
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
},

View File

@@ -10,7 +10,10 @@ import type {
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
const normalizeBoolean = (value: any): boolean | undefined => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeBoolean = (value: unknown): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
@@ -22,11 +25,17 @@ const normalizeBoolean = (value: any): boolean | undefined => {
return Boolean(value);
};
const normalizeModelAliases = (models: any): ModelAlias[] => {
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
if (!Array.isArray(models)) return [];
return models
.map((item) => {
if (!item) return null;
if (item === undefined || item === null) return null;
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? ({ name: trimmed } satisfies ModelAlias) : null;
}
if (!isRecord(item)) return null;
const name = item.name || item.id || item.model;
if (!name) return null;
const alias = item.alias || item.display_name || item.displayName;
@@ -37,7 +46,10 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
entry.alias = String(alias);
}
if (priority !== undefined) {
entry.priority = Number(priority);
const parsed = Number(priority);
if (Number.isFinite(parsed)) {
entry.priority = parsed;
}
}
if (testModel) {
entry.testModel = String(testModel);
@@ -47,13 +59,17 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
.filter(Boolean) as ModelAlias[];
};
const normalizeHeaders = (headers: any) => {
const normalizeHeaders = (headers: unknown) => {
if (!headers || typeof headers !== 'object') return undefined;
const normalized = buildHeaderObject(headers as Record<string, string>);
const normalized = buildHeaderObject(
Array.isArray(headers)
? (headers as Array<{ key: string; value: string }>)
: (headers as Record<string, string | undefined | null>)
);
return Object.keys(normalized).length ? normalized : undefined;
};
const normalizeExcludedModels = (input: any): string[] => {
const normalizeExcludedModels = (input: unknown): string[] => {
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
const seen = new Set<string>();
const normalized: string[] = [];
@@ -70,20 +86,22 @@ const normalizeExcludedModels = (input: any): string[] => {
return normalized;
};
const normalizePrefix = (value: any): string | undefined => {
const normalizePrefix = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
if (entry === undefined || entry === null) return null;
const record = isRecord(entry) ? entry : null;
const apiKey =
record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl;
const headers = normalizeHeaders(entry.headers);
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
const headers = record ? normalizeHeaders(record.headers) : undefined;
return {
apiKey: trimmed,
@@ -92,33 +110,38 @@ const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
};
};
const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
if (!item) return null;
const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : '');
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
if (item === undefined || item === null) return null;
const record = isRecord(item) ? item : null;
const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(item.headers);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const models = normalizeModelAliases(item.models);
const models = normalizeModelAliases(record?.models);
if (models.length) config.models = models;
const excludedModels = normalizeExcludedModels(
item['excluded-models'] ?? item.excludedModels ?? item['excluded_models'] ?? item.excluded_models
record?.['excluded-models'] ??
record?.excludedModels ??
record?.['excluded_models'] ??
record?.excluded_models
);
if (excludedModels.length) config.excludedModels = excludedModels;
return config;
};
const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!item) return null;
let apiKey = item['api-key'] ?? item.apiKey;
const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (item === undefined || item === null) return null;
const record = isRecord(item) ? item : null;
let apiKey = record?.['api-key'] ?? record?.apiKey;
if (!apiKey && typeof item === 'string') {
apiKey = item;
}
@@ -126,19 +149,19 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels);
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
if (excludedModels.length) config.excludedModels = excludedModels;
return config;
};
const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => {
if (!provider || typeof provider !== 'object') return null;
const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => {
if (!isRecord(provider)) return null;
const name = provider.name || provider.id;
const baseUrl = provider['base-url'] ?? provider.baseUrl;
if (!name || !baseUrl) return null;
@@ -146,11 +169,11 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
let apiKeyEntries: ApiKeyEntry[] = [];
if (Array.isArray(provider['api-key-entries'])) {
apiKeyEntries = provider['api-key-entries']
.map((entry: any) => normalizeApiKeyEntry(entry))
.map((entry) => normalizeApiKeyEntry(entry))
.filter(Boolean) as ApiKeyEntry[];
} else if (Array.isArray(provider['api-keys'])) {
apiKeyEntries = provider['api-keys']
.map((key: any) => normalizeApiKeyEntry({ 'api-key': key }))
.map((key) => normalizeApiKeyEntry({ 'api-key': key }))
.filter(Boolean) as ApiKeyEntry[];
}
@@ -174,10 +197,10 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
return result;
};
const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefined => {
if (!payload || typeof payload !== 'object') return undefined;
const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | undefined => {
if (!isRecord(payload)) return undefined;
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
if (!source || typeof source !== 'object') return undefined;
if (!isRecord(source)) return undefined;
const map: Record<string, string[]> = {};
Object.entries(source).forEach(([provider, models]) => {
const key = String(provider || '').trim();
@@ -188,13 +211,13 @@ const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefi
return map;
};
const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
input.forEach((entry) => {
if (!entry || typeof entry !== 'object') return;
if (!isRecord(entry)) return;
const from = String(entry.from ?? entry['from'] ?? '').trim();
const to = String(entry.to ?? entry['to'] ?? '').trim();
if (!from || !to) return;
@@ -207,9 +230,10 @@ const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
return mappings;
};
const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
const source = payload?.ampcode ?? payload;
if (!source || typeof source !== 'object') return undefined;
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
if (!isRecord(sourceRaw)) return undefined;
const source = sourceRaw;
const config: AmpcodeConfig = {};
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
@@ -237,70 +261,94 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
/**
* 规范化 /config 返回值
*/
export const normalizeConfigResponse = (raw: any): Config => {
const config: Config = { raw: raw || {} };
if (!raw || typeof raw !== 'object') {
export const normalizeConfigResponse = (raw: unknown): Config => {
const config: Config = { raw: isRecord(raw) ? raw : {} };
if (!isRecord(raw)) {
return config;
}
config.debug = raw.debug;
config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
config.requestRetry = raw['request-retry'] ?? raw.requestRetry;
config.debug = normalizeBoolean(raw.debug);
const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
config.proxyUrl =
typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl);
const requestRetry = raw['request-retry'] ?? raw.requestRetry;
if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) {
config.requestRetry = requestRetry;
} else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') {
const parsed = Number(requestRetry);
if (Number.isFinite(parsed)) {
config.requestRetry = parsed;
}
}
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
if (quota && typeof quota === 'object') {
if (isRecord(quota)) {
config.quotaExceeded = {
switchProject: quota['switch-project'] ?? quota.switchProject,
switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
};
}
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
const routing = raw.routing;
if (routing && typeof routing === 'object') {
config.routingStrategy = routing.strategy ?? routing['strategy'];
} else {
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
config.usageStatisticsEnabled = normalizeBoolean(
raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled
);
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) {
config.logsMaxTotalSizeMb = logsMaxTotalSizeMb;
} else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') {
const parsed = Number(logsMaxTotalSizeMb);
if (Number.isFinite(parsed)) {
config.logsMaxTotalSizeMb = parsed;
}
}
config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth);
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix);
const routing = raw.routing;
const strategyRaw = isRecord(routing)
? (routing.strategy ?? routing['strategy'])
: (raw['routing-strategy'] ?? raw.routingStrategy);
if (strategyRaw !== undefined && strategyRaw !== null) {
config.routingStrategy = String(strategyRaw);
}
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
if (Array.isArray(apiKeysRaw)) {
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');
}
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
if (Array.isArray(geminiList)) {
config.geminiApiKeys = geminiList
.map((item: any) => normalizeGeminiKeyConfig(item))
.map((item) => normalizeGeminiKeyConfig(item))
.filter(Boolean) as GeminiKeyConfig[];
}
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
if (Array.isArray(codexList)) {
config.codexApiKeys = codexList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
if (Array.isArray(claudeList)) {
config.claudeApiKeys = claudeList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
if (Array.isArray(vertexList)) {
config.vertexApiKeys = vertexList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList
.map((item: any) => normalizeOpenAIProvider(item))
.map((item) => normalizeOpenAIProvider(item))
.filter(Boolean) as OpenAIProviderConfig[];
}

View File

@@ -26,7 +26,7 @@ export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
getUsage: () => apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
@@ -42,10 +42,10 @@ export const usageApi = {
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/
async getKeyStats(usageData?: any): Promise<KeyStats> {
async getKeyStats(usageData?: unknown): Promise<KeyStats> {
let payload = usageData;
if (!payload) {
const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
const response = await apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS });
payload = response?.usage ?? response;
}
return computeKeyStats(payload);

View File

@@ -5,5 +5,5 @@
import { apiClient } from './client';
export const versionApi = {
checkLatest: () => apiClient.get('/latest-version')
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
};

View File

@@ -13,7 +13,7 @@ class SecureStorageService {
/**
* 存储数据
*/
setItem(key: string, value: any, options: StorageOptions = {}): void {
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
const { encrypt = true } = options;
if (value === null || value === undefined) {
@@ -30,7 +30,7 @@ class SecureStorageService {
/**
* 获取数据
*/
getItem<T = any>(key: string, options: StorageOptions = {}): T | null {
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
const { encrypt = true } = options;
const raw = localStorage.getItem(key);
@@ -84,7 +84,7 @@ class SecureStorageService {
return;
}
let parsed: any = raw;
let parsed: unknown = raw;
try {
parsed = JSON.parse(raw);
} catch {

View File

@@ -117,10 +117,16 @@ export const useAuthStore = create<AuthStoreState>()(
} else {
localStorage.removeItem('isLoggedIn');
}
} catch (error: any) {
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Connection failed';
set({
connectionStatus: 'error',
connectionError: error.message || 'Connection failed'
connectionError: message || 'Connection failed'
});
throw error;
}

View File

@@ -10,7 +10,7 @@ import { configApi } from '@/services/api/config';
import { CACHE_EXPIRY_MS } from '@/utils/constants';
interface ConfigCache {
data: any;
data: unknown;
timestamp: number;
}
@@ -21,8 +21,11 @@ interface ConfigState {
error: string | null;
// 操作
fetchConfig: (section?: RawConfigSection, forceRefresh?: boolean) => Promise<Config | any>;
updateConfigValue: (section: RawConfigSection, value: any) => void;
fetchConfig: {
(section?: undefined, forceRefresh?: boolean): Promise<Config>;
(section: RawConfigSection, forceRefresh?: boolean): Promise<unknown>;
};
updateConfigValue: (section: RawConfigSection, value: unknown) => void;
clearCache: (section?: RawConfigSection) => void;
isCacheValid: (section?: RawConfigSection) => boolean;
}
@@ -105,7 +108,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
loading: false,
error: null,
fetchConfig: async (section, forceRefresh = false) => {
fetchConfig: (async (section?: RawConfigSection, forceRefresh: boolean = false) => {
const { cache, isCacheValid } = get();
// 检查缓存
@@ -163,10 +166,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
});
return section ? extractSectionValue(data, section) : data;
} catch (error: any) {
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch config';
if (requestId === configRequestToken) {
set({
error: error.message || 'Failed to fetch config',
error: message || 'Failed to fetch config',
loading: false
});
}
@@ -176,7 +181,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
inFlightConfigRequest = null;
}
}
},
}) as ConfigState['fetchConfig'],
updateConfigValue: (section, value) => {
set((state) => {
@@ -186,61 +191,61 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
switch (section) {
case 'debug':
nextConfig.debug = value;
nextConfig.debug = value as Config['debug'];
break;
case 'proxy-url':
nextConfig.proxyUrl = value;
nextConfig.proxyUrl = value as Config['proxyUrl'];
break;
case 'request-retry':
nextConfig.requestRetry = value;
nextConfig.requestRetry = value as Config['requestRetry'];
break;
case 'quota-exceeded':
nextConfig.quotaExceeded = value;
nextConfig.quotaExceeded = value as Config['quotaExceeded'];
break;
case 'usage-statistics-enabled':
nextConfig.usageStatisticsEnabled = value;
nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled'];
break;
case 'request-log':
nextConfig.requestLog = value;
nextConfig.requestLog = value as Config['requestLog'];
break;
case 'logging-to-file':
nextConfig.loggingToFile = value;
nextConfig.loggingToFile = value as Config['loggingToFile'];
break;
case 'logs-max-total-size-mb':
nextConfig.logsMaxTotalSizeMb = value;
nextConfig.logsMaxTotalSizeMb = value as Config['logsMaxTotalSizeMb'];
break;
case 'ws-auth':
nextConfig.wsAuth = value;
nextConfig.wsAuth = value as Config['wsAuth'];
break;
case 'force-model-prefix':
nextConfig.forceModelPrefix = value;
nextConfig.forceModelPrefix = value as Config['forceModelPrefix'];
break;
case 'routing/strategy':
nextConfig.routingStrategy = value;
nextConfig.routingStrategy = value as Config['routingStrategy'];
break;
case 'api-keys':
nextConfig.apiKeys = value;
nextConfig.apiKeys = value as Config['apiKeys'];
break;
case 'ampcode':
nextConfig.ampcode = value;
nextConfig.ampcode = value as Config['ampcode'];
break;
case 'gemini-api-key':
nextConfig.geminiApiKeys = value;
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
break;
case 'codex-api-key':
nextConfig.codexApiKeys = value;
nextConfig.codexApiKeys = value as Config['codexApiKeys'];
break;
case 'claude-api-key':
nextConfig.claudeApiKeys = value;
nextConfig.claudeApiKeys = value as Config['claudeApiKeys'];
break;
case 'vertex-api-key':
nextConfig.vertexApiKeys = value;
nextConfig.vertexApiKeys = value as Config['vertexApiKeys'];
break;
case 'openai-compatibility':
nextConfig.openaiCompatibility = value;
nextConfig.openaiCompatibility = value as Config['openaiCompatibility'];
break;
case 'oauth-excluded-models':
nextConfig.oauthExcludedModels = value;
nextConfig.oauthExcludedModels = value as Config['oauthExcludedModels'];
break;
default:
break;

View File

@@ -6,13 +6,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import i18n from '@/i18n';
import { getInitialLanguage } from '@/utils/language';
import { getInitialLanguage, isSupportedLanguage } from '@/utils/language';
interface LanguageState {
language: Language;
setLanguage: (language: Language) => void;
setLanguage: (language: string) => void;
toggleLanguage: () => void;
}
@@ -22,6 +22,9 @@ export const useLanguageStore = create<LanguageState>()(
language: getInitialLanguage(),
setLanguage: (language) => {
if (!isSupportedLanguage(language)) {
return;
}
// 切换 i18next 语言
i18n.changeLanguage(language);
set({ language });
@@ -29,12 +32,24 @@ export const useLanguageStore = create<LanguageState>()(
toggleLanguage: () => {
const { language, setLanguage } = get();
const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN';
setLanguage(newLanguage);
const currentIndex = LANGUAGE_ORDER.indexOf(language);
const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length];
setLanguage(nextLanguage);
}
}),
{
name: STORAGE_KEY_LANGUAGE
name: STORAGE_KEY_LANGUAGE,
merge: (persistedState, currentState) => {
const nextLanguage = (persistedState as Partial<LanguageState>)?.language;
if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) {
return {
...currentState,
...(persistedState as Partial<LanguageState>),
language: nextLanguage
};
}
return currentState;
}
}
)
);

View File

@@ -52,8 +52,9 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
});
return list;
} catch (error: any) {
const message = error?.message || 'Failed to fetch models';
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch models';
set({
error: message,
loading: false,

View File

@@ -190,6 +190,67 @@
gap: $spacing-xs;
flex-shrink: 0;
.language-menu {
position: relative;
display: inline-flex;
align-items: center;
.language-menu-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: $z-dropdown;
min-width: 164px;
padding: $spacing-xs;
display: flex;
flex-direction: column;
gap: 2px;
}
.language-menu-option {
width: 100%;
border: none;
border-radius: $radius-sm;
background: transparent;
color: var(--text-primary);
cursor: pointer;
padding: 8px 10px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color $transition-fast, color $transition-fast;
&:hover {
background: var(--bg-secondary);
}
&:focus-visible {
outline: none;
background: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&.active {
color: var(--primary-color);
font-weight: 600;
}
}
.language-menu-check {
font-size: 13px;
line-height: 1;
}
@media (max-width: $breakpoint-mobile) {
.language-menu-popover {
right: auto;
left: 0;
}
}
}
svg {
display: block;
}
@@ -387,27 +448,6 @@
}
}
.footer {
padding: $spacing-md $spacing-lg;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 14px;
flex-wrap: wrap;
gap: $spacing-sm;
.footer-version {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
}
.grid {
display: grid;
gap: $spacing-lg;

View File

@@ -17,8 +17,8 @@ export interface ApiClientConfig {
export interface RequestOptions {
method?: HttpMethod;
headers?: Record<string, string>;
params?: Record<string, any>;
data?: any;
params?: Record<string, unknown>;
data?: unknown;
}
// 服务器版本信息
@@ -31,6 +31,6 @@ export interface ServerVersion {
export type ApiError = Error & {
status?: number;
code?: string;
details?: any;
data?: any;
details?: unknown;
data?: unknown;
};

View File

@@ -26,7 +26,7 @@ export interface AuthFileItem {
runtimeOnly?: boolean | string;
disabled?: boolean;
modified?: number;
[key: string]: any;
[key: string]: unknown;
}
export interface AuthFilesResponse {

View File

@@ -4,7 +4,7 @@
export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en';
export type Language = 'zh-CN' | 'en' | 'ru';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
@@ -15,7 +15,7 @@ export interface Notification {
duration?: number;
}
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
data?: T;
error?: string;
message?: string;

View File

@@ -31,7 +31,7 @@ export interface Config {
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[];
oauthExcludedModels?: Record<string, string[]>;
raw?: Record<string, any>;
raw?: Record<string, unknown>;
}
export type RawConfigSection =

View File

@@ -11,7 +11,7 @@ export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
details?: any;
details?: unknown;
}
// 日志筛选

View File

@@ -43,5 +43,5 @@ export interface OpenAIProviderConfig {
models?: ModelAlias[];
priority?: number;
testModel?: string;
[key: string]: any;
[key: string]: unknown;
}

View File

@@ -10,7 +10,7 @@ export type PayloadParamEntry = {
export type PayloadModelEntry = {
id: string;
name: string;
protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity';
protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity';
};
export type PayloadRule = {

View File

@@ -3,6 +3,12 @@
* 从原项目 src/utils/constants.js 迁移
*/
import type { Language } from '@/types';
const defineLanguageOrder = <T extends readonly Language[]>(
languages: T & ([Language] extends [T[number]] ? unknown : never)
) => languages;
// 缓存过期时间(毫秒)
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
@@ -33,6 +39,15 @@ export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language';
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
// 语言配置
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
'zh-CN': 'language.chinese',
en: 'language.english',
ru: 'language.russian'
};
export const SUPPORTED_LANGUAGES = LANGUAGE_ORDER;
// 通知持续时间
export const NOTIFICATION_DURATION_MS = 3000;

View File

@@ -15,13 +15,13 @@ export function normalizeArrayResponse<T>(data: T | T[] | null | undefined): T[]
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
export function debounce<This, Args extends unknown[], Return>(
func: (this: This, ...args: Args) => Return,
delay: number
): (...args: Parameters<T>) => void {
): (this: This, ...args: Args) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: Parameters<T>) {
return function (this: This, ...args: Args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
@@ -30,13 +30,13 @@ export function debounce<T extends (...args: any[]) => any>(
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
export function throttle<This, Args extends unknown[], Return>(
func: (this: This, ...args: Args) => Return,
limit: number
): (...args: Parameters<T>) => void {
): (this: This, ...args: Args) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>) {
return function (this: This, ...args: Args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
@@ -67,16 +67,17 @@ export function generateId(): string {
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any;
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T;
const clonedObj = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = deepClone((obj as any)[key]);
const source = obj as Record<string, unknown>;
const cloned: Record<string, unknown> = {};
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
cloned[key] = deepClone(source[key]);
}
}
return clonedObj;
return cloned as unknown as T;
}
/**

View File

@@ -1,15 +1,18 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
export const isSupportedLanguage = (value: string): value is Language =>
SUPPORTED_LANGUAGES.includes(value as Language);
const parseStoredLanguage = (value: string): Language | null => {
try {
const parsed = JSON.parse(value);
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
if (candidate === 'zh-CN' || candidate === 'en') {
if (typeof candidate === 'string' && isSupportedLanguage(candidate)) {
return candidate;
}
} catch {
if (value === 'zh-CN' || value === 'en') {
if (isSupportedLanguage(value)) {
return value;
}
}
@@ -36,7 +39,10 @@ const getBrowserLanguage = (): Language => {
return 'zh-CN';
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
const lower = raw.toLowerCase();
if (lower.startsWith('zh')) return 'zh-CN';
if (lower.startsWith('ru')) return 'ru';
return 'en';
};
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();

View File

@@ -30,12 +30,15 @@ const matchCategory = (text: string) => {
return null;
};
export function normalizeModelList(payload: any, { dedupe = false } = {}): ModelInfo[] {
const toModel = (entry: any): ModelInfo | null => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export function normalizeModelList(payload: unknown, { dedupe = false } = {}): ModelInfo[] {
const toModel = (entry: unknown): ModelInfo | null => {
if (typeof entry === 'string') {
return { name: entry };
}
if (!entry || typeof entry !== 'object') {
if (!isRecord(entry)) {
return null;
}
const name = entry.id || entry.name || entry.model || entry.value;
@@ -57,7 +60,7 @@ export function normalizeModelList(payload: any, { dedupe = false } = {}): Model
if (Array.isArray(payload)) {
models = payload.map(toModel);
} else if (payload && typeof payload === 'object') {
} else if (isRecord(payload)) {
if (Array.isArray(payload.data)) {
models = payload.data.map(toModel);
} else if (Array.isArray(payload.models)) {

View File

@@ -64,7 +64,16 @@ export interface ApiStats {
const TOKENS_PER_PRICE_UNIT = 1_000_000;
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const normalizeAuthIndex = (value: any) => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
const usageRecord = isRecord(usageData) ? usageData : null;
const apisRaw = usageRecord ? usageRecord.apis : null;
return isRecord(apisRaw) ? apisRaw : null;
};
const normalizeAuthIndex = (value: unknown) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
@@ -306,24 +315,29 @@ export function formatUsd(value: number): string {
/**
* 从使用数据中收集所有请求明细
*/
export function collectUsageDetails(usageData: any): UsageDetail[] {
if (!usageData) {
return [];
}
const apis = usageData.apis || {};
export function collectUsageDetails(usageData: unknown): UsageDetail[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const details: UsageDetail[] = [];
Object.values(apis as Record<string, any>).forEach((apiEntry) => {
const models = apiEntry?.models || {};
Object.entries(models as Record<string, any>).forEach(([modelName, modelEntry]) => {
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
modelDetails.forEach((detail: any) => {
if (detail && detail.timestamp) {
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
__modelName: modelName
});
}
Object.values(apis).forEach((apiEntry) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
const models = isRecord(modelsRaw) ? modelsRaw : null;
if (!models) return;
Object.entries(models).forEach(([modelName, modelEntry]) => {
if (!isRecord(modelEntry)) return;
const modelDetailsRaw = modelEntry.details;
const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : [];
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const detail = detailRaw as unknown as UsageDetail;
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
__modelName: modelName,
});
});
});
});
@@ -333,8 +347,10 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
/**
* 从单条明细提取总 tokens
*/
export function extractTotalTokens(detail: any): number {
const tokens = detail?.tokens || {};
export function extractTotalTokens(detail: unknown): number {
const record = isRecord(detail) ? detail : null;
const tokensRaw = record?.tokens;
const tokens = isRecord(tokensRaw) ? tokensRaw : {};
if (typeof tokens.total_tokens === 'number') {
return tokens.total_tokens;
}
@@ -352,7 +368,7 @@ export function extractTotalTokens(detail: any): number {
/**
* 计算 token 分类统计
*/
export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
export function calculateTokenBreakdown(usageData: unknown): TokenBreakdown {
const details = collectUsageDetails(usageData);
if (!details.length) {
return { cachedTokens: 0, reasoningTokens: 0 };
@@ -361,8 +377,8 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
let cachedTokens = 0;
let reasoningTokens = 0;
details.forEach(detail => {
const tokens = detail?.tokens || {};
details.forEach((detail) => {
const tokens = detail.tokens;
cachedTokens += Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0
@@ -378,7 +394,10 @@ export function calculateTokenBreakdown(usageData: any): TokenBreakdown {
/**
* 计算最近 N 分钟的 RPM/TPM
*/
export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageData: any): RateStats {
export function calculateRecentPerMinuteRates(
windowMinutes: number = 30,
usageData: unknown
): RateStats {
const details = collectUsageDetails(usageData);
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 30;
@@ -391,7 +410,7 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD
let requestCount = 0;
let tokenCount = 0;
details.forEach(detail => {
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
return;
@@ -413,15 +432,16 @@ export function calculateRecentPerMinuteRates(windowMinutes: number = 30, usageD
/**
* 从使用数据获取模型名称列表
*/
export function getModelNamesFromUsage(usageData: any): string[] {
if (!usageData) {
return [];
}
const apis = usageData.apis || {};
export function getModelNamesFromUsage(usageData: unknown): string[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const names = new Set<string>();
Object.values(apis as Record<string, any>).forEach(apiEntry => {
const models = apiEntry?.models || {};
Object.keys(models).forEach(modelName => {
Object.values(apis).forEach((apiEntry) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
const models = isRecord(modelsRaw) ? modelsRaw : null;
if (!models) return;
Object.keys(models).forEach((modelName) => {
if (modelName) {
names.add(modelName);
}
@@ -433,13 +453,13 @@ export function getModelNamesFromUsage(usageData: any): string[] {
/**
* 计算成本数据
*/
export function calculateCost(detail: any, modelPrices: Record<string, ModelPrice>): number {
export function calculateCost(detail: UsageDetail, modelPrices: Record<string, ModelPrice>): number {
const modelName = detail.__modelName || '';
const price = modelPrices[modelName];
if (!price) {
return 0;
}
const tokens = detail?.tokens || {};
const tokens = detail.tokens;
const rawInputTokens = Number(tokens.input_tokens);
const rawCompletionTokens = Number(tokens.output_tokens);
const rawCachedTokensPrimary = Number(tokens.cached_tokens);
@@ -463,7 +483,7 @@ export function calculateCost(detail: any, modelPrices: Record<string, ModelPric
/**
* 计算总成本
*/
export function calculateTotalCost(usageData: any, modelPrices: Record<string, ModelPrice>): number {
export function calculateTotalCost(usageData: unknown, modelPrices: Record<string, ModelPrice>): number {
const details = collectUsageDetails(usageData);
if (!details.length || !Object.keys(modelPrices).length) {
return 0;
@@ -483,16 +503,17 @@ export function loadModelPrices(): Record<string, ModelPrice> {
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) {
return {};
}
const normalized: Record<string, ModelPrice> = {};
Object.entries(parsed).forEach(([model, price]: [string, any]) => {
Object.entries(parsed).forEach(([model, price]: [string, unknown]) => {
if (!model) return;
const promptRaw = Number(price?.prompt);
const completionRaw = Number(price?.completion);
const cacheRaw = Number(price?.cache);
const priceRecord = isRecord(price) ? price : null;
const promptRaw = Number(priceRecord?.prompt);
const completionRaw = Number(priceRecord?.completion);
const cacheRaw = Number(priceRecord?.cache);
if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) {
return;
@@ -536,21 +557,21 @@ export function saveModelPrices(prices: Record<string, ModelPrice>): void {
/**
* 获取 API 统计数据
*/
export function getApiStats(usageData: any, modelPrices: Record<string, ModelPrice>): ApiStats[] {
if (!usageData?.apis) {
return [];
}
const apis = usageData.apis;
export function getApiStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): ApiStats[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const result: ApiStats[] = [];
Object.entries(apis as Record<string, any>).forEach(([endpoint, apiData]) => {
Object.entries(apis).forEach(([endpoint, apiData]) => {
if (!isRecord(apiData)) return;
const models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }> = {};
let derivedSuccessCount = 0;
let derivedFailureCount = 0;
let totalCost = 0;
const modelsData = apiData?.models || {};
Object.entries(modelsData as Record<string, any>).forEach(([modelName, modelData]) => {
const modelsData = isRecord(apiData.models) ? apiData.models : {};
Object.entries(modelsData).forEach(([modelName, modelData]) => {
if (!isRecord(modelData)) return;
const details = Array.isArray(modelData.details) ? modelData.details : [];
const hasExplicitCounts =
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
@@ -564,46 +585,50 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
const price = modelPrices[modelName];
if (details.length > 0 && (!hasExplicitCounts || price)) {
details.forEach((detail: any) => {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!hasExplicitCounts) {
if (detail?.failed === true) {
if (detailRecord?.failed === true) {
failureCount += 1;
} else {
successCount += 1;
}
}
if (price) {
totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
if (price && detailRecord) {
totalCost += calculateCost(
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
modelPrices
);
}
});
}
models[modelName] = {
requests: modelData.total_requests || 0,
requests: Number(modelData.total_requests) || 0,
successCount,
failureCount,
tokens: modelData.total_tokens || 0
tokens: Number(modelData.total_tokens) || 0
};
derivedSuccessCount += successCount;
derivedFailureCount += failureCount;
});
const hasApiExplicitCounts =
typeof apiData?.success_count === 'number' || typeof apiData?.failure_count === 'number';
typeof apiData.success_count === 'number' || typeof apiData.failure_count === 'number';
const successCount = hasApiExplicitCounts
? (Number(apiData?.success_count) || 0)
? (Number(apiData.success_count) || 0)
: derivedSuccessCount;
const failureCount = hasApiExplicitCounts
? (Number(apiData?.failure_count) || 0)
? (Number(apiData.failure_count) || 0)
: derivedFailureCount;
result.push({
endpoint: maskUsageSensitiveValue(endpoint) || endpoint,
totalRequests: apiData.total_requests || 0,
totalRequests: Number(apiData.total_requests) || 0,
successCount,
failureCount,
totalTokens: apiData.total_tokens || 0,
totalTokens: Number(apiData.total_tokens) || 0,
totalCost,
models
});
@@ -615,7 +640,7 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
/**
* 获取模型统计数据
*/
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
export function getModelStats(usageData: unknown, modelPrices: Record<string, ModelPrice>): Array<{
model: string;
requests: number;
successCount: number;
@@ -623,18 +648,22 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
tokens: number;
cost: number;
}> {
if (!usageData?.apis) {
return [];
}
const apis = getApisRecord(usageData);
if (!apis) return [];
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
const models = apiData?.models || {};
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
Object.values(apis).forEach((apiData) => {
if (!isRecord(apiData)) return;
const modelsRaw = apiData.models;
const models = isRecord(modelsRaw) ? modelsRaw : null;
if (!models) return;
Object.entries(models).forEach(([modelName, modelData]) => {
if (!isRecord(modelData)) return;
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
existing.requests += modelData.total_requests || 0;
existing.tokens += modelData.total_tokens || 0;
existing.requests += Number(modelData.total_requests) || 0;
existing.tokens += Number(modelData.total_tokens) || 0;
const details = Array.isArray(modelData.details) ? modelData.details : [];
@@ -648,17 +677,21 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
details.forEach((detail: any) => {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!hasExplicitCounts) {
if (detail?.failed === true) {
if (detailRecord?.failed === true) {
existing.failureCount += 1;
} else {
existing.successCount += 1;
}
}
if (price) {
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
if (price && detailRecord) {
existing.cost += calculateCost(
{ ...(detailRecord as unknown as UsageDetail), __modelName: modelName },
modelPrices
);
}
});
}
@@ -700,7 +733,10 @@ export function formatDayLabel(date: Date): string {
/**
* 构建小时级别的数据序列
*/
export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
export function buildHourlySeriesByModel(
usageData: unknown,
metric: 'requests' | 'tokens' = 'requests'
): {
labels: string[];
dataByModel: Map<string, number[]>;
hasData: boolean;
@@ -728,7 +764,7 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't
return { labels, dataByModel, hasData };
}
details.forEach(detail => {
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return;
@@ -767,7 +803,10 @@ export function buildHourlySeriesByModel(usageData: any, metric: 'requests' | 't
/**
* 构建日级别的数据序列
*/
export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'tokens' = 'requests'): {
export function buildDailySeriesByModel(
usageData: unknown,
metric: 'requests' | 'tokens' = 'requests'
): {
labels: string[];
dataByModel: Map<string, number[]>;
hasData: boolean;
@@ -781,7 +820,7 @@ export function buildDailySeriesByModel(usageData: any, metric: 'requests' | 'to
return { labels: [], dataByModel: new Map(), hasData };
}
details.forEach(detail => {
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return;
@@ -885,7 +924,7 @@ const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string,
* 构建图表数据
*/
export function buildChartData(
usageData: any,
usageData: unknown,
period: 'hour' | 'day' = 'day',
metric: 'requests' | 'tokens' = 'requests',
selectedModels: string[] = []
@@ -1034,8 +1073,9 @@ export function calculateStatusBarData(
};
}
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
if (!usageData) {
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
const apis = getApisRecord(usageData);
if (!apis) {
return { bySource: {}, byAuthIndex: {} };
}
@@ -1049,17 +1089,21 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
return bucket[key];
};
const apis = usageData.apis || {};
Object.values(apis as any).forEach((apiEntry: any) => {
const models = apiEntry?.models || {};
Object.values(apis).forEach((apiEntry) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
const models = isRecord(modelsRaw) ? modelsRaw : null;
if (!models) return;
Object.values(models as any).forEach((modelEntry: any) => {
const details = modelEntry?.details || [];
Object.values(models).forEach((modelEntry) => {
if (!isRecord(modelEntry)) return;
const details = Array.isArray(modelEntry.details) ? modelEntry.details : [];
details.forEach((detail: any) => {
const source = normalizeUsageSourceId(detail?.source, masker);
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
const isFailed = detail?.failed === true;
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
const source = normalizeUsageSourceId(detailRecord?.source, masker);
const authIndexKey = normalizeAuthIndex(detailRecord?.auth_index);
const isFailed = detailRecord?.failed === true;
if (source) {
const bucket = ensureBucket(sourceStats, source);