mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
antigravity_usage.json
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||
|
||||
@@ -43,6 +43,10 @@ function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅用于首屏同步 i18n 语言
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSplashReadyToFade(true);
|
||||
|
||||
@@ -514,7 +514,7 @@ export function MainLayout() {
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span onClick={handleVersionTap}>
|
||||
<span className="footer-version" onClick={handleVersionTap}>
|
||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import { IconX } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -9,23 +9,70 @@ interface ModalProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
if (!open) return null;
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
|
||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
if (closeTimerRef.current !== null) return;
|
||||
setIsClosing(true);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
if (notifyParent) {
|
||||
onClose();
|
||||
}
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
startClose(false);
|
||||
}
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
startClose(true);
|
||||
}, [startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
|
||||
@@ -6,14 +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 { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
escapeValue: false // React 已经转义
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
"empty_title": "No Antigravity Auth Files",
|
||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_models": "No quota data available"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON Login",
|
||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||
@@ -604,8 +614,6 @@
|
||||
"request_log_download_title": "Download Request Log",
|
||||
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||
"request_log_download_success": "Request log downloaded successfully",
|
||||
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
||||
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"log_content": "Log Content",
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
"empty_title": "暂无 Antigravity 认证",
|
||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_models": "暂无额度数据"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
@@ -604,8 +614,6 @@
|
||||
"request_log_download_title": "下载报文",
|
||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
||||
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"log_content": "日志内容",
|
||||
|
||||
@@ -11,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
||||
import {
|
||||
ampcodeApi,
|
||||
apiCallApi,
|
||||
getApiCallErrorMessage,
|
||||
modelsApi,
|
||||
providersApi,
|
||||
usageApi
|
||||
} from '@/services/api';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
@@ -91,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
|
||||
const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
let trimmed = String(baseUrl || '').trim();
|
||||
if (!trimmed) return '';
|
||||
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||
trimmed = trimmed.replace(/\/+$/g, '');
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `http://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
@@ -483,7 +497,7 @@ export function AiProvidersPage() {
|
||||
.find((entry) => entry.apiKey?.trim())
|
||||
?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
|
||||
const list = await modelsApi.fetchModels(
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
baseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headers
|
||||
@@ -492,7 +506,7 @@ export function AiProvidersPage() {
|
||||
} catch (err: any) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(baseUrl);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
|
||||
setOpenaiDiscoveryModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
@@ -645,48 +659,40 @@ export function AiProvidersPage() {
|
||||
setOpenaiTestStatus('loading');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
});
|
||||
const rawText = await response.text();
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const parsed = rawText ? JSON.parse(rawText) : null;
|
||||
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
|
||||
} catch {
|
||||
if (rawText) {
|
||||
errorMessage = rawText;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setOpenaiTestStatus('success');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: any) {
|
||||
setOpenaiTestStatus('error');
|
||||
if (err?.name === 'AbortError') {
|
||||
const isTimeout =
|
||||
err?.code === 'ECONNABORTED' ||
|
||||
String(err?.message || '').toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setOpenaiTestMessage(
|
||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
);
|
||||
} else {
|
||||
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1216,6 +1222,8 @@ export function AiProvidersPage() {
|
||||
onEdit: (index: number) => void,
|
||||
onDelete: (item: T) => void,
|
||||
addLabel: string,
|
||||
emptyTitle: string,
|
||||
emptyDescription: string,
|
||||
deleteLabel?: string,
|
||||
options?: {
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
@@ -1229,8 +1237,8 @@ export function AiProvidersPage() {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.info')}
|
||||
description={t('ai_providers.gemini_empty_desc')}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={
|
||||
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
|
||||
{addLabel}
|
||||
@@ -1381,6 +1389,8 @@ export function AiProvidersPage() {
|
||||
(index) => openGeminiModal(index),
|
||||
(item) => deleteGemini(item.apiKey),
|
||||
t('ai_providers.gemini_add_button'),
|
||||
t('ai_providers.gemini_empty_title'),
|
||||
t('ai_providers.gemini_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1499,6 +1509,8 @@ export function AiProvidersPage() {
|
||||
(index) => openProviderModal('codex', index),
|
||||
(item) => deleteProviderEntry('codex', item.apiKey),
|
||||
t('ai_providers.codex_add_button'),
|
||||
t('ai_providers.codex_empty_title'),
|
||||
t('ai_providers.codex_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1633,6 +1645,8 @@ export function AiProvidersPage() {
|
||||
(index) => openProviderModal('claude', index),
|
||||
(item) => deleteProviderEntry('claude', item.apiKey),
|
||||
t('ai_providers.claude_add_button'),
|
||||
t('ai_providers.claude_empty_title'),
|
||||
t('ai_providers.claude_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1853,7 +1867,9 @@ export function AiProvidersPage() {
|
||||
},
|
||||
(index) => openOpenaiModal(index),
|
||||
(item) => deleteOpenai(item.name),
|
||||
t('ai_providers.openai_add_button')
|
||||
t('ai_providers.openai_add_button'),
|
||||
t('ai_providers.openai_empty_title'),
|
||||
t('ai_providers.openai_empty_desc')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -162,6 +162,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.antigravityControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(224, 247, 250, 0.12),
|
||||
rgba(224, 247, 250, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding-top: $spacing-sm;
|
||||
margin-top: $spacing-xs;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.quotaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaBar {
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quotaBarFill {
|
||||
height: 100%;
|
||||
background-color: var(--success-color, #22c55e);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.quotaBarFillHigh {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.quotaMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaPercent {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quotaReset {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.quotaMessage {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
// 单个认证文件卡片
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
@@ -422,6 +571,60 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// OAuth 排除列表表单:提供商快捷标签
|
||||
.providerField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.providerTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagActive {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.detailContent {
|
||||
max-height: 400px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,10 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -134,8 +133,8 @@
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
@@ -220,9 +219,9 @@
|
||||
.configCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
height: 560px;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -254,10 +253,11 @@
|
||||
}
|
||||
|
||||
.configCard {
|
||||
height: 440px;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
height: 360px;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -12,6 +12,8 @@ export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
@@ -27,6 +29,7 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -49,10 +52,6 @@ export function LoginPage() {
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
const handleUseCurrent = () => {
|
||||
setApiBase(detectedBase);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!managementKey.trim()) {
|
||||
setError(t('login.error_required'));
|
||||
@@ -79,7 +78,20 @@ export function LoginPage() {
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<div className="login-title-row">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="login-language-btn"
|
||||
onClick={toggleLanguage}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="subtitle">{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
@@ -136,14 +148,9 @@ export function LoginPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
||||
{t('login.use_current_address')}
|
||||
</Button>
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
|
||||
@@ -851,10 +851,6 @@ export function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hint">
|
||||
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="hint">{t('logs.loading')}</div>
|
||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||
|
||||
@@ -64,6 +64,9 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
||||
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||
|
||||
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
|
||||
return typeof icon === 'string' ? icon : icon[theme];
|
||||
@@ -105,12 +108,15 @@ export function OAuthPage() {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
updateProviderState(provider, { status: 'success', polling: false });
|
||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
||||
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
||||
showNotification(
|
||||
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
|
||||
'error'
|
||||
);
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
@@ -153,7 +159,7 @@ export function OAuthPage() {
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
||||
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,14 +353,14 @@ export function OAuthPage() {
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t('auth_login.codex_copy_link')}
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('auth_login.codex_open_link')}
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,10 +405,10 @@ export function OAuthPage() {
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||
{state.status === 'success'
|
||||
? t('auth_login.codex_oauth_status_success')
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
||||
: t('auth_login.codex_oauth_status_waiting')}
|
||||
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
86
src/services/api/apiCall.ts
Normal file
86
src/services/api/apiCall.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Generic API call helper (proxied via management API).
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ApiCallRequest {
|
||||
authIndex?: string;
|
||||
method: string;
|
||||
url: string;
|
||||
header?: Record<string, string>;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface ApiCallResult<T = any> {
|
||||
statusCode: number;
|
||||
header: Record<string, string[]>;
|
||||
bodyText: string;
|
||||
body: T | null;
|
||||
}
|
||||
|
||||
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||
if (input === undefined || input === null) {
|
||||
return { bodyText: '', body: null };
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { bodyText: text, body: null };
|
||||
}
|
||||
try {
|
||||
return { bodyText: text, body: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
return { bodyText: text, body: text };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { bodyText: JSON.stringify(input), body: input };
|
||||
} catch {
|
||||
return { bodyText: String(input), body: input };
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||
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 || '';
|
||||
} else if (typeof body === 'string') {
|
||||
message = body;
|
||||
}
|
||||
|
||||
if (!message && bodyText) {
|
||||
message = bodyText;
|
||||
}
|
||||
|
||||
if (status && message) return `${status} ${message}`.trim();
|
||||
if (status) return `HTTP ${status}`;
|
||||
return message || 'Request failed';
|
||||
};
|
||||
|
||||
export const apiCallApi = {
|
||||
request: async (
|
||||
payload: ApiCallRequest,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiCallResult> => {
|
||||
const response = await apiClient.post('/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);
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
header,
|
||||
bodyText,
|
||||
body
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -62,12 +62,37 @@ class ApiClient {
|
||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||
}
|
||||
|
||||
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
|
||||
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
const normalizeValue = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
|
||||
return first !== undefined ? String(first) : null;
|
||||
}
|
||||
const text = String(value);
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||
if (typeof headerGetter === 'function') {
|
||||
for (const key of keys) {
|
||||
const match = normalizeValue(headerGetter.call(headers, key));
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
const entries =
|
||||
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||
: Object.entries(headers);
|
||||
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
|
||||
entries.map(([key, value]) => [String(key).toLowerCase(), value])
|
||||
);
|
||||
for (const key of keys) {
|
||||
const match = normalized[key.toLowerCase()];
|
||||
const match = normalizeValue(normalized[key.toLowerCase()]);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './client';
|
||||
export * from './apiCall';
|
||||
export * from './config';
|
||||
export * from './configFile';
|
||||
export * from './apiKeys';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import { normalizeModelList } from '@/utils/models';
|
||||
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||
let normalized = String(baseUrl || '').trim();
|
||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
||||
});
|
||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
async fetchModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
headers: Record<string, string> = {}
|
||||
) {
|
||||
const endpoint = buildModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
method: 'GET',
|
||||
url: endpoint,
|
||||
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
const payload = result.body ?? result.bodyText;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import i18n from '@/i18n';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
interface LanguageState {
|
||||
language: Language;
|
||||
@@ -18,7 +19,7 @@ interface LanguageState {
|
||||
export const useLanguageStore = create<LanguageState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
language: 'zh-CN',
|
||||
language: getInitialLanguage(),
|
||||
|
||||
setLanguage: (language) => {
|
||||
// 切换 i18next 语言
|
||||
|
||||
@@ -350,6 +350,32 @@ textarea {
|
||||
justify-content: center;
|
||||
z-index: $z-modal;
|
||||
padding: $spacing-lg;
|
||||
|
||||
&.modal-overlay-entering {
|
||||
animation: modal-overlay-fade-in 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
&.modal-overlay-closing {
|
||||
animation: modal-overlay-fade-out 0.35s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -361,12 +387,77 @@ textarea {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
|
||||
transform-origin: calc(100% - 28px) 28px;
|
||||
|
||||
&.modal-entering {
|
||||
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
&.modal-closing {
|
||||
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-collapse-to-close {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-floating {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: $radius-full;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||
z-index: 10;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
@@ -375,30 +466,6 @@ textarea {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: $radius-md;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
@@ -242,6 +245,7 @@
|
||||
height: auto;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
overflow: visible;
|
||||
padding-top: var(--header-height);
|
||||
|
||||
@supports (min-height: 100dvh) {
|
||||
min-height: calc(100dvh - var(--header-height));
|
||||
@@ -393,6 +397,13 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page {
|
||||
@@ -420,6 +431,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
@@ -432,6 +444,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.login-language-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
|
||||
42
src/utils/language.ts
Normal file
42
src/utils/language.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
|
||||
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') {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
if (value === 'zh-CN' || value === 'en') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredLanguage = (): Language | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_LANGUAGE);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return parseStoredLanguage(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getBrowserLanguage = (): Language => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
|
||||
};
|
||||
|
||||
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
||||
Reference in New Issue
Block a user