Merge pull request #57 from router-for-me/dev
feat(i18n): add multilingual support and locale selector
This commit is contained in:
25 files changed
+3519
-676
No files matched your search
+281
-41
@@ -84,6 +84,68 @@ const APP_USER_AGENT: &str = concat!(
|
||||
static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn normalize_app_locale(locale: &str) -> &'static str {
|
||||
let normalized = locale.trim().to_ascii_lowercase();
|
||||
if normalized.starts_with("en") {
|
||||
"en"
|
||||
} else if normalized.starts_with("ja") {
|
||||
"ja"
|
||||
} else if normalized == "zh-tw"
|
||||
|| normalized == "zh-hk"
|
||||
|| normalized == "zh-mo"
|
||||
|| normalized.starts_with("zh-hant")
|
||||
{
|
||||
"zh-TW"
|
||||
} else {
|
||||
"zh-CN"
|
||||
}
|
||||
}
|
||||
|
||||
fn locale_text<'a>(locale: &str, zh_cn: &'a str, en: &'a str) -> &'a str {
|
||||
match normalize_app_locale(locale) {
|
||||
"en" => en,
|
||||
"zh-TW" => match zh_cn {
|
||||
"打开主界面" => "開啟主介面",
|
||||
"退出" => "退出",
|
||||
"内核状态:处理中" => "核心狀態:處理中",
|
||||
"内核状态:未安装" => "核心狀態:未安裝",
|
||||
"内核状态:运行中" => "核心狀態:執行中",
|
||||
"内核状态:已停止" => "核心狀態:已停止",
|
||||
"处理中..." => "處理中...",
|
||||
"停止内核" => "停止核心",
|
||||
"启动内核" => "啟動核心",
|
||||
"重启内核" => "重新啟動核心",
|
||||
"EasyCLIProxyAPI · 内核处理中" => "EasyCLIProxyAPI · 核心處理中",
|
||||
"EasyCLIProxyAPI · 内核未安装" => "EasyCLIProxyAPI · 核心未安裝",
|
||||
"EasyCLIProxyAPI · 内核运行中" => "EasyCLIProxyAPI · 核心執行中",
|
||||
"EasyCLIProxyAPI · 内核已停止" => "EasyCLIProxyAPI · 核心已停止",
|
||||
"EasyCLIProxyAPI · 内核操作失败" => "EasyCLIProxyAPI · 核心操作失敗",
|
||||
"内核状态:正在检查" => "核心狀態:正在檢查",
|
||||
_ => zh_cn,
|
||||
},
|
||||
"ja" => match zh_cn {
|
||||
"打开主界面" => "メイン画面を開く",
|
||||
"退出" => "終了",
|
||||
"内核状态:处理中" => "コア状態:処理中",
|
||||
"内核状态:未安装" => "コア状態:未インストール",
|
||||
"内核状态:运行中" => "コア状態:実行中",
|
||||
"内核状态:已停止" => "コア状態:停止済み",
|
||||
"处理中..." => "処理中...",
|
||||
"停止内核" => "コアを停止",
|
||||
"启动内核" => "コアを起動",
|
||||
"重启内核" => "コアを再起動",
|
||||
"EasyCLIProxyAPI · 内核处理中" => "EasyCLIProxyAPI · コア処理中",
|
||||
"EasyCLIProxyAPI · 内核未安装" => "EasyCLIProxyAPI · コア未インストール",
|
||||
"EasyCLIProxyAPI · 内核运行中" => "EasyCLIProxyAPI · コア実行中",
|
||||
"EasyCLIProxyAPI · 内核已停止" => "EasyCLIProxyAPI · コア停止済み",
|
||||
"EasyCLIProxyAPI · 内核操作失败" => "EasyCLIProxyAPI · コア操作失敗",
|
||||
"内核状态:正在检查" => "コア状態:確認中",
|
||||
_ => zh_cn,
|
||||
},
|
||||
_ => zh_cn,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CoreDownloadState {
|
||||
inner: Mutex<CoreDownloadInner>,
|
||||
@@ -222,6 +284,7 @@ struct CoreInstallTask {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "kebab-case")]
|
||||
struct GuiConfigFile {
|
||||
locale: String,
|
||||
port: u16,
|
||||
allow_lan: bool,
|
||||
run_on_startup: bool,
|
||||
@@ -274,6 +337,7 @@ where
|
||||
impl Default for GuiConfigFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 8317,
|
||||
allow_lan: false,
|
||||
run_on_startup: false,
|
||||
@@ -295,6 +359,7 @@ impl Default for GuiConfigFile {
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "kebab-case")]
|
||||
struct GuiConfigPresence {
|
||||
locale: Option<String>,
|
||||
auth_dir: Option<String>,
|
||||
api_keys: Option<Vec<GuiApiKeyInput>>,
|
||||
management_secret_key: Option<String>,
|
||||
@@ -793,6 +858,13 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_locale(&self, locale: String) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.locale = normalize_app_locale(&locale).to_string();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_management_secret_key(&self, secret_key: String) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.management_secret_key = secret_key;
|
||||
@@ -925,6 +997,23 @@ fn get_gui_settings(
|
||||
Ok(GuiSettings::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_app_locale(
|
||||
app: tauri::AppHandle,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
locale: String,
|
||||
) -> Result<String, String> {
|
||||
let config = gui_config_state.set_locale(locale)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Ok(status) = current_core_status(Some(process_state.inner()), Some(config.port)) {
|
||||
update_windows_tray_locale(&app, &config.locale, &status);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = (app, process_state);
|
||||
Ok(config.locale)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resolve_windows_close_request(
|
||||
app: tauri::AppHandle,
|
||||
@@ -7840,6 +7929,9 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
if presence.auth_dir.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
if presence.locale.is_none() {
|
||||
changed = true;
|
||||
}
|
||||
changed |= sanitize_gui_config(&mut config)?;
|
||||
validate_gui_config(&config)?;
|
||||
if changed {
|
||||
@@ -7910,6 +8002,11 @@ fn merge_core_api_keys_with_gui_metadata(
|
||||
|
||||
fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
||||
let mut changed = false;
|
||||
let normalized_locale = normalize_app_locale(&config.locale);
|
||||
if config.locale != normalized_locale {
|
||||
config.locale = normalized_locale.to_string();
|
||||
changed = true;
|
||||
}
|
||||
let original_api_keys = config.api_keys.clone();
|
||||
let configured_keys = config
|
||||
.api_keys
|
||||
@@ -8867,9 +8964,25 @@ fn show_macos_tray_menu<R: tauri::Runtime>(tray: &TrayIcon<R>) {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
let open_main_window =
|
||||
MenuItem::with_id(app, "open-main-window", "打开主界面", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
let locale = app
|
||||
.state::<GuiConfigState>()
|
||||
.snapshot()
|
||||
.map(|config| config.locale)
|
||||
.unwrap_or_else(|_| "zh-CN".to_string());
|
||||
let open_main_window = MenuItem::with_id(
|
||||
app,
|
||||
"open-main-window",
|
||||
locale_text(&locale, "打开主界面", "Open Main Window"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(
|
||||
app,
|
||||
"quit",
|
||||
locale_text(&locale, "退出", "Quit"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let menu = Menu::with_items(app, &[&open_main_window, &quit])?;
|
||||
let click_state = Arc::new(Mutex::new(MacosTrayClickState::default()));
|
||||
let double_click_interval = Duration::from_secs_f64(NSEvent::doubleClickInterval());
|
||||
@@ -8972,60 +9085,113 @@ enum WindowsTrayCoreAction {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct WindowsTrayPresentation {
|
||||
status_text: &'static str,
|
||||
toggle_text: &'static str,
|
||||
status_text: String,
|
||||
toggle_text: String,
|
||||
toggle_enabled: bool,
|
||||
restart_enabled: bool,
|
||||
tooltip: &'static str,
|
||||
tooltip: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_tray_presentation(status: &CoreStatus, busy: bool) -> WindowsTrayPresentation {
|
||||
fn windows_tray_presentation(
|
||||
status: &CoreStatus,
|
||||
busy: bool,
|
||||
locale: &str,
|
||||
) -> WindowsTrayPresentation {
|
||||
let status_text = if busy {
|
||||
"内核状态:处理中"
|
||||
locale_text(locale, "内核状态:处理中", "Core status: Working")
|
||||
} else if !status.installed {
|
||||
"内核状态:未安装"
|
||||
locale_text(locale, "内核状态:未安装", "Core status: Not installed")
|
||||
} else if status.running {
|
||||
"内核状态:运行中"
|
||||
locale_text(locale, "内核状态:运行中", "Core status: Running")
|
||||
} else {
|
||||
"内核状态:已停止"
|
||||
locale_text(locale, "内核状态:已停止", "Core status: Stopped")
|
||||
};
|
||||
let toggle_text = if busy {
|
||||
"处理中..."
|
||||
locale_text(locale, "处理中...", "Working...")
|
||||
} else if status.running {
|
||||
"停止内核"
|
||||
locale_text(locale, "停止内核", "Stop Core")
|
||||
} else {
|
||||
"启动内核"
|
||||
locale_text(locale, "启动内核", "Start Core")
|
||||
};
|
||||
let tooltip = if busy {
|
||||
"EasyCLIProxyAPI · 内核处理中"
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核处理中",
|
||||
"EasyCLIProxyAPI · Core working",
|
||||
)
|
||||
} else if !status.installed {
|
||||
"EasyCLIProxyAPI · 内核未安装"
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核未安装",
|
||||
"EasyCLIProxyAPI · Core not installed",
|
||||
)
|
||||
} else if status.running {
|
||||
"EasyCLIProxyAPI · 内核运行中"
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核运行中",
|
||||
"EasyCLIProxyAPI · Core running",
|
||||
)
|
||||
} else {
|
||||
"EasyCLIProxyAPI · 内核已停止"
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核已停止",
|
||||
"EasyCLIProxyAPI · Core stopped",
|
||||
)
|
||||
};
|
||||
|
||||
WindowsTrayPresentation {
|
||||
status_text,
|
||||
toggle_text,
|
||||
status_text: status_text.to_string(),
|
||||
toggle_text: toggle_text.to_string(),
|
||||
toggle_enabled: status.installed && !busy,
|
||||
restart_enabled: status.installed && status.running && !busy,
|
||||
tooltip,
|
||||
tooltip: tooltip.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct WindowsTrayState {
|
||||
open_main_window: MenuItem<tauri::Wry>,
|
||||
status_item: MenuItem<tauri::Wry>,
|
||||
toggle_core_item: MenuItem<tauri::Wry>,
|
||||
restart_core_item: MenuItem<tauri::Wry>,
|
||||
quit_item: MenuItem<tauri::Wry>,
|
||||
locale: Mutex<String>,
|
||||
busy: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl WindowsTrayState {
|
||||
fn locale(&self) -> String {
|
||||
self.locale
|
||||
.lock()
|
||||
.map(|locale| locale.clone())
|
||||
.unwrap_or_else(|_| "zh-CN".to_string())
|
||||
}
|
||||
|
||||
fn set_locale(&self, locale: &str) {
|
||||
let normalized = normalize_app_locale(locale);
|
||||
if let Ok(mut current) = self.locale.lock() {
|
||||
*current = normalized.to_string();
|
||||
}
|
||||
let labels = [
|
||||
(
|
||||
&self.open_main_window,
|
||||
locale_text(normalized, "打开主界面", "Open Main Window"),
|
||||
),
|
||||
(
|
||||
&self.restart_core_item,
|
||||
locale_text(normalized, "重启内核", "Restart Core"),
|
||||
),
|
||||
(&self.quit_item, locale_text(normalized, "退出", "Quit")),
|
||||
];
|
||||
for (item, label) in labels {
|
||||
if let Err(error) = item.set_text(label) {
|
||||
eprintln!("更新 Windows 托盘语言失败: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_action(&self) -> bool {
|
||||
if self
|
||||
.busy
|
||||
@@ -9035,10 +9201,18 @@ impl WindowsTrayState {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Err(error) = self.status_item.set_text("内核状态:处理中") {
|
||||
let locale = self.locale();
|
||||
if let Err(error) = self.status_item.set_text(locale_text(
|
||||
&locale,
|
||||
"内核状态:处理中",
|
||||
"Core status: Working",
|
||||
)) {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_text("处理中...") {
|
||||
if let Err(error) =
|
||||
self.toggle_core_item
|
||||
.set_text(locale_text(&locale, "处理中...", "Working..."))
|
||||
{
|
||||
eprintln!("更新 Windows 托盘操作文本失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_enabled(false) {
|
||||
@@ -9054,12 +9228,16 @@ impl WindowsTrayState {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn update(&self, status: &CoreStatus) -> &'static str {
|
||||
let presentation = windows_tray_presentation(status, self.busy.load(Ordering::Acquire));
|
||||
if let Err(error) = self.status_item.set_text(presentation.status_text) {
|
||||
fn update(&self, status: &CoreStatus) -> String {
|
||||
let presentation =
|
||||
windows_tray_presentation(status, self.busy.load(Ordering::Acquire), &self.locale());
|
||||
if let Err(error) = self.status_item.set_text(presentation.status_text.clone()) {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_text(presentation.toggle_text) {
|
||||
if let Err(error) = self
|
||||
.toggle_core_item
|
||||
.set_text(presentation.toggle_text.clone())
|
||||
{
|
||||
eprintln!("更新 Windows 托盘操作文本失败: {error}");
|
||||
}
|
||||
if let Err(error) = self
|
||||
@@ -9082,7 +9260,13 @@ impl WindowsTrayState {
|
||||
if error.chars().count() > 48 {
|
||||
summary.push('…');
|
||||
}
|
||||
if let Err(update_error) = self.status_item.set_text(format!("操作失败:{summary}")) {
|
||||
let text = match normalize_app_locale(&self.locale()) {
|
||||
"en" => format!("Operation failed: {summary}"),
|
||||
"zh-TW" => format!("操作失敗:{summary}"),
|
||||
"ja" => format!("操作に失敗しました:{summary}"),
|
||||
_ => format!("操作失败:{summary}"),
|
||||
};
|
||||
if let Err(update_error) = self.status_item.set_text(text) {
|
||||
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
|
||||
}
|
||||
}
|
||||
@@ -9112,23 +9296,39 @@ fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus
|
||||
let tooltip = app_handle
|
||||
.try_state::<WindowsTrayState>()
|
||||
.map(|tray_state| tray_state.update(status))
|
||||
.unwrap_or_else(|| windows_tray_presentation(status, false).tooltip);
|
||||
.unwrap_or_else(|| windows_tray_presentation(status, false, "zh-CN").tooltip);
|
||||
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(error) = tray.set_tooltip(Some(tooltip)) {
|
||||
if let Err(error) = tray.set_tooltip(Some(&tooltip)) {
|
||||
eprintln!("更新 Windows 托盘提示失败: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn update_windows_tray_locale(app_handle: &tauri::AppHandle, locale: &str, status: &CoreStatus) {
|
||||
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
|
||||
tray_state.set_locale(locale);
|
||||
}
|
||||
update_windows_tray_status(app_handle, status);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
|
||||
eprintln!("Windows 托盘内核操作失败: {error}");
|
||||
let locale = app_handle
|
||||
.try_state::<WindowsTrayState>()
|
||||
.map(|state| state.locale())
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
|
||||
tray_state.show_error(error);
|
||||
}
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(update_error) = tray.set_tooltip(Some("EasyCLIProxyAPI · 内核操作失败")) {
|
||||
if let Err(update_error) = tray.set_tooltip(Some(locale_text(
|
||||
&locale,
|
||||
"EasyCLIProxyAPI · 内核操作失败",
|
||||
"EasyCLIProxyAPI · Core operation failed",
|
||||
))) {
|
||||
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
|
||||
}
|
||||
}
|
||||
@@ -9143,7 +9343,11 @@ fn run_windows_tray_core_action(app_handle: &tauri::AppHandle, action: WindowsTr
|
||||
return;
|
||||
}
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(error) = tray.set_tooltip(Some("EasyCLIProxyAPI · 内核处理中")) {
|
||||
if let Err(error) = tray.set_tooltip(Some(locale_text(
|
||||
&tray_state.locale(),
|
||||
"EasyCLIProxyAPI · 内核处理中",
|
||||
"EasyCLIProxyAPI · Core working",
|
||||
))) {
|
||||
eprintln!("更新 Windows 托盘处理中提示失败: {error}");
|
||||
}
|
||||
}
|
||||
@@ -9198,35 +9402,46 @@ fn run_windows_tray_core_action(app_handle: &tauri::AppHandle, action: WindowsTr
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
let locale = app
|
||||
.state::<GuiConfigState>()
|
||||
.snapshot()
|
||||
.map(|config| config.locale)
|
||||
.unwrap_or_else(|_| "zh-CN".to_string());
|
||||
let open_main_window = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_OPEN_MENU_ID,
|
||||
"打开主界面",
|
||||
locale_text(&locale, "打开主界面", "Open Main Window"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let status_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_STATUS_MENU_ID,
|
||||
"内核状态:正在检查",
|
||||
locale_text(&locale, "内核状态:正在检查", "Core status: Checking"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
|
||||
"启动内核",
|
||||
locale_text(&locale, "启动内核", "Start Core"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let restart_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
|
||||
"重启内核",
|
||||
locale_text(&locale, "重启内核", "Restart Core"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(app, WINDOWS_TRAY_QUIT_MENU_ID, "退出", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_QUIT_MENU_ID,
|
||||
locale_text(&locale, "退出", "Quit"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let separator_one = PredefinedMenuItem::separator(app)?;
|
||||
let separator_two = PredefinedMenuItem::separator(app)?;
|
||||
let menu = Menu::with_items(
|
||||
@@ -9276,9 +9491,12 @@ fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
.build(app)?;
|
||||
|
||||
let _ = app.manage(WindowsTrayState {
|
||||
open_main_window,
|
||||
status_item,
|
||||
toggle_core_item,
|
||||
restart_core_item,
|
||||
quit_item: quit,
|
||||
locale: Mutex::new(locale),
|
||||
busy: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
@@ -9394,6 +9612,7 @@ fn main() {
|
||||
detect_core_platform,
|
||||
get_core_status,
|
||||
get_gui_settings,
|
||||
set_app_locale,
|
||||
resolve_windows_close_request,
|
||||
get_agent_config_statuses,
|
||||
refresh_agent_config_statuses,
|
||||
@@ -9924,28 +10143,47 @@ mod tests {
|
||||
message: String::new(),
|
||||
};
|
||||
|
||||
let missing = windows_tray_presentation(&status, false);
|
||||
let missing = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(missing.status_text, "内核状态:未安装");
|
||||
assert!(!missing.toggle_enabled);
|
||||
assert!(!missing.restart_enabled);
|
||||
|
||||
status.installed = true;
|
||||
let stopped = windows_tray_presentation(&status, false);
|
||||
let stopped = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(stopped.toggle_text, "启动内核");
|
||||
assert!(stopped.toggle_enabled);
|
||||
assert!(!stopped.restart_enabled);
|
||||
|
||||
status.running = true;
|
||||
let running = windows_tray_presentation(&status, false);
|
||||
let running = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(running.status_text, "内核状态:运行中");
|
||||
assert_eq!(running.toggle_text, "停止内核");
|
||||
assert!(running.toggle_enabled);
|
||||
assert!(running.restart_enabled);
|
||||
|
||||
let busy = windows_tray_presentation(&status, true);
|
||||
let busy = windows_tray_presentation(&status, true, "zh-CN");
|
||||
assert_eq!(busy.status_text, "内核状态:处理中");
|
||||
assert!(!busy.toggle_enabled);
|
||||
assert!(!busy.restart_enabled);
|
||||
|
||||
let english = windows_tray_presentation(&status, false, "en-US");
|
||||
assert_eq!(english.status_text, "Core status: Running");
|
||||
assert_eq!(english.toggle_text, "Stop Core");
|
||||
|
||||
let japanese = windows_tray_presentation(&status, false, "ja-JP");
|
||||
assert_eq!(japanese.status_text, "コア状態:実行中");
|
||||
assert_eq!(japanese.toggle_text, "コアを停止");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_locale_normalization_has_a_stable_chinese_fallback() {
|
||||
assert_eq!(normalize_app_locale("en"), "en");
|
||||
assert_eq!(normalize_app_locale("en-US"), "en");
|
||||
assert_eq!(normalize_app_locale("ja-JP"), "ja");
|
||||
assert_eq!(normalize_app_locale("zh-TW"), "zh-TW");
|
||||
assert_eq!(normalize_app_locale("zh-Hant-HK"), "zh-TW");
|
||||
assert_eq!(normalize_app_locale("unsupported"), "zh-CN");
|
||||
assert_eq!(GuiConfigFile::default().locale, "zh-CN");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -10645,6 +10883,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runtime_network_patch_preserves_comments_and_other_settings() {
|
||||
let config = GuiConfigFile {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
run_on_startup: false,
|
||||
@@ -10962,6 +11201,7 @@ mod tests {
|
||||
let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n";
|
||||
let current = "host: 127.0.0.1\nport: 9000\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\n";
|
||||
let config = GuiConfigFile {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
run_on_startup: false,
|
||||
|
||||
@@ -1372,6 +1372,7 @@ mod tests {
|
||||
#[test]
|
||||
fn normalizes_queue_records_without_persisting_secrets_or_headers() {
|
||||
let config = GuiConfigFile {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 8317,
|
||||
allow_lan: false,
|
||||
run_on_startup: false,
|
||||
|
||||
+111
-23
@@ -3,11 +3,14 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
FileKey,
|
||||
Gauge,
|
||||
GitFork,
|
||||
History,
|
||||
Languages,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Network,
|
||||
@@ -25,61 +28,69 @@ import { QuotaPage } from './pages/QuotaPage';
|
||||
import { AgentsPage } from './pages/AgentsPage';
|
||||
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
|
||||
import { UsageRecordsPage } from './pages/UsageRecordsPage';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'zh-CN', labelKey: 'app.language.zhCN' },
|
||||
{ value: 'zh-TW', labelKey: 'app.language.zhTW' },
|
||||
{ value: 'ja', labelKey: 'app.language.ja' },
|
||||
{ value: 'en', labelKey: 'app.language.en' },
|
||||
] as const;
|
||||
|
||||
const pages = [
|
||||
{
|
||||
id: 'kernel',
|
||||
label: '内核',
|
||||
labelKey: 'app.nav.kernel',
|
||||
icon: ServerCog,
|
||||
component: KernelPage,
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: '配置',
|
||||
labelKey: 'app.nav.config',
|
||||
icon: Settings,
|
||||
component: ConfigPanelPage,
|
||||
},
|
||||
{
|
||||
id: 'thinking-aliases',
|
||||
label: '模型别名',
|
||||
labelKey: 'app.nav.thinkingAliases',
|
||||
icon: GitFork,
|
||||
component: ThinkingAliasesPage,
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
label: 'OAuth',
|
||||
labelKey: 'app.nav.oauth',
|
||||
icon: LogIn,
|
||||
component: OAuthLoginPage,
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API 接入',
|
||||
labelKey: 'app.nav.api',
|
||||
icon: Network,
|
||||
component: ApiAccessPage,
|
||||
},
|
||||
{
|
||||
id: 'auth-files',
|
||||
label: '认证文件',
|
||||
labelKey: 'app.nav.authFiles',
|
||||
icon: FileKey,
|
||||
component: AuthFileManagementPage,
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
label: '配额',
|
||||
labelKey: 'app.nav.quota',
|
||||
icon: Gauge,
|
||||
component: QuotaPage,
|
||||
},
|
||||
{
|
||||
id: 'usage-records',
|
||||
label: '使用记录',
|
||||
labelKey: 'app.nav.usageRecords',
|
||||
icon: History,
|
||||
component: UsageRecordsPage,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
label: '智能体',
|
||||
labelKey: 'app.nav.agents',
|
||||
icon: Bot,
|
||||
component: AgentsPage,
|
||||
},
|
||||
@@ -102,13 +113,19 @@ function App() {
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [active, setActive] = useState<PageId>('kernel');
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
|
||||
const closeDialogRef = useRef<HTMLElement>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement>(null);
|
||||
const languageButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { status } = useCoreRuntime();
|
||||
const coreRunning = Boolean(status?.running);
|
||||
const activePage = pages.find((page) => page.id === active) ?? pages[0];
|
||||
const ActivePage = activePage.component;
|
||||
const selectedLanguage = languageOptions.find((option) => option.value === locale)
|
||||
?? languageOptions[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreRunning && active !== 'kernel') {
|
||||
@@ -116,6 +133,26 @@ function AppContent() {
|
||||
}
|
||||
}, [active, coreRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageMenuOpen) return undefined;
|
||||
const closeFromOutside = (event: PointerEvent) => {
|
||||
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
setLanguageMenuOpen(false);
|
||||
languageButtonRef.current?.focus();
|
||||
};
|
||||
document.addEventListener('pointerdown', closeFromOutside);
|
||||
document.addEventListener('keydown', closeFromKeyboard);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeFromOutside);
|
||||
document.removeEventListener('keydown', closeFromKeyboard);
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let stopListening: (() => void) | undefined;
|
||||
@@ -206,11 +243,11 @@ function AppContent() {
|
||||
<img src={appLogo} alt="" className="brand-mark brand-logo" />
|
||||
<div>
|
||||
<strong>EasyCLIProxyAPI</strong>
|
||||
<span>Desktop Console</span>
|
||||
<span>{t('app.desktopConsole')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-section" aria-label="主导航">
|
||||
<nav className="nav-section" aria-label={t('app.navigation')}>
|
||||
{pages.map((page) => {
|
||||
const Icon = page.icon;
|
||||
const locked = page.id !== 'kernel' && !coreRunning;
|
||||
@@ -222,25 +259,73 @@ function AppContent() {
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={locked}
|
||||
title={locked ? '请先启动内核' : undefined}
|
||||
title={locked ? t('app.coreRequired.title') : undefined}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{page.label}</span>
|
||||
<span>{t(page.labelKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<div ref={languageMenuRef} className="sidebar-language">
|
||||
<button
|
||||
ref={languageButtonRef}
|
||||
type="button"
|
||||
className="sidebar-language-trigger"
|
||||
aria-label={t('app.language')}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={languageMenuOpen}
|
||||
aria-controls="sidebar-language-list"
|
||||
onClick={() => setLanguageMenuOpen((open) => !open)}
|
||||
>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<span>{t(selectedLanguage.labelKey)}</span>
|
||||
<ChevronUp
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={languageMenuOpen ? 'expanded' : ''}
|
||||
/>
|
||||
</button>
|
||||
{languageMenuOpen ? (
|
||||
<div
|
||||
id="sidebar-language-list"
|
||||
className="sidebar-language-list"
|
||||
role="listbox"
|
||||
aria-label={t('app.language')}
|
||||
>
|
||||
{languageOptions.map((option) => {
|
||||
const selected = option.value === locale;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={selected ? 'selected' : ''}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
setLocale(option.value);
|
||||
setLanguageMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{t(option.labelKey)}</span>
|
||||
{selected ? <Check size={14} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-contact"
|
||||
title="通过 QQ 联系我们"
|
||||
title={t('app.contact.title')}
|
||||
onClick={() => void openContact()}
|
||||
>
|
||||
<MessageCircle size={16} aria-hidden="true" />
|
||||
<span>联系我们</span>
|
||||
<span>{t('app.contact.label')}</span>
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -274,11 +359,11 @@ function AppContent() {
|
||||
}}
|
||||
>
|
||||
<div className="close-dialog-heading">
|
||||
<span>关闭窗口</span>
|
||||
<h2 id="close-dialog-title">请选择后续操作</h2>
|
||||
<span>{t('app.close.eyebrow')}</span>
|
||||
<h2 id="close-dialog-title">{t('app.close.title')}</h2>
|
||||
</div>
|
||||
<p id="close-dialog-description">
|
||||
你可以退出程序,或让 EasyCLIProxyAPI 继续在系统托盘中运行。
|
||||
{t('app.close.description')}
|
||||
</p>
|
||||
{windowsClosePrompt.error ? (
|
||||
<div className="close-dialog-error" role="alert">
|
||||
@@ -294,8 +379,8 @@ function AppContent() {
|
||||
>
|
||||
<span>
|
||||
{windowsClosePrompt.resolvingAction === 'minimize-to-tray'
|
||||
? '正在最小化...'
|
||||
: '最小化到托盘'}
|
||||
? t('app.close.minimizing')
|
||||
: t('app.close.minimize')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -305,7 +390,9 @@ function AppContent() {
|
||||
onClick={() => void resolveWindowsCloseRequest('exit')}
|
||||
>
|
||||
<span>
|
||||
{windowsClosePrompt.resolvingAction === 'exit' ? '正在退出...' : '退出程序'}
|
||||
{windowsClosePrompt.resolvingAction === 'exit'
|
||||
? t('app.close.exiting')
|
||||
: t('app.close.exit')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -317,12 +404,13 @@ function AppContent() {
|
||||
}
|
||||
|
||||
function CoreLockedPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<section className="page core-locked-page">
|
||||
<div className="empty-state core-locked-panel">
|
||||
<ServerCog size={26} aria-hidden="true" />
|
||||
<strong>请先启动内核</strong>
|
||||
<span>启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。</span>
|
||||
<strong>{t('app.coreRequired.title')}</strong>
|
||||
<span>{t('app.coreRequired.description')}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
+17
-12
@@ -1,4 +1,5 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { useI18n } from './i18n';
|
||||
|
||||
type Props = { children: ReactNode };
|
||||
type State = { error: Error | null };
|
||||
@@ -16,17 +17,21 @@ export class AppErrorBoundary extends Component<Props, State> {
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<main className="app-error-boundary">
|
||||
<section className="empty-state">
|
||||
<strong>页面渲染出现异常</strong>
|
||||
<span>{this.state.error.message || '未知错误'}</span>
|
||||
<button type="button" className="primary-button" onClick={() => window.location.reload()}>
|
||||
重新加载页面
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
return <AppErrorFallback error={this.state.error} />;
|
||||
}
|
||||
}
|
||||
|
||||
function AppErrorFallback({ error }: { error: Error }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<main className="app-error-boundary">
|
||||
<section className="empty-state">
|
||||
<strong>{t('error.render.title')}</strong>
|
||||
<span>{error.message || t('error.unknown')}</span>
|
||||
<button type="button" className="primary-button" onClick={() => window.location.reload()}>
|
||||
{t('error.reload')}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { en, ja, zhCN, zhTW, type MessageKey, type MessageVariables } from './resources';
|
||||
|
||||
export type AppLocale = 'zh-CN' | 'zh-TW' | 'ja' | 'en';
|
||||
|
||||
const STORAGE_KEY = 'easy-cli-proxy-api.locale';
|
||||
const resources = { 'zh-CN': zhCN, 'zh-TW': zhTW, ja, en } as const;
|
||||
let currentLocale: AppLocale = 'zh-CN';
|
||||
|
||||
export const supportedLocales: readonly AppLocale[] = ['zh-CN', 'zh-TW', 'ja', 'en'];
|
||||
|
||||
export function getCurrentLocale(): AppLocale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): AppLocale {
|
||||
const normalized = value?.trim().toLowerCase() ?? '';
|
||||
if (normalized.startsWith('en')) return 'en';
|
||||
if (normalized.startsWith('ja')) return 'ja';
|
||||
if (
|
||||
normalized === 'zh-tw'
|
||||
|| normalized === 'zh-hk'
|
||||
|| normalized === 'zh-mo'
|
||||
|| normalized.startsWith('zh-hant')
|
||||
) return 'zh-TW';
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
function detectInitialLocale(): AppLocale {
|
||||
if (typeof window === 'undefined') return 'zh-CN';
|
||||
try {
|
||||
const saved = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return normalizeLocale(saved);
|
||||
} catch {
|
||||
// Storage may be unavailable in a restricted WebView; use the OS language.
|
||||
}
|
||||
return normalizeLocale(window.navigator.languages?.[0] ?? window.navigator.language);
|
||||
}
|
||||
|
||||
function interpolate(template: string, variables?: MessageVariables): string {
|
||||
if (!variables) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(variables, name) ? String(variables[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
export function translate(locale: AppLocale, key: MessageKey, variables?: MessageVariables): string {
|
||||
return interpolate(resources[locale][key] ?? zhCN[key], variables);
|
||||
}
|
||||
|
||||
type I18nContextValue = {
|
||||
locale: AppLocale;
|
||||
setLocale: (locale: AppLocale) => void;
|
||||
t: (key: MessageKey, variables?: MessageVariables) => string;
|
||||
formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
|
||||
formatDate: (value: Date | number | string, options?: Intl.DateTimeFormatOptions) => string;
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, updateLocale] = useState<AppLocale>(detectInitialLocale);
|
||||
currentLocale = locale;
|
||||
|
||||
const setLocale = useCallback((nextLocale: AppLocale) => {
|
||||
updateLocale(normalizeLocale(nextLocale));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = 'ltr';
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// The in-memory locale still works when persistent storage is unavailable.
|
||||
}
|
||||
void invoke('set_app_locale', { locale }).catch((error) => {
|
||||
console.warn('Failed to synchronize the app locale with the native shell', error);
|
||||
});
|
||||
}, [locale]);
|
||||
|
||||
const t = useCallback(
|
||||
(key: MessageKey, variables?: MessageVariables) => translate(locale, key, variables),
|
||||
[locale],
|
||||
);
|
||||
const formatNumber = useCallback(
|
||||
(value: number, options?: Intl.NumberFormatOptions) =>
|
||||
new Intl.NumberFormat(locale, options).format(value),
|
||||
[locale],
|
||||
);
|
||||
const formatDate = useCallback(
|
||||
(value: Date | number | string, options?: Intl.DateTimeFormatOptions) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return new Intl.DateTimeFormat(locale, options).format(date);
|
||||
},
|
||||
[locale],
|
||||
);
|
||||
|
||||
const context = useMemo<I18nContextValue>(
|
||||
() => ({ locale, setLocale, t, formatNumber, formatDate }),
|
||||
[formatDate, formatNumber, locale, setLocale, t],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={context}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) throw new Error('useI18n must be used inside I18nProvider');
|
||||
return context;
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
import type { MessageKey } from './resources';
|
||||
|
||||
export const jaOverrides = {
|
||||
'app.language': '言語',
|
||||
'app.language.zhCN': '簡体字中国語',
|
||||
'app.language.zhTW': '繁体字中国語',
|
||||
'app.language.ja': '日本語',
|
||||
'app.language.en': 'English',
|
||||
'app.desktopConsole': 'デスクトップコンソール',
|
||||
'app.navigation': 'メインナビゲーション',
|
||||
'app.nav.kernel': 'コア',
|
||||
'app.nav.config': '設定',
|
||||
'app.nav.thinkingAliases': 'モデルエイリアス',
|
||||
'app.nav.oauth': 'OAuth',
|
||||
'app.nav.api': 'API 接続',
|
||||
'app.nav.authFiles': '認証ファイル',
|
||||
'app.nav.quota': 'クォータ',
|
||||
'app.nav.usageRecords': '使用履歴',
|
||||
'app.nav.agents': 'エージェント',
|
||||
'app.coreRequired.title': '先にコアを起動してください',
|
||||
'app.coreRequired.description': '設定、OAuth、API 接続などを使用するには CPA コアを起動してください。',
|
||||
'app.contact.title': 'QQ でお問い合わせ',
|
||||
'app.contact.label': 'お問い合わせ',
|
||||
'app.close.eyebrow': 'ウィンドウを閉じる',
|
||||
'app.close.title': '次の操作を選択してください',
|
||||
'app.close.description': 'アプリを終了するか、EasyCLIProxyAPI をシステムトレイで実行し続けることができます。',
|
||||
'app.close.minimizing': '最小化しています...',
|
||||
'app.close.minimize': 'トレイに最小化',
|
||||
'app.close.exiting': '終了しています...',
|
||||
'app.close.exit': 'アプリを終了',
|
||||
'error.render.title': 'ページの描画中にエラーが発生しました',
|
||||
'error.unknown': '不明なエラー',
|
||||
'error.reload': 'ページを再読み込み',
|
||||
'quota.title': 'クォータ',
|
||||
'quota.queryableCredentials.one': '照会可能な認証情報 {count} 件',
|
||||
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
|
||||
'quota.credentials.one': '認証情報 {count} 件',
|
||||
'quota.credentials.other': '認証情報 {count} 件',
|
||||
'quota.readList': '一覧を読み込む',
|
||||
'quota.refreshAll': 'すべて更新',
|
||||
'quota.loadingFiles': '認証ファイルを読み込んでいます',
|
||||
'quota.empty.title': '照会可能なクォータがありません',
|
||||
'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。',
|
||||
'quota.unknownProvider': '不明',
|
||||
'quota.reset': 'クォータをリセット',
|
||||
'quota.fileDisabled': '認証ファイルは無効です',
|
||||
'quota.refresh': 'クォータを取得/更新',
|
||||
'quota.notFetched': 'クォータはまだ取得されていません',
|
||||
'quota.disabled': '無効',
|
||||
'quota.fetch': 'クォータを取得',
|
||||
'quota.querying': '照会中',
|
||||
'quota.resetCredits': '手動リセット回数',
|
||||
'quota.earliestExpiry': '最も早い有効期限',
|
||||
'quota.remaining': '残り {percent}%',
|
||||
'quota.confirm.title': '「{name}」の Codex クォータをリセットしますか?',
|
||||
'quota.confirm.cost': 'この操作では手動リセットを 1 回消費します。',
|
||||
'quota.confirm.available': '現在利用可能:{count} 回',
|
||||
'quota.confirm.expiry': '最も早い有効期限:{time}',
|
||||
'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。',
|
||||
'oauth.title': 'OAuth ログイン',
|
||||
'oauth.loginSuccess': '{provider} へのログインに成功しました',
|
||||
'oauth.authFailed': '認証に失敗しました',
|
||||
'oauth.loginFailed': '{provider} へのログインに失敗しました{detail}',
|
||||
'oauth.missingState': 'コアから OAuth state が返されませんでした',
|
||||
'oauth.missingStatePolling': 'コアから OAuth state が返されなかったため、ログイン状態を確認できません',
|
||||
'oauth.openFailedDetail': 'ログインリンクを取得しましたが、ブラウザーを自動で開けませんでした:{error}',
|
||||
'oauth.openFailed': 'ログインリンクを取得しましたが、ブラウザーを自動で開けませんでした。手動で開いてください',
|
||||
'oauth.linkCopied': 'ログインリンクをコピーしました',
|
||||
'oauth.linkCopyFailed': 'ログインリンクをコピーできませんでした',
|
||||
'oauth.pasteCallback': 'コールバック URL を貼り付けてください',
|
||||
'oauth.pasteXaiCallback': 'xAI から返された code またはコールバック URL を貼り付けてください',
|
||||
'oauth.invalidCallback': 'コールバック内容が無効です',
|
||||
'oauth.invalidXaiCallback': '入力から xAI コールバックを作成できません。code と state を確認してください',
|
||||
'oauth.callbackSubmittedNotice': 'コールバックを送信しました。認証完了を待っています',
|
||||
'oauth.loginAnother': '別のアカウントでログイン',
|
||||
'oauth.loggingIn': 'ログイン中…',
|
||||
'oauth.startLogin': 'ログインを開始',
|
||||
'oauth.hint': 'ログインを開始するとブラウザーが開き、認証を行います。自動で開かない場合はリンクを手動で開いてください',
|
||||
'oauth.authorizationLink': '認証リンク',
|
||||
'oauth.copyLink': 'リンクをコピー',
|
||||
'oauth.openLink': 'リンクを開く',
|
||||
'oauth.callbackPlaceholder': '完全なコールバック URL を貼り付け',
|
||||
'oauth.xaiCallbackPlaceholder': 'code または完全なコールバック URL を貼り付け',
|
||||
'oauth.submitCallback': 'コールバックを送信',
|
||||
'oauth.callbackSubmitted': 'コールバックを送信しました。認証完了を待っています',
|
||||
'oauth.callbackFailed': 'コールバックの送信に失敗しました{detail}',
|
||||
'oauth.openBrowser': 'ブラウザーを開く',
|
||||
'oauth.status.completed': '完了',
|
||||
'oauth.status.failed': '失敗',
|
||||
'oauth.status.waiting': 'コールバック待ち',
|
||||
'oauth.status.signedOut': '未ログイン',
|
||||
'usage.title': '使用履歴',
|
||||
'usage.collector.collecting': 'ローカルで収集中',
|
||||
'usage.collector.error': '収集エラー',
|
||||
'usage.collector.waiting': 'コアを待機中',
|
||||
'usage.longTermRecords': '長期レコード {count} 件',
|
||||
'usage.pageLabel': '使用履歴ページ',
|
||||
'usage.tab.overview': '概要',
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': 'リクエスト詳細',
|
||||
'usage.filter.timeRange': '期間',
|
||||
'usage.range.4h': '過去 4 時間',
|
||||
'usage.range.24h': '過去 24 時間',
|
||||
'usage.range.today': '今日',
|
||||
'usage.range.7d': '過去 7 日間',
|
||||
'usage.range.30d': '過去 30 日間',
|
||||
'usage.range.all': '全期間',
|
||||
'usage.range.custom': 'カスタム',
|
||||
'usage.filter.model': 'モデル',
|
||||
'usage.filter.allModels': 'すべてのモデル',
|
||||
'usage.filter.allProviders': 'すべてのプロバイダー',
|
||||
'usage.filter.source': 'ソース',
|
||||
'usage.filter.allSources': 'すべてのソース',
|
||||
'usage.filter.allKeys': 'すべてのキー',
|
||||
'usage.filter.result': 'リクエスト結果',
|
||||
'usage.filter.allResults': 'すべての結果',
|
||||
'usage.result.success': '成功',
|
||||
'usage.result.failed': '失敗',
|
||||
'usage.filter.startTime': '開始日時',
|
||||
'usage.filter.endTime': '終了日時',
|
||||
'usage.filter.to': '~',
|
||||
'usage.loading': 'ローカル SQLite の使用履歴を読み込んでいます',
|
||||
'usage.stat.requests': '総リクエスト数',
|
||||
'usage.stat.requestMeta': '成功 {success} · 失敗 {failed}',
|
||||
'usage.stat.tokens': '合計 Token',
|
||||
'usage.stat.tokenMeta': '入力 {input} · 出力 {output}',
|
||||
'usage.stat.successRate': '成功率',
|
||||
'usage.stat.reasoningMeta': '推論 {tokens} Token',
|
||||
'usage.stat.averageLatency': '平均レイテンシ',
|
||||
'usage.trend.title': 'リクエストと Token の推移',
|
||||
'usage.trend.description': 'システムのローカル時刻ごとに集計',
|
||||
'usage.token.title': 'Token の内訳',
|
||||
'usage.token.description': 'キャッシュ、推論、生成の消費量',
|
||||
'usage.token.input': '入力',
|
||||
'usage.token.output': '出力',
|
||||
'usage.token.reasoning': '推論',
|
||||
'usage.token.cacheRead': 'キャッシュ読み取り',
|
||||
'usage.token.cacheCreation': 'キャッシュ作成',
|
||||
'usage.trend.aria': 'リクエスト推移',
|
||||
'usage.analysis.models': 'モデル使用量',
|
||||
'usage.analysis.sources': 'リクエスト元',
|
||||
'usage.analysis.keys': '認証キー',
|
||||
'usage.analysis.hours': '時間帯分布',
|
||||
'usage.analysis.sortedByTokens': 'Token 順',
|
||||
'usage.analysis.itemMeta': '{requests} 回 · {percent}% · {tokens} Token',
|
||||
'usage.events.total': '合計 {count} 件',
|
||||
'usage.events.page': '{page} / {total} ページ',
|
||||
'usage.column.time': '日時',
|
||||
'usage.column.model': 'モデル',
|
||||
'usage.column.source': 'ソース',
|
||||
'usage.column.result': '結果',
|
||||
'usage.column.latency': 'レイテンシ',
|
||||
'usage.column.input': '入力',
|
||||
'usage.column.output': '出力',
|
||||
'usage.column.reasoning': '推論',
|
||||
'usage.column.cache': 'キャッシュ',
|
||||
'usage.column.total': '合計',
|
||||
'usage.key.noRemark': 'メモなし',
|
||||
'usage.previous': '前へ',
|
||||
'usage.next': '次へ',
|
||||
'usage.empty': '現在のフィルター範囲に使用履歴はありません',
|
||||
'aliases.effort.low': '低',
|
||||
'aliases.effort.medium': '標準',
|
||||
'aliases.effort.high': '高',
|
||||
'aliases.effort.xhigh': '超高',
|
||||
'aliases.effort.max': '最大',
|
||||
'aliases.source.openAiCompatible': 'OpenAI 互換',
|
||||
'aliases.source.other': 'その他のソース',
|
||||
'aliases.source.available': '現在コアで利用可能なモデル',
|
||||
'aliases.error.selectModel': '先に元のモデルを選択してください',
|
||||
'aliases.error.emptyAlias': 'エイリアスモデル名を入力してください',
|
||||
'aliases.error.emptyEffort': '推論レベルを入力してください',
|
||||
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
|
||||
'aliases.deleteConfirm': 'エイリアスモデル「{alias}」を削除しますか?\n対応する推論レベルの上書きルールも削除されます。',
|
||||
'aliases.deleted': '{alias} を削除しました',
|
||||
'aliases.title': '推論エイリアス',
|
||||
'aliases.intro.title': '元のモデルに固定推論レベルのカスタムエイリアスを追加',
|
||||
'aliases.intro.description': 'エイリアス名は自由に指定できます。クライアントがエイリアスを使用した場合のみ CPA が選択した推論レベルを注入し、元のモデルを直接使用した場合は名前、設定、動作を変更しません。',
|
||||
'aliases.intro.badge': '元のモデルには影響しません',
|
||||
'aliases.flow.aria': '推論エイリアスのワークフロー',
|
||||
'aliases.flow.original': '元のモデル',
|
||||
'aliases.flow.direct': '直接呼び出し',
|
||||
'aliases.flow.directValue': 'deepseek-v4-pro · 変更なし',
|
||||
'aliases.flow.alias': 'エイリアス呼び出し',
|
||||
'aliases.flow.aliasValue': 'deepseek-v4-pro-max · max に固定',
|
||||
'aliases.create.title': '推論エイリアスを作成',
|
||||
'aliases.create.description': 'Codex OAuth、Codex API、OpenAI 互換接続に対応',
|
||||
'aliases.originalModel': '元のモデル',
|
||||
'aliases.loadingModels': 'モデルを読み込んでいます',
|
||||
'aliases.searchModel': '元のモデルを検索して選択',
|
||||
'aliases.availableModels': '利用可能な元モデル',
|
||||
'aliases.noMatch': '一致するモデルまたはソースがありません',
|
||||
'aliases.noModels': '選択可能なモデルがありません',
|
||||
'aliases.sourceLabel': 'ソース:{source}',
|
||||
'aliases.sourceHint': '同名モデルは Codex OAuth、Codex API、OpenAI 互換ソースごとに表示されます',
|
||||
'aliases.effort.title': '推論レベル',
|
||||
'aliases.effort.description': '一般的なレベルを選択するか、上流が対応するカスタム値を入力します',
|
||||
'aliases.customLevel': 'カスタムレベル:{effort}',
|
||||
'aliases.customLevelHint': 'モデルプロバイダーが対応する別の推論レベルを入力',
|
||||
'aliases.custom': 'カスタム',
|
||||
'aliases.customPlaceholder': '例:minimal、auto、ultra、またはベンダー独自レベル',
|
||||
'aliases.collapseCustom': 'カスタム入力を折りたたむ',
|
||||
'aliases.customHelp': '対象モデルが実際に対応するレベル名のみ入力してください',
|
||||
'aliases.aliasName.title': 'エイリアスモデル名',
|
||||
'aliases.aliasName.description': '新しい入口のみを追加し、元のモデル名は変更しません',
|
||||
'aliases.aliasName.example': '例:{model}-{effort}',
|
||||
'aliases.aliasName.selectFirst': '先に元のモデルを選択し、クライアントで使用するエイリアスを入力してください',
|
||||
'aliases.notSelected': 'モデル未選択',
|
||||
'aliases.enterAlias': 'エイリアスモデル名を入力してください',
|
||||
'aliases.notSet': '未入力',
|
||||
'aliases.creating': '作成中',
|
||||
'aliases.create': 'エイリアスを作成',
|
||||
'aliases.createdList.title': '作成済みエイリアス',
|
||||
'aliases.createdList.description': '各エイリアスには元のモデル、ソース、固定推論レベルが保持されます',
|
||||
'aliases.loadingConfig': '設定を読み込んでいます',
|
||||
'aliases.empty.title': '推論エイリアスはまだありません',
|
||||
'aliases.empty.description': '左側でモデルとレベルを選択して作成できます',
|
||||
'aliases.unboundEffort': 'レベル未設定',
|
||||
'aliases.delete': '{alias} を削除',
|
||||
'config.routing.roundRobin': 'ラウンドロビン',
|
||||
'config.routing.fillFirst': '優先充填',
|
||||
'config.error.emptyKey': '認証キーを入力してください',
|
||||
'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます',
|
||||
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
|
||||
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
|
||||
'config.notice.keyAdded': '認証キーを追加しました',
|
||||
'config.notice.keyDeleted': '認証キーを削除しました',
|
||||
'config.notice.keyCopied': '認証キーをコピーしました',
|
||||
'config.notice.keyCopyFailed': '認証キーをコピーできませんでした',
|
||||
'config.notice.pluginsEnabled': 'プラグインシステムを有効にしました',
|
||||
'config.notice.pluginsDisabled': 'プラグインシステムを無効にしました',
|
||||
'config.notice.routingUpdated': 'ルーティング方式を更新しました',
|
||||
'config.keys.title': '認証キー',
|
||||
'config.keys.count': 'キー数',
|
||||
'config.keys.add': '認証キーを追加',
|
||||
'config.unavailable': '設定を利用できません',
|
||||
'common.retry': '再試行',
|
||||
'config.keys.noRemark': 'メモなし',
|
||||
'config.keys.copy': '認証キーをコピー',
|
||||
'config.keys.copyNth': '{number} 番目の認証キーをコピー',
|
||||
'config.keys.builtInProtected': '組み込みキーは削除できません',
|
||||
'config.keys.builtIn': '組み込み',
|
||||
'config.keys.delete': '認証キーを削除',
|
||||
'config.keys.deleteNth': '{number} 番目の認証キーを削除',
|
||||
'config.keys.empty': '認証キーがありません',
|
||||
'config.plugins.title': 'プラグインシステム',
|
||||
'common.loading': '読み込み中',
|
||||
'common.unavailable': '利用不可',
|
||||
'common.enabled': '有効',
|
||||
'common.disabled': '無効',
|
||||
'config.plugins.enable': 'プラグインシステムを有効にする',
|
||||
'config.routing.title': 'ルーティング方式',
|
||||
'config.keys.addTitle': '認証キーを追加',
|
||||
'common.close': '閉じる',
|
||||
'config.keys.label': '認証キー',
|
||||
'config.keys.hide': 'キーを隠す',
|
||||
'config.keys.show': 'キーを表示',
|
||||
'config.keys.remark': 'メモ',
|
||||
'config.keys.remarkPlaceholder': '例:開発環境、田中さんのキー',
|
||||
'config.keys.generate': 'キーを生成',
|
||||
'config.keys.adding': '追加中',
|
||||
'common.add': '追加',
|
||||
'config.keys.deleteTitle': '認証キーを削除',
|
||||
'common.cancel': 'キャンセル',
|
||||
'common.deleting': '削除中',
|
||||
'common.delete': '削除',
|
||||
'authFiles.unknownProvider': '不明なプロバイダー',
|
||||
'authFiles.status.disabled': '無効',
|
||||
'authFiles.status.unavailable': '利用不可',
|
||||
'authFiles.status.ready': '準備完了',
|
||||
'authFiles.quota.loading': 'クォータを照会中',
|
||||
'authFiles.quota.failed': 'クォータの取得に失敗しました',
|
||||
'authFiles.quota.aria': 'クォータ情報',
|
||||
'authFiles.quota.empty': 'クォータデータがありません',
|
||||
'authFiles.quota.resets': '手動リセット {count} 回',
|
||||
'authFiles.quota.expiry': '最も早い有効期限 {time}',
|
||||
'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません',
|
||||
'authFiles.models.updated': '{provider} の公開モデルを更新しました',
|
||||
'authFiles.uploaded': '認証ファイルを {count} 件アップロードしました',
|
||||
'authFiles.uploadFailed': '{count} 件のファイルをアップロードできませんでした:{errors}',
|
||||
'authFiles.notice.enabled': '認証ファイルを有効にしました',
|
||||
'authFiles.notice.disabled': '認証ファイルを無効にしました',
|
||||
'authFiles.runtimeDeleteError': '実行時認証ファイルはディスクから削除できません',
|
||||
'authFiles.deleteConfirm': '「{name}」を削除しますか?',
|
||||
'authFiles.deleted': '認証ファイルを削除しました',
|
||||
'authFiles.deleteAllConfirm': 'ディスク上の認証ファイルをすべて削除しますか?実行時認証は削除されません。',
|
||||
'authFiles.deletedAll': 'ディスク上の認証ファイルをすべて削除しました',
|
||||
'authFiles.runtimeDownloadError': '実行時認証ファイルにはダウンロード可能なディスクファイルがありません',
|
||||
'authFiles.downloaded': '認証ファイルをダウンロードしました',
|
||||
'common.copyFailed': 'コピーに失敗しました',
|
||||
'authFiles.title': '認証ファイル',
|
||||
'authFiles.summary': 'ファイル {files} 件 · 無効 {disabled} 件',
|
||||
'common.refresh': '更新',
|
||||
'authFiles.clearDisk': 'ディスクファイルを消去',
|
||||
'common.upload': 'アップロード',
|
||||
'authFiles.searchPlaceholder': 'ファイル名、アカウント、プロバイダーを検索',
|
||||
'authFiles.filter.allProviders': 'すべてのプロバイダー',
|
||||
'authFiles.filter.allStatuses': 'すべての状態',
|
||||
'authFiles.filter.enabled': '有効',
|
||||
'authFiles.filter.disabled': '無効',
|
||||
'authFiles.filter.runtime': '実行時のみ',
|
||||
'authFiles.loading': '認証ファイルを読み込んでいます',
|
||||
'authFiles.empty.filtered': '一致する認証ファイルがありません',
|
||||
'authFiles.empty.none': '認証ファイルがありません',
|
||||
'authFiles.empty.tryFilter': '別のフィルター条件をお試しください',
|
||||
'authFiles.empty.upload': 'JSON 認証ファイルをアップロードするとここに表示されます',
|
||||
'authFiles.unknownSize': 'サイズ不明',
|
||||
'authFiles.runtime': '実行時',
|
||||
'authFiles.quota.querying': '照会中',
|
||||
'authFiles.quota.refresh': 'クォータを更新',
|
||||
'authFiles.quota.fetch': 'クォータを取得',
|
||||
'authFiles.models.settings': '公開モデルを設定',
|
||||
'authFiles.models.button': 'モデル',
|
||||
'authFiles.copyName': 'ファイル名をコピー',
|
||||
'common.download': 'ダウンロード',
|
||||
'common.enable': '有効化',
|
||||
'common.disable': '無効化',
|
||||
'authFiles.runtimeFootnote': '{count} 件の認証情報は実行時ストレージ由来です。対応するプロバイダーまたは認証元でのみ管理できます。',
|
||||
'authFiles.models.title': '公開モデル',
|
||||
'authFiles.models.description': '{provider} · 既定ではすべて公開されます。不要なモデルは選択解除できます',
|
||||
'authFiles.models.search': 'モデル名を検索',
|
||||
'authFiles.models.summary': '合計 {total} 件 · 公開 {open} 件',
|
||||
'authFiles.models.closeVisible': '表示中を非公開',
|
||||
'authFiles.models.openVisible': '表示中を公開',
|
||||
'authFiles.models.openAll': 'すべて公開',
|
||||
'authFiles.models.closeAll': 'すべて非公開',
|
||||
'authFiles.models.loading': 'モデル定義を読み込んでいます',
|
||||
'authFiles.models.loadFailed': 'モデルの読み込みに失敗しました',
|
||||
'authFiles.models.noMatch': '一致するモデルがありません',
|
||||
'authFiles.models.empty': 'モデル定義がありません',
|
||||
'common.saving': '保存中',
|
||||
'authFiles.models.save': '保存({count} 件を公開)',
|
||||
'agents.description.claudeCode': 'CPA の Anthropic 互換 API を使用',
|
||||
'agents.description.claudeDesktop': 'CPA 3P 推論ゲートウェイを使用(Windows、macOS、Linux Beta 対応)',
|
||||
'agents.description.codex': 'CPA Responses API を使用',
|
||||
'agents.description.opencode': 'CPA OpenAI Compatible プロバイダーを使用',
|
||||
'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用',
|
||||
'agents.description.hermes': 'CPA カスタムプロバイダーを使用',
|
||||
'agents.list.detecting': '検出中',
|
||||
'agents.list.unsupported': '現在のプラットフォームでは非対応',
|
||||
'agents.list.notInstalled': 'インストールを検出できません',
|
||||
'agents.list.conflict': '設定競合 · 復元待ち',
|
||||
'agents.list.recovery': '操作未完了 · 復元可能',
|
||||
'agents.list.modified': '変更済み · {model}',
|
||||
'agents.list.installedVersion': 'インストール済み · {version}',
|
||||
'agents.list.installed': 'インストール済み · 元の設定を維持',
|
||||
'agents.model.loading': 'モデルを読み込んでいます',
|
||||
'agents.model.loadFailed': 'モデルの取得に失敗しました',
|
||||
'agents.model.select': 'モデルを選択',
|
||||
'agents.model.none': '選択可能なモデルなし',
|
||||
'agents.model.search': 'モデル名またはエイリアスを検索',
|
||||
'agents.model.clearSearch': '検索を消去',
|
||||
'agents.model.refresh': 'モデルを更新',
|
||||
'agents.model.fetching': 'モデルを取得しています',
|
||||
'agents.model.noMatch': '一致するモデルがありません',
|
||||
'agents.model.unavailable': '現在利用可能なモデルはありません',
|
||||
'agents.model.tryKeywords': '別のキーワードをお試しください',
|
||||
'agents.model.connectFirst': '先に利用可能なモデルを接続してから更新してください',
|
||||
'agents.model.available': '利用可能なモデル',
|
||||
'agents.model.count': '利用可能なモデル {count} 件',
|
||||
'agents.model.stale': '更新に失敗したため、前回の結果を表示しています',
|
||||
'agents.error.modelsLoading': 'モデル一覧を読み込み中です。しばらくしてから再試行してください',
|
||||
'agents.error.noModels': '選択可能なモデルがないため、エージェント設定を変更できません',
|
||||
'agents.error.selectionGone': '選択中のモデルは利用可能一覧にありません。更新後に選び直してください',
|
||||
'agents.notice.enabled': '{name} の設定変更を有効にしました。アプリ終了前に「エージェント設定を変更」をオフにして元の設定を復元することを推奨します',
|
||||
'agents.notice.restored': '{name} の元の設定を復元しました。クライアントを再起動してください',
|
||||
'agents.error.conflict': '設定の競合または復元タスクがあるため、新しいモデルを適用できません',
|
||||
'agents.notice.appliedLaunch': '{name} にモデルを適用して起動しました。終了前に設定変更をオフにして元の設定を復元することを推奨します',
|
||||
'agents.notice.syncedLaunch': '{name} のモデルカタログを同期して起動しました。終了前に設定変更をオフにして元の設定を復元することを推奨します',
|
||||
'agents.notice.managedLaunch': '{name} を起動しました。終了前に設定変更をオフにして元の設定を復元することを推奨します',
|
||||
'agents.notice.launched': '{name} を起動しました',
|
||||
'agents.status.detecting': '検出中',
|
||||
'agents.status.unsupported': 'プラットフォーム非対応',
|
||||
'agents.status.notInstalled': '未インストール',
|
||||
'agents.status.conflict': '設定競合',
|
||||
'agents.status.recovery': '復元が必要',
|
||||
'agents.status.modified': '設定変更済み',
|
||||
'agents.status.original': '元の設定を維持',
|
||||
'agents.title': 'エージェント設定',
|
||||
'agents.redetect': '再検出',
|
||||
'agents.localClients': 'ローカルクライアント',
|
||||
'agents.selectClient': '管理するエージェントを選択',
|
||||
'agents.installStatus': 'インストール状態',
|
||||
'agents.clientDetected': 'クライアントを検出しました',
|
||||
'agents.clientNotDetected': 'クライアントを検出できません',
|
||||
'agents.clientVersion': 'クライアントバージョン',
|
||||
'agents.notFetched': '未取得',
|
||||
'agents.useModel': '使用モデル',
|
||||
'agents.useModelDescription': 'コアで公開中のモデルを同期し、選択したモデルを既定に設定します',
|
||||
'agents.pending': '未適用',
|
||||
'agents.model.readingAvailable': '利用可能なモデルを読み込んでいます',
|
||||
'agents.model.cannotConfigure': '選択可能なモデルがないため、設定を有効化または更新できません',
|
||||
'agents.model.current': '現在適用中:{model}',
|
||||
'agents.model.firstSelection': '利用可能なモデル {count} 件。初回は先頭を選択します',
|
||||
'agents.modify.title': 'エージェント設定を変更',
|
||||
'agents.modify.conflict': '設定が外部で変更されています。無効化時に復元の確認が必要です',
|
||||
'agents.modify.recovery': '前回の操作が未完了です。スイッチをオフにすると元の設定の復元を試みます',
|
||||
'agents.modify.managed': '元の設定をバックアップし、現在は CPA が管理しています',
|
||||
'agents.modify.disabled': 'クライアント起動前に有効化してください。元の設定をバックアップしてから CPA 設定を書き込みます',
|
||||
'agents.footer.changed': 'モデル選択が変更されました。起動時に新しいモデルを適用します',
|
||||
'agents.footer.restore': 'アプリ終了前に「エージェント設定を変更」をオフにし、元の設定を復元することを推奨します',
|
||||
'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません',
|
||||
'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください',
|
||||
'agents.footer.noCommand': '設定ファイルは検出しましたが、クライアントコマンドが見つかりません',
|
||||
'agents.footer.invalidConfig': '元の設定形式が無効なため、安全に変更できません',
|
||||
'agents.footer.enableFirst': '先に「エージェント設定を変更」を有効にし、CPA モデル設定を反映してからクライアントを起動してください',
|
||||
'agents.launchMethods': 'Codex の起動方法',
|
||||
'agents.launch.enableFirst': '先に「エージェント設定を変更」を有効にしてください',
|
||||
'agents.launch.starting': '起動中',
|
||||
'agents.launch.start': '{target} を起動',
|
||||
'agents.launch.unavailable': '起動できません',
|
||||
'agents.restore.title': '設定が外部で変更されました',
|
||||
'agents.restore.description': '設定変更を有効にした後、{count} 件の設定ファイルが別のプログラムによって変更されました。復元を続行すると、有効化前のバックアップで変更を上書きします。',
|
||||
'agents.restore.confirm': '強制復元を確認',
|
||||
'apiAccess.compatibleName': 'OpenAI 互換 {number}',
|
||||
'apiAccess.error.headerMissingColon': 'カスタムヘッダーの {number} 行目にコロンがありません',
|
||||
'apiAccess.error.headerInvalid': 'カスタムヘッダーの {number} 行目の形式が無効です',
|
||||
'apiAccess.error.partialLoad': '一部の接続を読み込めませんでした:{errors}',
|
||||
'apiAccess.error.requiredAll': '名前、Base URL、キーを入力してください',
|
||||
'apiAccess.error.requiredBaseKey': 'Base URL と API キーを入力してください',
|
||||
'apiAccess.error.requiredKey': 'API キーを入力してください',
|
||||
'apiAccess.error.baseRequired': '{provider} 接続には Base URL が必要です',
|
||||
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
|
||||
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
|
||||
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
|
||||
'apiAccess.notice.updated': '接続を更新しました',
|
||||
'apiAccess.notice.added': '接続を追加しました',
|
||||
'apiAccess.deleteConfirm': '「{name}」を削除しますか?',
|
||||
'apiAccess.notice.deleted': '接続を削除しました',
|
||||
'apiAccess.notice.enabled': '接続を有効にしました',
|
||||
'apiAccess.notice.disabled': '接続を無効にしました',
|
||||
'apiAccess.title': 'API 接続',
|
||||
'apiAccess.count': '接続 {count} 件',
|
||||
'apiAccess.add': '追加',
|
||||
'apiAccess.matches': '一致する接続 {count} 件',
|
||||
'apiAccess.search': '名前、キー、アドレスを検索',
|
||||
'apiAccess.loading': '設定を読み込んでいます',
|
||||
'apiAccess.empty.filtered': '一致する接続がありません',
|
||||
'apiAccess.empty.none': '接続設定がありません',
|
||||
'apiAccess.empty.tryKeyword': '別のキーワードをお試しください',
|
||||
'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください',
|
||||
'apiAccess.keys.count': 'キー {count} 件',
|
||||
'apiAccess.keys.summary': '{key} · 合計 {count} 件',
|
||||
'apiAccess.defaultUrl': '既定のアドレスを使用',
|
||||
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
|
||||
'apiAccess.priorityValue': '優先度 {priority}',
|
||||
'apiAccess.runtimeCredential': '実行時認証情報 {index}…',
|
||||
'apiAccess.enable': '接続を有効化',
|
||||
'apiAccess.disable': '接続を無効化',
|
||||
'apiAccess.status.disabled': '無効',
|
||||
'apiAccess.status.enabled': '有効',
|
||||
'apiAccess.toggleAria': '{name} 接続の{action}スイッチ',
|
||||
'common.edit': '編集',
|
||||
'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください',
|
||||
'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません',
|
||||
'apiAccess.error.baseBeforeModels': 'モデルを取得する前に Base URL を入力してください',
|
||||
'apiAccess.models.selected': '{count} 件のモデルを選択済み',
|
||||
'apiAccess.models.restricted': 'モデル制限を設定済み',
|
||||
'apiAccess.models.autoAll': '保存時にすべてのモデルを既定で公開',
|
||||
'apiAccess.models.upstreamDefault': '上流の既定モデルを使用',
|
||||
'apiAccess.models.hiddenHint': '選択していないモデルは公開一覧に表示されません',
|
||||
'apiAccess.models.autoHint': '接続の保存時にモデルを自動取得します。不要な項目は事前に選択解除できます',
|
||||
'apiAccess.models.allHint': '現在、上流のすべてのモデルを公開しています',
|
||||
'apiAccess.dialog.edit': 'API 接続を編集',
|
||||
'apiAccess.dialog.add': 'API 接続を追加',
|
||||
'apiAccess.field.name': '名前',
|
||||
'apiAccess.field.keysMany': 'API キー(1 行に 1 件)',
|
||||
'apiAccess.field.key': 'API キー',
|
||||
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
|
||||
'apiAccess.preset.title': 'OpenAI 互換プリセット',
|
||||
'apiAccess.preset.description': 'DeepSeek 公式アドレスを入力済みです。保存時にすべてのモデルを自動検出して公開します',
|
||||
'apiAccess.thinking.builtIn': '組み込み推論レベル',
|
||||
'apiAccess.thinking.builtInDescription': '現在公開中のすべてのモデルに自動適用します',
|
||||
'apiAccess.thinking.title': '推論レベル',
|
||||
'apiAccess.thinking.description': '上流の対応状況に合わせて追加してください',
|
||||
'apiAccess.thinking.placeholder': '例:low、medium、またはカスタムレベル',
|
||||
'apiAccess.thinking.add': '追加',
|
||||
'apiAccess.thinking.delete': '{level} を削除',
|
||||
'apiAccess.thinking.deleteAria': '推論レベル {level} を削除',
|
||||
'apiAccess.thinking.empty': '未追加の場合 thinking.levels は書き込まれません',
|
||||
'apiAccess.models.title': 'モデル',
|
||||
'apiAccess.models.description': '選択したモデルを公開します。選択解除するとモデル一覧から非表示になります',
|
||||
'apiAccess.models.fetch': 'モデルを取得',
|
||||
'apiAccess.field.priority': '優先度',
|
||||
'common.optional': '任意',
|
||||
'apiAccess.advanced': '詳細設定',
|
||||
'apiAccess.field.prefix': 'モデル接頭辞',
|
||||
'apiAccess.prefixPlaceholder': '任意、例:team-a',
|
||||
'apiAccess.field.headers': 'カスタムリクエストヘッダー(1 行に Name: Value)',
|
||||
'apiAccess.field.excludedModels': '除外モデル(1 行に 1 件、ワイルドカード対応)',
|
||||
'apiAccess.field.testModel': 'テストモデル',
|
||||
'apiAccess.cloak.mode': 'Claude 偽装モード',
|
||||
'apiAccess.cloak.default': '既定(auto)',
|
||||
'apiAccess.cloak.words': '偽装する機密語(1 行に 1 件)',
|
||||
'apiAccess.cloak.strict': '厳格モード',
|
||||
'apiAccess.cloak.strictDescription': 'Claude Code のシステムプロンプトのみ保持します',
|
||||
'apiAccess.cloak.enableStrict': '厳格モードを有効化',
|
||||
'apiAccess.cloak.cacheUser': 'ユーザー識別子をキャッシュ',
|
||||
'apiAccess.cloak.cacheUserDescription': '偽装後のユーザー識別子を再利用します',
|
||||
'apiAccess.websocket.description': 'この Codex 接続で WebSocket を有効にします',
|
||||
'apiAccess.websocket.enable': 'WebSocket を有効化',
|
||||
'apiAccess.cooling.title': 'クールダウンを無効化',
|
||||
'apiAccess.cooling.description': '上流でレート制限が発生しても、この接続を引き続き選択可能にします',
|
||||
'common.save': '保存',
|
||||
'apiAccess.modelDialog.title': 'モデルを選択',
|
||||
'apiAccess.modelDialog.summary': '{found} 件検出 · {selected} 件選択済み',
|
||||
'apiAccess.modelDialog.deselectAll': 'すべて選択解除',
|
||||
'apiAccess.modelDialog.selectVisible': '表示中をすべて選択',
|
||||
'common.clear': '消去',
|
||||
'apiAccess.modelDialog.fetching': 'モデルを取得しています',
|
||||
'apiAccess.modelDialog.fetchFailed': 'モデルの取得に失敗しました',
|
||||
'apiAccess.modelDialog.noMatch': '一致するモデルがありません',
|
||||
'apiAccess.modelDialog.none': 'モデルが見つかりません',
|
||||
'apiAccess.modelDialog.tryKeyword': '別のキーワードをお試しください',
|
||||
'apiAccess.modelDialog.checkCredentials': 'Base URL と API キーを確認してください',
|
||||
'apiAccess.modelDialog.apply': '選択を適用({count})',
|
||||
'kernel.install.completed': '{version} のインストールが完了しました',
|
||||
'kernel.error.progressListener': 'ダウンロード進捗の監視に失敗しました:{error}',
|
||||
'kernel.action.start': '起動',
|
||||
'kernel.action.stop': '停止',
|
||||
'kernel.action.restart': '再起動',
|
||||
'kernel.notice.actionSuccess': 'コアの{action}に成功しました',
|
||||
'kernel.notice.actionFailed': 'コアの{action}に失敗しました:{error}',
|
||||
'kernel.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました',
|
||||
'kernel.notice.networkRestartFailed': 'ネットワーク設定を保存しましたが、コアの再起動に失敗しました',
|
||||
'kernel.notice.networkNextStart': 'ネットワーク設定を保存しました。次回のコア起動時に反映されます',
|
||||
'kernel.notice.networkSaveFailed': 'ネットワーク設定を保存できませんでした:{error}',
|
||||
'kernel.error.port': 'ポートは 1~65535 の範囲で指定してください',
|
||||
'kernel.error.installTask': 'インストールタスクを読み込めませんでした:{error}',
|
||||
'kernel.install.installingVersion': '{version} をインストールしています',
|
||||
'kernel.phase.preparingDownload': 'ダウンロードを準備中',
|
||||
'kernel.phase.downloading': 'ダウンロード中',
|
||||
'kernel.phase.extracting': '展開中',
|
||||
'kernel.phase.completed': 'インストール完了',
|
||||
'kernel.install.installingBundled': '組み込みコア {version} をインストールしています',
|
||||
'kernel.phase.preparingBundled': '組み込みコアを準備中',
|
||||
'kernel.install.bundledCompleted': '組み込みコア {version} のインストールが完了しました',
|
||||
'kernel.phase.failed': 'インストール失敗',
|
||||
'kernel.phase.cancelled': 'キャンセル済み',
|
||||
'kernel.install.cancelling': 'ダウンロードをキャンセルしています',
|
||||
'kernel.notice.copyFailed': '接続情報をコピーできませんでした',
|
||||
'kernel.error.openUpdate': '更新ページを開けませんでした:{error}',
|
||||
'kernel.progress.completed': '完了',
|
||||
'kernel.progress.extracting': 'ファイルを展開しています',
|
||||
'kernel.progress.waiting': '進捗情報を待っています',
|
||||
'kernel.status.running': '実行中',
|
||||
'kernel.status.stopped': '停止済み',
|
||||
'kernel.status.notInstalled': '未インストール',
|
||||
'common.detectionFailed': '検出失敗',
|
||||
'common.detecting': '検出中',
|
||||
'kernel.update.failedRetry': '確認失敗、再試行',
|
||||
'kernel.update.toVersion': '{version} に更新',
|
||||
'kernel.update.checking': '確認中',
|
||||
'kernel.update.failed': '確認失敗',
|
||||
'kernel.update.notChecked': '未確認',
|
||||
'kernel.update.statusChecking': '確認しています',
|
||||
'kernel.update.notYetChecked': 'まだ確認していません',
|
||||
'kernel.update.latest': '最新バージョンです',
|
||||
'kernel.update.available': '新しいバージョンをインストールできます',
|
||||
'kernel.install.inProgress': 'インストール中',
|
||||
'kernel.install.githubFailed': 'GitHub に接続できません。「オフラインインストール」を使用してください',
|
||||
'kernel.install.titleCancelling': 'ダウンロードをキャンセル中',
|
||||
'kernel.install.titleInstalling': 'コアをインストール中',
|
||||
'kernel.install.titleCompleted': 'インストール完了',
|
||||
'kernel.install.titleCancelled': 'ダウンロードをキャンセルしました',
|
||||
'kernel.install.titleFailed': 'インストール失敗',
|
||||
'kernel.install.waitingStop': 'ダウンロードタスクの停止を待っています',
|
||||
'kernel.install.taskRunning': 'インストールタスクを実行中です',
|
||||
'kernel.install.cancellingShort': 'キャンセル中',
|
||||
'kernel.install.cancel': 'ダウンロードをキャンセル',
|
||||
'common.processing': '処理中',
|
||||
'kernel.overview.coreStatus': 'コア状態',
|
||||
'kernel.overview.appVersion': 'アプリバージョン',
|
||||
'kernel.update.openGithub': 'GitHub の最新リリースページを開く',
|
||||
'kernel.overview.coreVersion': 'コアバージョン',
|
||||
'kernel.control.title': '実行制御',
|
||||
'kernel.control.installStatus': 'インストール状態',
|
||||
'kernel.control.installed': 'インストール済み',
|
||||
'kernel.control.runStatus': '実行状態',
|
||||
'kernel.control.pid': 'プロセス PID',
|
||||
'kernel.control.notRunning': '未実行',
|
||||
'kernel.control.noPid': 'なし',
|
||||
'kernel.control.allowLan': 'LAN を許可',
|
||||
'kernel.control.lanRestartHint': '変更すると実行中のコアが自動的に再起動します',
|
||||
'kernel.control.allowLanAria': 'LAN アクセスを許可',
|
||||
'kernel.control.port': 'ポート',
|
||||
'kernel.control.portPlaceholder': 'ポート番号',
|
||||
'kernel.control.portRange': 'ポート範囲:1~65535',
|
||||
'kernel.notice.stopped': 'コアを停止しました',
|
||||
'kernel.notice.started': 'コアを起動しました',
|
||||
'kernel.notice.restarted': 'コアを再起動しました',
|
||||
'kernel.control.refresh': '状態を更新',
|
||||
'kernel.versions.title': 'コアバージョン管理',
|
||||
'kernel.versions.offlineHint': 'GitHub に接続できない場合は、オフラインインストールで組み込みコアを使用できます。',
|
||||
'kernel.versions.current': '現在のバージョン',
|
||||
'kernel.versions.latest': '最新バージョン',
|
||||
'kernel.versions.bundled': '組み込みバージョン',
|
||||
'kernel.versions.notIncluded': '含まれていません',
|
||||
'kernel.versions.platform': '実行プラットフォーム',
|
||||
'kernel.versions.updateStatus': '更新状態',
|
||||
'kernel.versions.check': '更新を確認',
|
||||
'kernel.versions.installVersion': '{version} をインストール',
|
||||
'kernel.versions.installLatest': '最新版をインストール',
|
||||
'kernel.versions.reinstallTitle': '現在のバージョンを再インストール',
|
||||
'kernel.versions.reinstall': '再インストール',
|
||||
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
|
||||
'kernel.versions.offlineInstall': 'オフラインインストール',
|
||||
'kernel.access.defaultKey': '既定のキー:{key}',
|
||||
'kernel.access.connectable': '接続可能',
|
||||
'kernel.access.waiting': 'コアの起動を待っています',
|
||||
'kernel.access.localUrl': 'ローカル URL',
|
||||
'kernel.access.localCopied': '{name} のローカル URL をコピーしました',
|
||||
'kernel.access.copyLocal': '{name} のローカル URL をコピー',
|
||||
'kernel.access.lanUrl': 'LAN URL',
|
||||
'kernel.access.detectingIp': 'LAN IP を検出しています',
|
||||
'kernel.access.noIp': 'LAN IP を検出できません',
|
||||
'kernel.access.lanCopied': '{name} の LAN URL をコピーしました',
|
||||
'kernel.access.copyLan': '{name} の LAN URL をコピー',
|
||||
'kernel.dialog.install': 'コアのインストール',
|
||||
'kernel.dialog.phase': '現在の段階',
|
||||
'kernel.dialog.unknownProgress': '進捗不明',
|
||||
'kernel.access.openaiDescription': 'OpenAI SDK、OpenCode などの互換クライアント向け',
|
||||
'kernel.access.claudeDescription': 'Claude SDK および Claude Code 向け',
|
||||
'kernel.access.geminiDescription': 'Gemini ネイティブ互換クライアント向け',
|
||||
'authFiles.unnamed': '無名の認証ファイル',
|
||||
'management.notConfigured': '未設定',
|
||||
'management.error.upstream': '上流リクエストに失敗しました',
|
||||
'management.error.upstreamHttp': '上流が HTTP {status} を返しました',
|
||||
'model.error.invalidBaseUrl': 'Base URL が無効です。https://api.example.com のような URL を入力してください',
|
||||
'model.error.unsupportedBaseUrl': 'Base URL は http:// または https:// のみ使用できます',
|
||||
'model.error.baseUrlRequired': '先に Base URL を入力してください',
|
||||
'model.error.noResponse': 'モデル API から応答がありません',
|
||||
'quota.service.relative.daysHours': '{days} 日 {hours} 時間後',
|
||||
'quota.service.relative.days': '{days} 日後',
|
||||
'quota.service.relative.hoursMinutes': '{hours} 時間 {minutes} 分後',
|
||||
'quota.service.relative.hours': '{hours} 時間後',
|
||||
'quota.service.relative.minutes': '{minutes} 分後',
|
||||
'quota.service.duration.unknown': '不明な期間',
|
||||
'quota.service.duration.days': '{count} 日',
|
||||
'quota.service.duration.hours': '{count} 時間',
|
||||
'quota.service.duration.minutes': '{count} 分',
|
||||
'quota.service.duration.seconds': '{count} 秒',
|
||||
'quota.service.limit.fiveHours': '5 時間制限',
|
||||
'quota.service.limit.week': '週間制限',
|
||||
'quota.service.limit.month': '月間制限',
|
||||
'quota.service.limit.duration': '{duration}制限',
|
||||
'quota.service.codeReview': 'コードレビュー',
|
||||
'quota.service.additional': '追加 {index}',
|
||||
'quota.service.window.fiveHour': '5 時間枠',
|
||||
'quota.service.window.sevenDay': '7 日間枠',
|
||||
'quota.service.window.sevenDayOAuth': '7 日間 OAuth アプリ枠',
|
||||
'quota.service.window.sevenDayOpus': '7 日間 Opus 枠',
|
||||
'quota.service.window.sevenDaySonnet': '7 日間 Sonnet 枠',
|
||||
'quota.service.window.sevenDayCowork': '7 日間 Cowork 枠',
|
||||
'quota.service.window.duration': '{duration}枠',
|
||||
'quota.service.extraUsage': '追加使用量',
|
||||
'quota.service.usedOf': '使用済み {used} / {limit}',
|
||||
'quota.service.weekly': '週間クォータ',
|
||||
'quota.service.limit.numbered': '制限 {index}',
|
||||
'quota.service.product.numbered': '製品 {index}',
|
||||
'quota.service.monthlyIncluded': '月間内包クォータ',
|
||||
'quota.service.onDemand': '従量課金クォータ',
|
||||
'quota.service.quota': 'クォータ',
|
||||
'quota.service.quota.numbered': 'クォータ {index}',
|
||||
'quota.service.error.missingAuthIndex': 'auth-index がないためクォータを照会できません',
|
||||
'quota.service.error.missingProject': 'Antigravity project ID がありません',
|
||||
'quota.service.error.antigravityEmpty': 'Antigravity は成功しましたが、認識可能なクォータグループがありません',
|
||||
'quota.service.error.upstreamEmpty': '上流から空のクォータデータが返されました',
|
||||
'quota.service.error.noResponse': 'クォータ API から応答がありません',
|
||||
'quota.service.error.missingResetAuthIndex': 'auth-index がないため手動リセット回数を照会できません',
|
||||
'quota.service.error.unsupportedProvider': 'このプロバイダーのクォータ照会には対応していません',
|
||||
'quota.service.error.unrecognized': '上流からクォータデータが返されましたが、形式を認識できません',
|
||||
'quota.service.error.codexResetOnly': 'リセットクォータを使用できるのは Codex 認証情報のみです',
|
||||
'quota.service.error.missingConsumeAuthIndex': 'auth-index がないためリセットクォータを使用できません',
|
||||
} satisfies Partial<Record<MessageKey, string>>;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,116 @@
|
||||
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
|
||||
['繁体中文', '繁體中文'],
|
||||
['简体中文', '簡體中文'],
|
||||
['智能体', '智慧代理'],
|
||||
['使用记录', '使用記錄'],
|
||||
['认证文件', '認證檔案'],
|
||||
['配置文件', '設定檔'],
|
||||
['配置', '設定'],
|
||||
['文件夹', '資料夾'],
|
||||
['文件', '檔案'],
|
||||
['软件版本', '軟體版本'],
|
||||
['软件', '軟體'],
|
||||
['内核', '核心'],
|
||||
['管理接口', '管理介面'],
|
||||
['接口', '介面'],
|
||||
['默认', '預設'],
|
||||
['服务器', '伺服器'],
|
||||
['提供商', '供應商'],
|
||||
['供应商', '供應商'],
|
||||
['局域网', '區域網路'],
|
||||
['网络', '網路'],
|
||||
['本机', '本機'],
|
||||
['用户名', '使用者名稱'],
|
||||
['用户', '使用者'],
|
||||
['账号', '帳號'],
|
||||
['密钥', '金鑰'],
|
||||
['令牌', '權杖'],
|
||||
['凭据', '憑證'],
|
||||
['信息', '資訊'],
|
||||
['数据', '資料'],
|
||||
['应用程序', '應用程式'],
|
||||
['应用', '應用程式'],
|
||||
['支持', '支援'],
|
||||
['兼容', '相容'],
|
||||
['链接', '連結'],
|
||||
['目录', '目錄'],
|
||||
['加载', '載入'],
|
||||
['保存', '儲存'],
|
||||
['添加', '新增'],
|
||||
['创建', '建立'],
|
||||
['导入', '匯入'],
|
||||
['导出', '匯出'],
|
||||
['粘贴', '貼上'],
|
||||
['复制', '複製'],
|
||||
['重复', '重複'],
|
||||
['刷新', '重新整理'],
|
||||
['检测', '偵測'],
|
||||
['查找', '尋找'],
|
||||
['运行', '執行'],
|
||||
['启动', '啟動'],
|
||||
['关闭', '關閉'],
|
||||
['打开', '開啟'],
|
||||
['窗口', '視窗'],
|
||||
['选项', '選項'],
|
||||
['设置', '設定'],
|
||||
['重置', '重設'],
|
||||
['按钮', '按鈕'],
|
||||
['代码审查', '程式碼審查'],
|
||||
['代码', '程式碼'],
|
||||
['程序', '程式'],
|
||||
['访问', '存取'],
|
||||
['日志', '日誌'],
|
||||
['每周', '每週'],
|
||||
['周限额', '週限額'],
|
||||
['周窗口', '週視窗'],
|
||||
['小时', '小時'],
|
||||
['分钟', '分鐘'],
|
||||
['当前', '目前'],
|
||||
['这里', '這裡'],
|
||||
['正在', '正在'],
|
||||
];
|
||||
|
||||
const characterReplacements: Readonly<Record<string, string>> = {
|
||||
败: '敗', 绑: '綁', 备: '備', 闭: '閉', 编: '編', 变: '變', 标: '標',
|
||||
别: '別', 并: '並', 采: '採', 侧: '側', 测: '測', 产: '產', 尝: '嘗',
|
||||
厂: '廠', 称: '稱', 迟: '遲', 当: '當',
|
||||
冲: '衝', 储: '儲', 处: '處', 传: '傳', 创: '創', 词: '詞', 从: '從',
|
||||
错: '錯', 导: '導', 点: '點', 调: '調', 动: '動', 对: '對', 额: '額',
|
||||
发: '發', 范: '範', 访: '訪', 费: '費', 复: '復', 该: '該', 盖: '蓋',
|
||||
个: '個', 构: '構', 关: '關', 规: '規', 过: '過', 号: '號', 户: '戶',
|
||||
环: '環', 缓: '緩', 换: '換', 会: '會', 获: '獲', 击: '擊', 机: '機',
|
||||
级: '級', 辑: '輯', 计: '計', 记: '記', 际: '際', 继: '繼', 监: '監',
|
||||
检: '檢', 简: '簡', 见: '見', 鉴: '鑑', 键: '鍵', 将: '將', 阶: '階',
|
||||
结: '結', 仅: '僅', 进: '進', 经: '經', 据: '據', 开: '開', 览: '覽', 间: '間',
|
||||
类: '類', 离: '離', 连: '連', 联: '聯', 链: '鏈', 浏: '瀏', 录: '錄',
|
||||
轮: '輪', 络: '絡', 码: '碼', 没: '沒', 们: '們', 内: '內', 盘: '盤',
|
||||
后: '後', 况: '況', 来: '來', 吗: '嗎', 却: '卻',
|
||||
凭: '憑', 启: '啟', 强: '強', 轻: '輕', 请: '請', 趋: '趨', 权: '權',
|
||||
确: '確', 让: '讓', 认: '認', 软: '軟', 筛: '篩', 删: '刪', 设: '設',
|
||||
审: '審', 时: '時', 识: '識', 实: '實', 势: '勢', 试: '試', 适: '適',
|
||||
输: '輸', 数: '數', 台: '臺', 态: '態', 体: '體', 听: '聽', 统: '統',
|
||||
条: '條', 贴: '貼', 问: '問', 须: '須', 许: '許', 绪: '緒', 钥: '鑰', 于: '於',
|
||||
头: '頭', 网: '網', 围: '圍', 伪: '偽', 为: '為', 无: '無', 务: '務',
|
||||
误: '誤', 细: '細', 显: '顯', 现: '現', 线: '線', 响: '響', 项: '項',
|
||||
写: '寫', 续: '續', 选: '選', 询: '詢', 压: '壓', 严: '嚴', 页: '頁',
|
||||
义: '義', 议: '議', 异: '異', 隐: '隱', 应: '應', 优: '優', 与: '與',
|
||||
语: '語', 预: '預', 运: '運', 载: '載', 暂: '暫', 则: '則', 择: '擇',
|
||||
张: '張', 长: '長', 账: '帳', 这: '這', 证: '證', 执: '執', 钟: '鐘',
|
||||
转: '轉', 装: '裝', 状: '狀', 缀: '綴', 准: '準', 总: '總', 组: '組',
|
||||
};
|
||||
|
||||
const toTraditional = (message: string): string => {
|
||||
const phrased = phraseReplacements.reduce(
|
||||
(current, [simplified, traditional]) => current.split(simplified).join(traditional),
|
||||
message,
|
||||
);
|
||||
return Array.from(phrased, (character) => characterReplacements[character] ?? character).join('');
|
||||
};
|
||||
|
||||
export function createTraditionalMessages<T extends Record<string, string>>(
|
||||
messages: T,
|
||||
): { [K in keyof T]: string } {
|
||||
return Object.fromEntries(
|
||||
Object.entries(messages).map(([key, message]) => [key, toTraditional(message)]),
|
||||
) as { [K in keyof T]: string };
|
||||
}
|
||||
+6
-3
@@ -2,12 +2,15 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AppErrorBoundary } from './AppErrorBoundary';
|
||||
import App from './App';
|
||||
import { I18nProvider } from './i18n';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<App />
|
||||
</AppErrorBoundary>
|
||||
<I18nProvider>
|
||||
<AppErrorBoundary>
|
||||
<App />
|
||||
</AppErrorBoundary>
|
||||
</I18nProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
+82
-77
@@ -34,6 +34,7 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from '../services/agentModelPicker';
|
||||
import type { ModelOption } from '../services/modelService';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
|
||||
type AgentClientId =
|
||||
| 'claude-code'
|
||||
@@ -83,7 +84,7 @@ type AgentDefinition = {
|
||||
name: string;
|
||||
icon?: string;
|
||||
Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
|
||||
description: string;
|
||||
descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes';
|
||||
};
|
||||
|
||||
const agentDefinitions: AgentDefinition[] = [
|
||||
@@ -91,37 +92,37 @@ const agentDefinitions: AgentDefinition[] = [
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
icon: claudeIcon,
|
||||
description: '使用 CPA 的 Anthropic 兼容接口',
|
||||
descriptionKey: 'agents.description.claudeCode',
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
icon: claudeIcon,
|
||||
description: '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta',
|
||||
descriptionKey: 'agents.description.claudeDesktop',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
icon: codexIcon,
|
||||
description: '使用 CPA Responses 接口',
|
||||
descriptionKey: 'agents.description.codex',
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
icon: opencodeIcon,
|
||||
description: '使用 CPA OpenAI Compatible provider',
|
||||
descriptionKey: 'agents.description.opencode',
|
||||
},
|
||||
{
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
icon: openclawIcon,
|
||||
description: '使用 CPA 模型供应商和默认模型',
|
||||
descriptionKey: 'agents.description.openclaw',
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
name: 'Hermes Agent',
|
||||
icon: hermesIcon,
|
||||
description: '使用 CPA custom provider',
|
||||
descriptionKey: 'agents.description.hermes',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -197,13 +198,16 @@ function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; siz
|
||||
}
|
||||
|
||||
const listStatusText = (status: AgentConfigStatus | undefined) => {
|
||||
if (!status) return '正在检测';
|
||||
if (!status.supportedPlatform) return '当前平台不支持';
|
||||
if (!status.installed) return '未检测到安装';
|
||||
if (status.modificationState === 'conflict') return '配置冲突 · 等待恢复';
|
||||
if (status.modificationState === 'recovery') return '操作未完成 · 可恢复';
|
||||
if (status.modificationEnabled) return `已修改 · ${status.appliedModel ?? '未记录模型'}`;
|
||||
return status.version ? `已安装 · ${status.version}` : '已安装 · 保持原配置';
|
||||
const locale = getCurrentLocale();
|
||||
if (!status) return translate(locale, 'agents.list.detecting');
|
||||
if (!status.supportedPlatform) return translate(locale, 'agents.list.unsupported');
|
||||
if (!status.installed) return translate(locale, 'agents.list.notInstalled');
|
||||
if (status.modificationState === 'conflict') return translate(locale, 'agents.list.conflict');
|
||||
if (status.modificationState === 'recovery') return translate(locale, 'agents.list.recovery');
|
||||
if (status.modificationEnabled) return translate(locale, 'agents.list.modified', { model: status.appliedModel ?? '—' });
|
||||
return status.version
|
||||
? translate(locale, 'agents.list.installedVersion', { version: status.version })
|
||||
: translate(locale, 'agents.list.installed');
|
||||
};
|
||||
|
||||
type AgentModelPickerProps = {
|
||||
@@ -232,6 +236,7 @@ function AgentModelPicker({
|
||||
onChange,
|
||||
onRefresh,
|
||||
}: AgentModelPickerProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
@@ -357,7 +362,7 @@ function AgentModelPicker({
|
||||
>
|
||||
<span>
|
||||
<strong title={value || undefined}>
|
||||
{value || (loading ? '正在读取模型' : error ? '获取模型失败' : models.length ? '选择模型' : '无可选模型')}
|
||||
{value || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))}
|
||||
</strong>
|
||||
{selectedAlias ? <small title={selectedAlias}>{selectedAlias}</small> : null}
|
||||
</span>
|
||||
@@ -381,7 +386,7 @@ function AgentModelPicker({
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="搜索模型名称或别名"
|
||||
placeholder={t('agents.model.search')}
|
||||
role="combobox"
|
||||
aria-controls="agent-model-listbox"
|
||||
aria-expanded="true"
|
||||
@@ -395,25 +400,25 @@ function AgentModelPicker({
|
||||
setActiveIndex(0);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
title="清空搜索"
|
||||
title={t('agents.model.clearSearch')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title="刷新模型">
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')}>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="agent-model-list" id="agent-model-listbox" role="listbox">
|
||||
{loading && models.length === 0 ? (
|
||||
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />正在获取模型</div>
|
||||
<div className="agent-model-empty"><LoaderCircle size={18} className="spin" />{t('agents.model.fetching')}</div>
|
||||
) : error && models.length === 0 ? (
|
||||
<div className="agent-model-empty error"><strong>获取模型失败</strong><span>{error}</span></div>
|
||||
<div className="agent-model-empty error"><strong>{t('agents.model.loadFailed')}</strong><span>{error}</span></div>
|
||||
) : choices.length === 0 ? (
|
||||
<div className="agent-model-empty">
|
||||
<strong>{search.trim() ? '没有匹配的模型' : '暂时没有可用模型'}</strong>
|
||||
<span>{search.trim() ? '尝试其他关键词' : '请先接入可用模型后刷新'}</span>
|
||||
<strong>{search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')}</strong>
|
||||
<span>{search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')}</span>
|
||||
</div>
|
||||
) : choices.map((choice, index) => {
|
||||
const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase();
|
||||
@@ -429,7 +434,7 @@ function AgentModelPicker({
|
||||
>
|
||||
<span>
|
||||
<strong title={choice.name}>{choice.name}</strong>
|
||||
<small>{choice.alias || '可用模型'}</small>
|
||||
<small>{choice.alias || t('agents.model.available')}</small>
|
||||
</span>
|
||||
{selected ? <Check size={16} aria-hidden /> : null}
|
||||
</button>
|
||||
@@ -437,8 +442,8 @@ function AgentModelPicker({
|
||||
})}
|
||||
</div>
|
||||
<div className="agent-model-dropdown-footer">
|
||||
<span>{models.length} 个可用模型</span>
|
||||
{error && models.length > 0 ? <span className="error">刷新失败,显示上次结果</span> : null}
|
||||
<span>{t('agents.model.count', { count: models.length })}</span>
|
||||
{error && models.length > 0 ? <span className="error">{t('agents.model.stale')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -447,6 +452,7 @@ function AgentModelPicker({
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
@@ -574,16 +580,16 @@ export function AgentsPage() {
|
||||
|
||||
const requireSelectedModel = () => {
|
||||
if (modelLoading) {
|
||||
setError('模型列表仍在加载,请稍后再试');
|
||||
setError(t('agents.error.modelsLoading'));
|
||||
return null;
|
||||
}
|
||||
if (models.length === 0) {
|
||||
setError(modelError || '当前没有可选模型,无法修改智能体配置');
|
||||
setError(modelError || t('agents.error.noModels'));
|
||||
return null;
|
||||
}
|
||||
const model = findAgentModel(models, selectedModel);
|
||||
if (!model) {
|
||||
setError('当前选择已不在可用模型列表中,请刷新模型后重新选择');
|
||||
setError(t('agents.error.selectionGone'));
|
||||
return null;
|
||||
}
|
||||
return model.name;
|
||||
@@ -608,8 +614,8 @@ export function AgentsPage() {
|
||||
}
|
||||
setRestoreConflict(null);
|
||||
setNotice(result.enabled
|
||||
? `${activeDefinition.name} 已启用配置修改;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 的原配置已恢复,请重启客户端`);
|
||||
? t('agents.notice.enabled', { name: activeDefinition.name })
|
||||
: t('agents.notice.restored', { name: activeDefinition.name }));
|
||||
await loadStatuses(true);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -629,7 +635,7 @@ export function AgentsPage() {
|
||||
);
|
||||
if (draftChanged || shouldSyncModelCatalog) {
|
||||
if (activeStatus?.modificationState !== 'active') {
|
||||
throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型');
|
||||
throw new Error(t('agents.error.conflict'));
|
||||
}
|
||||
const model = requireSelectedModel();
|
||||
if (!model) return;
|
||||
@@ -642,11 +648,11 @@ export function AgentsPage() {
|
||||
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
|
||||
setNotice(appliedBeforeLaunch
|
||||
? draftChanged
|
||||
? `${activeDefinition.name} 已应用模型并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 已同步模型目录并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
? t('agents.notice.appliedLaunch', { name: activeDefinition.name })
|
||||
: t('agents.notice.syncedLaunch', { name: activeDefinition.name })
|
||||
: activeStatus?.modificationEnabled
|
||||
? `${activeDefinition.name} 已启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 已启动`);
|
||||
? t('agents.notice.managedLaunch', { name: activeDefinition.name })
|
||||
: t('agents.notice.launched', { name: activeDefinition.name }));
|
||||
if (appliedBeforeLaunch) await loadStatuses(true);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -656,18 +662,18 @@ export function AgentsPage() {
|
||||
};
|
||||
|
||||
const statusLabel = loading
|
||||
? '检测中'
|
||||
? t('agents.status.detecting')
|
||||
: !activeStatus?.supportedPlatform
|
||||
? '平台不支持'
|
||||
? t('agents.status.unsupported')
|
||||
: !activeStatus.installed
|
||||
? '未安装'
|
||||
? t('agents.status.notInstalled')
|
||||
: activeStatus.modificationState === 'conflict'
|
||||
? '配置冲突'
|
||||
? t('agents.status.conflict')
|
||||
: activeStatus.modificationState === 'recovery'
|
||||
? '需要恢复'
|
||||
? t('agents.status.recovery')
|
||||
: activeStatus.modificationEnabled
|
||||
? '已修改配置'
|
||||
: '保持原配置';
|
||||
? t('agents.status.modified')
|
||||
: t('agents.status.original');
|
||||
const statusTone = activeStatus?.modificationState === 'conflict'
|
||||
|| activeStatus?.modificationState === 'recovery'
|
||||
|| !activeStatus?.supportedPlatform
|
||||
@@ -681,11 +687,11 @@ export function AgentsPage() {
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Agent Clients</span>
|
||||
<h1>智能体配置</h1>
|
||||
<h1>{t('agents.title')}</h1>
|
||||
</div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refresh()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
重新检测
|
||||
{t('agents.redetect')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -698,7 +704,7 @@ export function AgentsPage() {
|
||||
<aside className="panel agent-client-list">
|
||||
<div className="agent-list-heading">
|
||||
<Bot size={18} />
|
||||
<div><strong>本机客户端</strong><span>选择需要管理的智能体</span></div>
|
||||
<div><strong>{t('agents.localClients')}</strong><span>{t('agents.selectClient')}</span></div>
|
||||
</div>
|
||||
<div className="agent-list-items">
|
||||
{agentDefinitions.map((agent) => {
|
||||
@@ -727,7 +733,7 @@ export function AgentsPage() {
|
||||
<div className="agent-config-heading">
|
||||
<div className="agent-config-title">
|
||||
<span className="agent-logo"><AgentMark definition={activeDefinition} size={24} /></span>
|
||||
<div><h2>{activeDefinition.name}</h2><span>{activeDefinition.description}</span></div>
|
||||
<div><h2>{activeDefinition.name}</h2><span>{t(activeDefinition.descriptionKey)}</span></div>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`}>{statusLabel}</span>
|
||||
</div>
|
||||
@@ -741,19 +747,19 @@ export function AgentsPage() {
|
||||
|
||||
<div className="agent-status-grid">
|
||||
<div>
|
||||
<span><BadgeCheck size={14} />安装状态</span>
|
||||
<strong>{activeStatus?.installed ? '已检测到客户端' : '未检测到客户端'}</strong>
|
||||
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
|
||||
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>客户端版本</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? '未获取'}</strong>
|
||||
<span>{t('agents.clientVersion')}</span>
|
||||
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>使用模型</strong><span>同步内核当前开放的模型,并将所选项设为默认模型</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">尚未应用</span> : null}
|
||||
<div><strong>{t('agents.useModel')}</strong><span>{t('agents.useModelDescription')}</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
@@ -766,29 +772,29 @@ export function AgentsPage() {
|
||||
/>
|
||||
<span className="agent-model-hint">
|
||||
{modelLoading
|
||||
? '正在读取可用模型'
|
||||
? t('agents.model.readingAvailable')
|
||||
: modelError && models.length === 0
|
||||
? modelError
|
||||
: models.length === 0
|
||||
? '无可选模型,不能开启或更新配置'
|
||||
? t('agents.model.cannotConfigure')
|
||||
: activeStatus?.modificationEnabled
|
||||
? `当前已应用:${appliedModel || '未记录模型'}`
|
||||
: `${models.length} 个可用模型,首次默认选择第一项`}
|
||||
? t('agents.model.current', { model: appliedModel || '—' })
|
||||
: t('agents.model.firstSelection', { count: models.length })}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className={`agent-modification-switch ${activeStatus?.modificationEnabled ? 'enabled' : ''}`}>
|
||||
<div>
|
||||
<strong>修改智能体配置</strong>
|
||||
<strong>{t('agents.modify.title')}</strong>
|
||||
<span>{activeStatus?.modificationEnabled
|
||||
? activeStatus.modificationState === 'conflict'
|
||||
? '配置已被外部修改,关闭时需要确认恢复'
|
||||
? t('agents.modify.conflict')
|
||||
: activeStatus.modificationState === 'recovery'
|
||||
? '上次操作未完成,关闭开关可尝试恢复原配置'
|
||||
: '原配置已备份,当前由 CPA 管理'
|
||||
: '启动客户端前必须开启;程序会先备份原配置,再写入 CPA 配置'}</span>
|
||||
? t('agents.modify.recovery')
|
||||
: t('agents.modify.managed')
|
||||
: t('agents.modify.disabled')}</span>
|
||||
</div>
|
||||
<label className="switch-control" title="修改智能体配置">
|
||||
<label className="switch-control" title={t('agents.modify.title')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(activeStatus?.modificationEnabled)}
|
||||
@@ -804,21 +810,21 @@ export function AgentsPage() {
|
||||
{activeStatus?.modificationEnabled ? <Check size={16} /> : <Sparkles size={16} />}
|
||||
<span>{activeStatus?.modificationEnabled
|
||||
? draftChanged
|
||||
? '模型选择已变化,启动时会先应用新模型'
|
||||
: '退出软件前建议关闭“修改智能体配置”,恢复开启前的原配置文件'
|
||||
? t('agents.footer.changed')
|
||||
: t('agents.footer.restore')
|
||||
: !activeStatus?.supportedPlatform
|
||||
? '当前系统无法配置该客户端'
|
||||
? t('agents.footer.unsupported')
|
||||
: !activeStatus.installed
|
||||
? '请先安装客户端并重新检测'
|
||||
? t('agents.footer.installFirst')
|
||||
: activeStatus.launchTargets.length === 0
|
||||
? '检测到配置文件,但没有找到客户端命令'
|
||||
? t('agents.footer.noCommand')
|
||||
: !activeStatus.configValid
|
||||
? '原配置格式异常,无法安全修改'
|
||||
: '请先开启“修改智能体配置”,确保 CPA 模型配置生效后再启动客户端'}</span>
|
||||
? t('agents.footer.invalidConfig')
|
||||
: t('agents.footer.enableFirst')}</span>
|
||||
</div>
|
||||
<div className="agent-launch-actions">
|
||||
{activeLaunchTargets.length > 1 ? (
|
||||
<div className="agent-launch-targets" aria-label="Codex 启动方式">
|
||||
<div className="agent-launch-targets" aria-label={t('agents.launchMethods')}>
|
||||
{activeLaunchTargets.map((target) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -847,10 +853,10 @@ export function AgentsPage() {
|
||||
}
|
||||
title={activeStatus?.modificationEnabled
|
||||
? selectedLaunchTarget?.detail
|
||||
: '请先开启“修改智能体配置”'}
|
||||
: t('agents.launch.enableFirst')}
|
||||
>
|
||||
{busy ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
|
||||
{busy ? '启动中' : selectedLaunchTarget ? `启动 ${selectedLaunchTarget.label}` : '无法启动'}
|
||||
{busy ? t('agents.launch.starting') : selectedLaunchTarget ? t('agents.launch.start', { target: selectedLaunchTarget.label }) : t('agents.launch.unavailable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -861,17 +867,16 @@ export function AgentsPage() {
|
||||
<div className="config-dialog-backdrop">
|
||||
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-restore-title">
|
||||
<div className="config-dialog-heading">
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-restore-title">配置已发生外部变化</h2></div>
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-restore-title">{t('agents.restore.title')}</h2></div>
|
||||
</div>
|
||||
<p>
|
||||
开启修改配置后,有 {restoreConflict.length} 个配置文件又被其他程序修改。
|
||||
继续恢复会使用开启前的备份覆盖这些变化。
|
||||
{t('agents.restore.description', { count: restoreConflict.length })}
|
||||
</p>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setRestoreConflict(null)} disabled={busy}>取消</button>
|
||||
<button type="button" className="secondary-button" onClick={() => setRestoreConflict(null)} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" className="danger-button" onClick={() => void setModificationEnabled(false, true)} disabled={busy}>
|
||||
{busy ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
确认强制恢复
|
||||
{t('agents.restore.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+98
-95
@@ -32,6 +32,7 @@ import {
|
||||
type ModelProvider,
|
||||
} from '../services/modelService';
|
||||
import { modelMatchesRule } from '../services/oauthModels';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
|
||||
export type ProviderSection =
|
||||
| 'gemini-api-key'
|
||||
@@ -94,7 +95,7 @@ const providerDefinitions: ProviderDefinition[] = [
|
||||
id: 'openai-compatibility',
|
||||
section: 'openai-compatibility',
|
||||
responseKey: 'openai-compatibility',
|
||||
label: 'OpenAI 兼容',
|
||||
label: 'OpenAI Compatible',
|
||||
icon: openaiIcon,
|
||||
openAi: true,
|
||||
},
|
||||
@@ -168,7 +169,7 @@ const rowFromRecord = (
|
||||
index,
|
||||
record,
|
||||
name: definitionFor(section).openAi
|
||||
? readString(record, 'name') || `OpenAI 兼容 ${index + 1}`
|
||||
? readString(record, 'name') || translate(getCurrentLocale(), 'apiAccess.compatibleName', { number: index + 1 })
|
||||
: definitionFor(section).label,
|
||||
apiKey: entry ? readString(entry, 'api-key', 'apiKey') : singleApiKey,
|
||||
apiKeys: entry ? apiKeys : singleApiKey ? [singleApiKey] : [],
|
||||
@@ -406,11 +407,11 @@ export const parseProviderHeaders = (value: string): Record<string, string> => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
const separator = trimmed.indexOf(':');
|
||||
if (separator <= 0) throw new Error(`自定义请求头第 ${index + 1} 行缺少冒号`);
|
||||
if (separator <= 0) throw new Error(translate(getCurrentLocale(), 'apiAccess.error.headerMissingColon', { number: index + 1 }));
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const headerValue = trimmed.slice(separator + 1).trim();
|
||||
if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(key) || !headerValue) {
|
||||
throw new Error(`自定义请求头第 ${index + 1} 行格式无效`);
|
||||
throw new Error(translate(getCurrentLocale(), 'apiAccess.error.headerInvalid', { number: index + 1 }));
|
||||
}
|
||||
const duplicateKey = Object.keys(headers).find(
|
||||
(current) => current.toLowerCase() === key.toLowerCase(),
|
||||
@@ -539,6 +540,7 @@ const providerIdentityMatches = (row: ProviderRow, record: Record<string, unknow
|
||||
};
|
||||
|
||||
export function ApiAccessPage() {
|
||||
const { t } = useI18n();
|
||||
const [records, setRecords] = useState(emptyRecords);
|
||||
const [activeCategory, setActiveCategory] = useState<ProviderCategory>('codex-api-key');
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -579,7 +581,7 @@ export function ApiAccessPage() {
|
||||
return next;
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
setError(`部分接入读取失败:${failures.join(';')}`);
|
||||
setError(t('apiAccess.error.partialLoad', { errors: failures.join('; ') }));
|
||||
}
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -638,10 +640,10 @@ export function ApiAccessPage() {
|
||||
) {
|
||||
setError(
|
||||
definition.openAi
|
||||
? '名称、Base URL 和密钥不能为空'
|
||||
? t('apiAccess.error.requiredAll')
|
||||
: baseUrlRequired
|
||||
? 'Base URL 和 API 密钥不能为空'
|
||||
: 'API 密钥不能为空',
|
||||
? t('apiAccess.error.requiredBaseKey')
|
||||
: t('apiAccess.error.requiredKey'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -649,7 +651,7 @@ export function ApiAccessPage() {
|
||||
let providerHeaders: Record<string, string> = {};
|
||||
try {
|
||||
if (baseUrl) baseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (baseUrlRequired && !baseUrl) throw new Error(`${definition.label} 接入必须填写 Base URL`);
|
||||
if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: definition.label }));
|
||||
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -668,7 +670,7 @@ export function ApiAccessPage() {
|
||||
providerHeaders,
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error('没有发现可放行的模型,请确认 Base URL 和 API 密钥');
|
||||
throw new Error(t('apiAccess.error.noModels'));
|
||||
}
|
||||
draftToSave = applyProviderPreset(activeCategory, {
|
||||
...draftToSave,
|
||||
@@ -684,7 +686,7 @@ export function ApiAccessPage() {
|
||||
providerIdentityMatches(editingRow, record),
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
throw new Error('接入配置已被其他操作修改,请刷新后重试');
|
||||
throw new Error(t('apiAccess.error.stale'));
|
||||
}
|
||||
const nextRecord = buildProviderRecord(
|
||||
activeSection,
|
||||
@@ -701,7 +703,7 @@ export function ApiAccessPage() {
|
||||
: readString(record, 'api-key', 'apiKey') === preparedDraft.apiKey.trim()
|
||||
&& readString(record, 'base-url', 'baseUrl') === baseUrl,
|
||||
);
|
||||
if (duplicate) throw new Error('相同的接入配置已经存在');
|
||||
if (duplicate) throw new Error(t('apiAccess.error.duplicate'));
|
||||
nextList = [
|
||||
...current,
|
||||
buildProviderRecord(activeSection, draftToSave),
|
||||
@@ -709,7 +711,7 @@ export function ApiAccessPage() {
|
||||
}
|
||||
|
||||
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
|
||||
setNotice(editingRow ? '接入已更新' : '接入已新增');
|
||||
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
|
||||
await loadProviders();
|
||||
return true;
|
||||
} catch (requestError) {
|
||||
@@ -721,7 +723,7 @@ export function ApiAccessPage() {
|
||||
};
|
||||
|
||||
const deleteRow = async (row: ProviderRow) => {
|
||||
if (!window.confirm(`确定删除「${row.name}」吗?`)) return;
|
||||
if (!window.confirm(t('apiAccess.deleteConfirm', { name: row.name }))) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -732,7 +734,7 @@ export function ApiAccessPage() {
|
||||
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
|
||||
});
|
||||
}
|
||||
setNotice('接入已删除');
|
||||
setNotice(t('apiAccess.notice.deleted'));
|
||||
await loadProviders();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -749,7 +751,7 @@ export function ApiAccessPage() {
|
||||
const latestRows = sectionRecordsFromConfig(latestConfig, row.section);
|
||||
const targetIndex = latestRows.findIndex((record) => providerIdentityMatches(row, record));
|
||||
if (targetIndex < 0) {
|
||||
throw new Error('接入配置已被其他操作修改,请刷新后重试');
|
||||
throw new Error(t('apiAccess.error.stale'));
|
||||
}
|
||||
const latestRecord = latestRows[targetIndex];
|
||||
const definition = definitionFor(row.section);
|
||||
@@ -775,7 +777,7 @@ export function ApiAccessPage() {
|
||||
value: nextRecord,
|
||||
});
|
||||
}
|
||||
setNotice(currentlyDisabled ? '接入已启用' : '接入已停用');
|
||||
setNotice(currentlyDisabled ? t('apiAccess.notice.enabled') : t('apiAccess.notice.disabled'));
|
||||
await loadProviders();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -796,17 +798,17 @@ export function ApiAccessPage() {
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Providers</span>
|
||||
<h1>API 接入</h1>
|
||||
<h1>{t('apiAccess.title')}</h1>
|
||||
</div>
|
||||
<div className="management-heading-actions">
|
||||
<span className="muted-summary">{totalCount} 个接入</span>
|
||||
<span className="muted-summary">{t('apiAccess.count', { count: totalCount })}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadProviders()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
<button type="button" className="primary-button compact-button" onClick={openCreate} disabled={loading || busy}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
新增
|
||||
{t('apiAccess.add')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -835,21 +837,21 @@ export function ApiAccessPage() {
|
||||
<div className="management-panel-heading">
|
||||
<div>
|
||||
<h2 title={activeDefinition.label}>{activeDefinition.label}</h2>
|
||||
<span>{rows.length} 个匹配接入</span>
|
||||
<span>{t('apiAccess.matches', { count: rows.length })}</span>
|
||||
</div>
|
||||
<div className="management-toolbar compact-toolbar">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder="搜索名称、密钥或地址" />
|
||||
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('apiAccess.search')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取配置中</div>
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('apiAccess.loading')}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="management-empty">
|
||||
<Filter size={24} aria-hidden="true" />
|
||||
<strong>{filter ? '没有匹配的接入' : '暂无接入配置'}</strong>
|
||||
<span>{filter ? '换个关键词试试' : '点击右上角“新增”添加第一个接入'}</span>
|
||||
<strong>{filter ? t('apiAccess.empty.filtered') : t('apiAccess.empty.none')}</strong>
|
||||
<span>{filter ? t('apiAccess.empty.tryKeyword') : t('apiAccess.empty.addFirst')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="real-provider-list">
|
||||
@@ -859,36 +861,36 @@ export function ApiAccessPage() {
|
||||
<div className="provider-row-title">
|
||||
<strong title={row.name}>{row.name}</strong>
|
||||
</div>
|
||||
<code title={definitionFor(row.section).openAi ? `${row.apiKeys.length} 个密钥` : undefined}>
|
||||
<code title={definitionFor(row.section).openAi ? t('apiAccess.keys.count', { count: row.apiKeys.length }) : undefined}>
|
||||
{definitionFor(row.section).openAi && row.apiKeys.length > 1
|
||||
? `${maskSecret(row.apiKey)} · 共 ${row.apiKeys.length} 个密钥`
|
||||
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
|
||||
: maskSecret(row.apiKey)}
|
||||
</code>
|
||||
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || '使用默认地址'}</span>
|
||||
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>模型 {row.models.length} 个 · {row.models.slice(0, 3).map((model) => model.name).join(', ')}</span> : null}
|
||||
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
|
||||
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })}</span> : null}
|
||||
</div>
|
||||
<div className="provider-row-meta">
|
||||
{row.priority === null ? null : <span>优先级 {row.priority}</span>}
|
||||
{row.authIndex ? <span title={row.authIndex}>运行时凭据 {row.authIndex.slice(0, 8)}…</span> : null}
|
||||
{row.priority === null ? null : <span>{t('apiAccess.priorityValue', { priority: row.priority })}</span>}
|
||||
{row.authIndex ? <span title={row.authIndex}>{t('apiAccess.runtimeCredential', { index: row.authIndex.slice(0, 8) })}</span> : null}
|
||||
</div>
|
||||
<div className="provider-row-actions">
|
||||
<label className="provider-enabled-control" title={row.disabled ? '启用接入' : '停用接入'}>
|
||||
<span>{row.disabled ? '已停用' : '已启用'}</span>
|
||||
<label className="provider-enabled-control" title={row.disabled ? t('apiAccess.enable') : t('apiAccess.disable')}>
|
||||
<span>{row.disabled ? t('apiAccess.status.disabled') : t('apiAccess.status.enabled')}</span>
|
||||
<span className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!row.disabled}
|
||||
onChange={() => void toggleProvider(row)}
|
||||
disabled={busy}
|
||||
aria-label={`${row.name} 接入${row.disabled ? '启用' : '停用'}开关`}
|
||||
aria-label={t('apiAccess.toggleAria', { name: row.name, action: row.disabled ? t('common.enable') : t('common.disable') })}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title="编辑">
|
||||
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')}>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteRow(row)} disabled={busy} title="删除">
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteRow(row)} disabled={busy} title={t('common.delete')}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -930,6 +932,7 @@ function ApiProviderDialog({
|
||||
onClose,
|
||||
onSave,
|
||||
}: ApiProviderDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const definition = definitionFor(activeCategory);
|
||||
const activeSection = definition.section;
|
||||
const [draft, setDraft] = useState<ProviderDraft>(initialDraft);
|
||||
@@ -1002,7 +1005,7 @@ function ApiProviderDialog({
|
||||
const baseUrlRequired =
|
||||
activeSection === 'codex-api-key' || activeSection === 'openai-compatibility';
|
||||
if (baseUrlRequired && !draft.baseUrl.trim()) {
|
||||
setModelError('请先填写 Base URL');
|
||||
setModelError(t('apiAccess.error.enterBaseUrl'));
|
||||
return;
|
||||
}
|
||||
setModelLoading(true);
|
||||
@@ -1029,7 +1032,7 @@ function ApiProviderDialog({
|
||||
).models;
|
||||
setDiscoveredModels(models);
|
||||
setSelectedModelNames(allModelSelectionForDiscovery(models));
|
||||
if (!models.length) setModelError('未发现可用模型');
|
||||
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
|
||||
} catch (requestError) {
|
||||
setDiscoveredModels([]);
|
||||
setModelError(String(requestError));
|
||||
@@ -1042,7 +1045,7 @@ function ApiProviderDialog({
|
||||
const baseUrlRequired =
|
||||
activeSection === 'codex-api-key' || activeSection === 'openai-compatibility';
|
||||
if (baseUrlRequired && !draft.baseUrl.trim()) {
|
||||
setModelError('请先填写 Base URL,再获取模型');
|
||||
setModelError(t('apiAccess.error.baseBeforeModels'));
|
||||
return;
|
||||
}
|
||||
setModelSearch('');
|
||||
@@ -1099,19 +1102,19 @@ function ApiProviderDialog({
|
||||
const hasModelExclusions = activeSection !== 'openai-compatibility'
|
||||
&& Boolean(draft.excludedModelsText?.trim());
|
||||
const modelSummaryTitle = draft.models.length > 0
|
||||
? `已选择 ${draft.models.length} 个模型`
|
||||
? t('apiAccess.models.selected', { count: draft.models.length })
|
||||
: hasModelExclusions
|
||||
? '已配置模型限制'
|
||||
? t('apiAccess.models.restricted')
|
||||
: activeSection === 'openai-compatibility'
|
||||
? '保存时默认开放全部模型'
|
||||
: '使用上游默认模型';
|
||||
? t('apiAccess.models.autoAll')
|
||||
: t('apiAccess.models.upstreamDefault');
|
||||
const modelSummaryDetail = draft.models.length > 0
|
||||
? draft.models.slice(0, 3).map((model) => model.name).join('、')
|
||||
: hasModelExclusions
|
||||
? '未勾选的模型不会出现在开放列表'
|
||||
? t('apiAccess.models.hiddenHint')
|
||||
: activeSection === 'openai-compatibility'
|
||||
? '保存接入时会自动获取模型,也可提前取消不需要的项目'
|
||||
: '当前开放上游全部模型';
|
||||
? t('apiAccess.models.autoHint')
|
||||
: t('apiAccess.models.allHint');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1120,38 +1123,38 @@ function ApiProviderDialog({
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<Plus size={19} aria-hidden="true" />
|
||||
<h2>{editingRow ? '编辑 API 接入' : '新增 API 接入'}</h2>
|
||||
<h2>{editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title="关闭">
|
||||
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{definition.openAi ? (
|
||||
<label><span>名称</span><input autoFocus value={draft.name} onChange={(event) => updateTextField('name', event.currentTarget.value)} placeholder="openrouter" /></label>
|
||||
<label><span>{t('apiAccess.field.name')}</span><input autoFocus value={draft.name} onChange={(event) => updateTextField('name', event.currentTarget.value)} placeholder="openrouter" /></label>
|
||||
) : null}
|
||||
<label className={definition.openAi ? 'multiline-field' : undefined}>
|
||||
<span>{definition.openAi ? 'API 密钥(每行一个)' : 'API 密钥'}</span>
|
||||
<span>{definition.openAi ? t('apiAccess.field.keysMany') : t('apiAccess.field.key')}</span>
|
||||
{definition.openAi ? (
|
||||
<textarea value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder={'sk-...\nsk-...'} rows={3} />
|
||||
) : (
|
||||
<input autoFocus type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
|
||||
)}
|
||||
</label>
|
||||
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? '必填,例如 https://api.example.com' : '可选,留空使用默认地址'} /></label>
|
||||
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
|
||||
{activeCategory === 'deepseek' ? (
|
||||
<div className="provider-preset-summary">
|
||||
<img src={deepseekIcon} alt="" className="provider-logo" />
|
||||
<div>
|
||||
<strong>OpenAI 兼容预设</strong>
|
||||
<span>已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型</span>
|
||||
<strong>{t('apiAccess.preset.title')}</strong>
|
||||
<span>{t('apiAccess.preset.description')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeCategory === 'deepseek' ? (
|
||||
<div className="thinking-level-config">
|
||||
<div className="thinking-level-heading">
|
||||
<strong>内置思考等级</strong>
|
||||
<span>自动应用到当前开放的全部模型</span>
|
||||
<strong>{t('apiAccess.thinking.builtIn')}</strong>
|
||||
<span>{t('apiAccess.thinking.builtInDescription')}</span>
|
||||
</div>
|
||||
<div className="thinking-level-tags readonly">
|
||||
{DEEPSEEK_THINKING_LEVELS.map((level) => <span key={level}>{level}</span>)}
|
||||
@@ -1160,8 +1163,8 @@ function ApiProviderDialog({
|
||||
) : activeCategory === 'openai-compatibility' ? (
|
||||
<div className="thinking-level-config">
|
||||
<div className="thinking-level-heading">
|
||||
<strong>思考等级</strong>
|
||||
<span>按上游支持情况自行添加</span>
|
||||
<strong>{t('apiAccess.thinking.title')}</strong>
|
||||
<span>{t('apiAccess.thinking.description')}</span>
|
||||
</div>
|
||||
<div className="thinking-level-entry">
|
||||
<input
|
||||
@@ -1173,10 +1176,10 @@ function ApiProviderDialog({
|
||||
addThinkingLevel();
|
||||
}
|
||||
}}
|
||||
placeholder="例如 low、medium 或自定义等级"
|
||||
placeholder={t('apiAccess.thinking.placeholder')}
|
||||
/>
|
||||
<button type="button" className="secondary-button compact-button" onClick={addThinkingLevel} disabled={!thinkingLevelInput.trim()}>
|
||||
<Plus size={14} />添加
|
||||
<Plus size={14} />{t('apiAccess.thinking.add')}
|
||||
</button>
|
||||
</div>
|
||||
{(draft.thinkingLevels?.length ?? 0) > 0 ? (
|
||||
@@ -1184,20 +1187,20 @@ function ApiProviderDialog({
|
||||
{draft.thinkingLevels?.map((level) => (
|
||||
<span key={level}>
|
||||
{level}
|
||||
<button type="button" onClick={() => removeThinkingLevel(level)} title={`删除 ${level}`} aria-label={`删除思考等级 ${level}`}>
|
||||
<button type="button" onClick={() => removeThinkingLevel(level)} title={t('apiAccess.thinking.delete', { level })} aria-label={t('apiAccess.thinking.deleteAria', { level })}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : <small className="thinking-level-empty">未添加时不写入 thinking.levels</small>}
|
||||
) : <small className="thinking-level-empty">{t('apiAccess.thinking.empty')}</small>}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="model-config-card">
|
||||
<div className="model-config-heading">
|
||||
<div><span>模型</span><small>勾选表示当前开放,取消勾选后将从模型列表隐藏</small></div>
|
||||
<div><span>{t('apiAccess.models.title')}</span><small>{t('apiAccess.models.description')}</small></div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={openModelDiscovery} disabled={busy}>
|
||||
<RefreshCw size={15} />获取模型
|
||||
<RefreshCw size={15} />{t('apiAccess.models.fetch')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`model-config-summary ${draft.models.length || hasModelExclusions ? 'has-models' : ''}`}>
|
||||
@@ -1206,64 +1209,64 @@ function ApiProviderDialog({
|
||||
</div>
|
||||
{modelError && !modelDiscoveryOpen ? <small className="model-picker-error">{modelError}</small> : null}
|
||||
</div>
|
||||
<label><span>优先级</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder="可选" /></label>
|
||||
<label><span>{t('apiAccess.field.priority')}</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder={t('common.optional')} /></label>
|
||||
<details className="provider-advanced-settings">
|
||||
<summary>高级设置</summary>
|
||||
<summary>{t('apiAccess.advanced')}</summary>
|
||||
<div className="provider-advanced-fields">
|
||||
<label><span>模型前缀</span><input value={draft.prefix ?? ''} onChange={(event) => updateTextField('prefix', event.currentTarget.value)} placeholder="可选,例如 team-a" /></label>
|
||||
<label><span>{t('apiAccess.field.prefix')}</span><input value={draft.prefix ?? ''} onChange={(event) => updateTextField('prefix', event.currentTarget.value)} placeholder={t('apiAccess.prefixPlaceholder')} /></label>
|
||||
<label className="multiline-field">
|
||||
<span>自定义请求头(每行 Name: Value)</span>
|
||||
<span>{t('apiAccess.field.headers')}</span>
|
||||
<textarea value={draft.headersText ?? ''} onChange={(event) => updateTextField('headersText', event.currentTarget.value)} rows={3} placeholder={'X-Team: production\nAuthorization: Bearer ...'} />
|
||||
</label>
|
||||
{activeSection !== 'openai-compatibility' ? (
|
||||
<label className="multiline-field">
|
||||
<span>排除模型(每行一个,支持通配符)</span>
|
||||
<span>{t('apiAccess.field.excludedModels')}</span>
|
||||
<textarea value={draft.excludedModelsText ?? ''} onChange={(event) => updateTextField('excludedModelsText', event.currentTarget.value)} rows={3} placeholder={'model-old-*\nmodel-preview'} />
|
||||
</label>
|
||||
) : null}
|
||||
{activeSection === 'openai-compatibility' ? (
|
||||
<label><span>测试模型</span><input value={draft.testModel ?? ''} onChange={(event) => updateTextField('testModel', event.currentTarget.value)} placeholder="可选" /></label>
|
||||
<label><span>{t('apiAccess.field.testModel')}</span><input value={draft.testModel ?? ''} onChange={(event) => updateTextField('testModel', event.currentTarget.value)} placeholder={t('common.optional')} /></label>
|
||||
) : null}
|
||||
{activeSection === 'claude-api-key' ? (
|
||||
<div className="provider-cloak-settings">
|
||||
<label>
|
||||
<span>Claude 伪装模式</span>
|
||||
<span>{t('apiAccess.cloak.mode')}</span>
|
||||
<select value={draft.cloakMode ?? ''} onChange={(event) => updateTextField('cloakMode', event.currentTarget.value)}>
|
||||
<option value="">默认(auto)</option>
|
||||
<option value="">{t('apiAccess.cloak.default')}</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="always">Always</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="multiline-field">
|
||||
<span>伪装敏感词(每行一个)</span>
|
||||
<span>{t('apiAccess.cloak.words')}</span>
|
||||
<textarea value={draft.cloakSensitiveWordsText ?? ''} onChange={(event) => updateTextField('cloakSensitiveWordsText', event.currentTarget.value)} rows={3} placeholder={'internal-name\nworkspace-id'} />
|
||||
</label>
|
||||
<div className="provider-advanced-toggle">
|
||||
<div><strong>严格模式</strong><span>仅保留 Claude Code 系统提示</span></div>
|
||||
<label className="switch-control" title="启用严格模式"><input type="checkbox" checked={Boolean(draft.cloakStrictMode)} onChange={(event) => updateBooleanField('cloakStrictMode', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
<div><strong>{t('apiAccess.cloak.strict')}</strong><span>{t('apiAccess.cloak.strictDescription')}</span></div>
|
||||
<label className="switch-control" title={t('apiAccess.cloak.enableStrict')}><input type="checkbox" checked={Boolean(draft.cloakStrictMode)} onChange={(event) => updateBooleanField('cloakStrictMode', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
</div>
|
||||
<div className="provider-advanced-toggle">
|
||||
<div><strong>缓存用户标识</strong><span>复用伪装后的用户标识</span></div>
|
||||
<label className="switch-control" title="缓存用户标识"><input type="checkbox" checked={Boolean(draft.cloakCacheUserId)} onChange={(event) => updateBooleanField('cloakCacheUserId', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
<div><strong>{t('apiAccess.cloak.cacheUser')}</strong><span>{t('apiAccess.cloak.cacheUserDescription')}</span></div>
|
||||
<label className="switch-control" title={t('apiAccess.cloak.cacheUser')}><input type="checkbox" checked={Boolean(draft.cloakCacheUserId)} onChange={(event) => updateBooleanField('cloakCacheUserId', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeSection === 'codex-api-key' ? (
|
||||
<div className="provider-advanced-toggle">
|
||||
<div><strong>WebSocket</strong><span>为该 Codex 接入启用 WebSocket</span></div>
|
||||
<label className="switch-control" title="启用 WebSocket"><input type="checkbox" checked={Boolean(draft.websockets)} onChange={(event) => updateBooleanField('websockets', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
<div><strong>WebSocket</strong><span>{t('apiAccess.websocket.description')}</span></div>
|
||||
<label className="switch-control" title={t('apiAccess.websocket.enable')}><input type="checkbox" checked={Boolean(draft.websockets)} onChange={(event) => updateBooleanField('websockets', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="provider-advanced-toggle">
|
||||
<div><strong>禁用冷却</strong><span>上游限流后仍允许继续选择该接入</span></div>
|
||||
<label className="switch-control" title="禁用冷却"><input type="checkbox" checked={Boolean(draft.disableCooling)} onChange={(event) => updateBooleanField('disableCooling', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
<div><strong>{t('apiAccess.cooling.title')}</strong><span>{t('apiAccess.cooling.description')}</span></div>
|
||||
<label className="switch-control" title={t('apiAccess.cooling.title')}><input type="checkbox" checked={Boolean(draft.disableCooling)} onChange={(event) => updateBooleanField('disableCooling', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={busy}>取消</button>
|
||||
<button type="submit" className="primary-button" disabled={busy}>{busy ? '保存中' : '保存'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="submit" className="primary-button" disabled={busy}>{busy ? t('common.saving') : t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1272,33 +1275,33 @@ function ApiProviderDialog({
|
||||
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && setModelDiscoveryOpen(false)}>
|
||||
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
|
||||
<div className="model-discovery-header">
|
||||
<div><h2 id="model-discovery-title">选择模型</h2><span>{definition.label}</span></div>
|
||||
<button type="button" className="icon-button quiet" onClick={() => setModelDiscoveryOpen(false)} title="关闭"><X size={18} /></button>
|
||||
<div><h2 id="model-discovery-title">{t('apiAccess.modelDialog.title')}</h2><span>{definition.label}</span></div>
|
||||
<button type="button" className="icon-button quiet" onClick={() => setModelDiscoveryOpen(false)} title={t('common.close')}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={modelSearch} onChange={(event) => setModelSearch(event.currentTarget.value)} placeholder="搜索模型名称或别名" />
|
||||
<input value={modelSearch} onChange={(event) => setModelSearch(event.currentTarget.value)} placeholder={t('agents.model.search')} />
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void discoverModels()} disabled={modelLoading}>
|
||||
<RefreshCw size={15} className={modelLoading ? 'spin' : ''} />刷新
|
||||
<RefreshCw size={15} className={modelLoading ? 'spin' : ''} />{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-toolbar">
|
||||
<span>找到 {modelOptions.length} 个 · 已选择 {selectedModelNames.size} 个</span>
|
||||
<span>{t('apiAccess.modelDialog.summary', { found: modelOptions.length, selected: selectedModelNames.size })}</span>
|
||||
<div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleModels} disabled={modelLoading || visibleModelOptions.length === 0}>{allVisibleModelsSelected ? '取消全选' : '全选当前'}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setSelectedModelNames(new Set())} disabled={modelLoading || selectedModelNames.size === 0}>清空</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleModels} disabled={modelLoading || visibleModelOptions.length === 0}>{allVisibleModelsSelected ? t('apiAccess.modelDialog.deselectAll') : t('apiAccess.modelDialog.selectVisible')}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setSelectedModelNames(new Set())} disabled={modelLoading || selectedModelNames.size === 0}>{t('common.clear')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-content">
|
||||
{modelLoading ? (
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />正在获取模型</div>
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('apiAccess.modelDialog.fetching')}</div>
|
||||
) : modelError ? (
|
||||
<div className="model-discovery-message error"><strong>获取模型失败</strong><span>{modelError}</span></div>
|
||||
<div className="model-discovery-message error"><strong>{t('apiAccess.modelDialog.fetchFailed')}</strong><span>{modelError}</span></div>
|
||||
) : visibleModelOptions.length === 0 ? (
|
||||
<div className="model-discovery-message"><strong>{modelOptions.length ? '没有匹配的模型' : '未发现模型'}</strong><span>{modelOptions.length ? '换个关键词试试' : '请检查 Base URL 和 API 密钥'}</span></div>
|
||||
<div className="model-discovery-message"><strong>{modelOptions.length ? t('apiAccess.modelDialog.noMatch') : t('apiAccess.modelDialog.none')}</strong><span>{modelOptions.length ? t('apiAccess.modelDialog.tryKeyword') : t('apiAccess.modelDialog.checkCredentials')}</span></div>
|
||||
) : (
|
||||
<div className="model-discovery-list">
|
||||
{visibleModelOptions.map((model) => {
|
||||
@@ -1316,8 +1319,8 @@ function ApiProviderDialog({
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-actions">
|
||||
<button type="button" className="secondary-button" onClick={() => setModelDiscoveryOpen(false)}>取消</button>
|
||||
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading}>应用选择({selectedModelNames.size})</button>
|
||||
<button type="button" className="secondary-button" onClick={() => setModelDiscoveryOpen(false)}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading}>{t('apiAccess.modelDialog.apply', { count: selectedModelNames.size })}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
openOAuthModelNames,
|
||||
type OAuthModelDefinition,
|
||||
} from '../services/oauthModels';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
|
||||
type AuthFile = Record<string, unknown>;
|
||||
|
||||
@@ -71,7 +72,7 @@ const providerName = (file: AuthFile) => {
|
||||
if (value === 'anthropic') return 'Claude';
|
||||
if (value === 'anti-gravity') return 'Antigravity';
|
||||
if (value === 'xai') return 'xAI';
|
||||
return value ? value.charAt(0).toUpperCase() + value.slice(1) : '未知提供商';
|
||||
return value ? value.charAt(0).toUpperCase() + value.slice(1) : translate(getCurrentLocale(), 'authFiles.unknownProvider');
|
||||
};
|
||||
|
||||
const providerKey = (file: AuthFile) => {
|
||||
@@ -84,24 +85,25 @@ const fileName = authFileName;
|
||||
const isRuntimeOnly = isRuntimeOnlyAuthFile;
|
||||
|
||||
const statusText = (file: AuthFile) => {
|
||||
if (readBoolean(file, 'disabled')) return '已停用';
|
||||
if (readBoolean(file, 'unavailable')) return '不可用';
|
||||
return readString(file, 'status') || '就绪';
|
||||
if (readBoolean(file, 'disabled')) return translate(getCurrentLocale(), 'authFiles.status.disabled');
|
||||
if (readBoolean(file, 'unavailable')) return translate(getCurrentLocale(), 'authFiles.status.unavailable');
|
||||
return readString(file, 'status') || translate(getCurrentLocale(), 'authFiles.status.ready');
|
||||
};
|
||||
|
||||
function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
const { locale, t } = useI18n();
|
||||
if (quota.status === 'loading') {
|
||||
return (
|
||||
<div className="auth-file-quota loading">
|
||||
<LoaderCircle size={13} className="spin" />
|
||||
<span>额度查询中</span>
|
||||
<span>{t('authFiles.quota.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (quota.status === 'error') {
|
||||
return (
|
||||
<div className="auth-file-quota error" title={quota.error}>
|
||||
<span>额度获取失败</span>
|
||||
<span>{t('authFiles.quota.failed')}</span>
|
||||
{quota.error ? <small>{quota.error}</small> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -109,7 +111,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
if (quota.status !== 'success') return null;
|
||||
|
||||
return (
|
||||
<div className="auth-file-quota" aria-label="额度信息">
|
||||
<div className="auth-file-quota" aria-label={t('authFiles.quota.aria')}>
|
||||
{quota.plan ? <span className="auth-file-quota-plan">{quota.plan}</span> : null}
|
||||
{quota.rows.length > 0 ? quota.rows.map((row, index) => {
|
||||
const detail = [row.detail, row.reset].filter(Boolean).join(' · ');
|
||||
@@ -120,18 +122,19 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) {
|
||||
{row.reset ? <small>{row.reset}</small> : null}
|
||||
</span>
|
||||
);
|
||||
}) : <span className="auth-file-quota-empty">暂无额度数据</span>}
|
||||
}) : <span className="auth-file-quota-empty">{t('authFiles.quota.empty')}</span>}
|
||||
{quota.resetCredits !== undefined ? (
|
||||
<span className="auth-file-quota-credit">主动重置 {quota.resetCredits} 次</span>
|
||||
<span className="auth-file-quota-credit">{t('authFiles.quota.resets', { count: quota.resetCredits })}</span>
|
||||
) : null}
|
||||
{quota.resetCreditsEarliestExpiry ? (
|
||||
<span className="auth-file-quota-credit">最早过期 {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}</span>
|
||||
<span className="auth-file-quota-credit">{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthFileManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState('all');
|
||||
@@ -218,7 +221,7 @@ export function AuthFileManagementPage() {
|
||||
setOauthModels(models);
|
||||
setOauthExcludedRules(excludedRules);
|
||||
setOpenOauthModelNames(openOAuthModelNames(models, excludedRules));
|
||||
if (models.length === 0) setOauthModelError('该提供商没有可配置的模型定义');
|
||||
if (models.length === 0) setOauthModelError(t('authFiles.models.noneForProvider'));
|
||||
} catch (requestError) {
|
||||
if (oauthModelRequestRef.current === requestId) setOauthModelError(String(requestError));
|
||||
} finally {
|
||||
@@ -242,7 +245,7 @@ export function AuthFileManagementPage() {
|
||||
models: excludedModels,
|
||||
});
|
||||
}
|
||||
setNotice(`${oauthModelProviderLabel} 开放模型已更新`);
|
||||
setNotice(t('authFiles.models.updated', { provider: oauthModelProviderLabel }));
|
||||
closeOauthModels();
|
||||
} catch (requestError) {
|
||||
setOauthModelError(String(requestError));
|
||||
@@ -331,8 +334,8 @@ export function AuthFileManagementPage() {
|
||||
}
|
||||
try {
|
||||
await loadFiles();
|
||||
if (uploaded > 0) setNotice(`已上传 ${uploaded} 个认证文件`);
|
||||
if (failures.length > 0) setError(`${failures.length} 个文件上传失败:${failures.join(';')}`);
|
||||
if (uploaded > 0) setNotice(t('authFiles.uploaded', { count: uploaded }));
|
||||
if (failures.length > 0) setError(t('authFiles.uploadFailed', { count: failures.length, errors: failures.join('; ') }));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
@@ -349,7 +352,7 @@ export function AuthFileManagementPage() {
|
||||
name,
|
||||
disabled: !readBoolean(file, 'disabled'),
|
||||
});
|
||||
setNotice(readBoolean(file, 'disabled') ? '认证文件已启用' : '认证文件已停用');
|
||||
setNotice(readBoolean(file, 'disabled') ? t('authFiles.notice.enabled') : t('authFiles.notice.disabled'));
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -361,15 +364,15 @@ export function AuthFileManagementPage() {
|
||||
const deleteFile = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
if (isRuntimeOnly(file)) {
|
||||
setError('运行时认证文件不能从磁盘删除');
|
||||
setError(t('authFiles.runtimeDeleteError'));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定删除「${name}」吗?`)) return;
|
||||
if (!window.confirm(t('authFiles.deleteConfirm', { name }))) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.delete('/auth-files', { query: { name } });
|
||||
setNotice('认证文件已删除');
|
||||
setNotice(t('authFiles.deleted'));
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -379,12 +382,12 @@ export function AuthFileManagementPage() {
|
||||
};
|
||||
|
||||
const deleteAll = async () => {
|
||||
if (!window.confirm('确定删除所有磁盘认证文件吗?运行时认证不会被删除。')) return;
|
||||
if (!window.confirm(t('authFiles.deleteAllConfirm'))) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.delete('/auth-files', { query: { all: true } });
|
||||
setNotice('磁盘认证文件已全部删除');
|
||||
setNotice(t('authFiles.deletedAll'));
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -396,7 +399,7 @@ export function AuthFileManagementPage() {
|
||||
const downloadFile = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
if (isRuntimeOnly(file)) {
|
||||
setError('运行时认证文件没有可下载的磁盘文件');
|
||||
setError(t('authFiles.runtimeDownloadError'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -409,7 +412,7 @@ export function AuthFileManagementPage() {
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice('认证文件已下载');
|
||||
setNotice(t('authFiles.downloaded'));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
@@ -423,7 +426,7 @@ export function AuthFileManagementPage() {
|
||||
setCopied(name);
|
||||
window.setTimeout(() => setCopied((current) => (current === name ? '' : current)), 1500);
|
||||
} catch {
|
||||
setError('复制失败');
|
||||
setError(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -436,18 +439,18 @@ export function AuthFileManagementPage() {
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Auth Files</span>
|
||||
<h1>认证文件</h1>
|
||||
<h1>{t('authFiles.title')}</h1>
|
||||
</div>
|
||||
<div className="management-heading-actions">
|
||||
<span className="muted-summary">{files.length} 个文件 · {disabledCount} 个停用</span>
|
||||
<span className="muted-summary">{t('authFiles.summary', { files: files.length, disabled: disabledCount })}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} />刷新
|
||||
<RefreshCw size={16} />{t('common.refresh')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={deleteAll} disabled={loading || busy || diskCount === 0}>
|
||||
<Trash2 size={16} />清空磁盘文件
|
||||
<Trash2 size={16} />{t('authFiles.clearDisk')}
|
||||
</button>
|
||||
<button type="button" className="primary-button compact-button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
|
||||
<Upload size={16} />上传
|
||||
<Upload size={16} />{t('common.upload')}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".json,application/json" multiple hidden onChange={(event) => void handleUpload(event)} />
|
||||
</div>
|
||||
@@ -459,23 +462,23 @@ export function AuthFileManagementPage() {
|
||||
<section className="panel auth-files-panel real-auth-files-panel">
|
||||
<div className="management-toolbar auth-files-toolbar">
|
||||
<Search size={16} />
|
||||
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder="搜索文件名、账号或提供商" />
|
||||
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('authFiles.searchPlaceholder')} />
|
||||
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)}>
|
||||
<option value="all">全部提供商</option>
|
||||
<option value="all">{t('authFiles.filter.allProviders')}</option>
|
||||
{providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="disabled">已停用</option>
|
||||
<option value="runtime">仅运行时</option>
|
||||
<option value="all">{t('authFiles.filter.allStatuses')}</option>
|
||||
<option value="enabled">{t('authFiles.filter.enabled')}</option>
|
||||
<option value="disabled">{t('authFiles.filter.disabled')}</option>
|
||||
<option value="runtime">{t('authFiles.filter.runtime')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取认证文件中</div>
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('authFiles.loading')}</div>
|
||||
) : visibleFiles.length === 0 ? (
|
||||
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? '没有匹配的认证文件' : '暂无认证文件'}</strong><span>{files.length ? '换个筛选条件试试' : '上传 JSON 认证文件后会显示在这里'}</span></div>
|
||||
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? t('authFiles.empty.filtered') : t('authFiles.empty.none')}</strong><span>{files.length ? t('authFiles.empty.tryFilter') : t('authFiles.empty.upload')}</span></div>
|
||||
) : (
|
||||
<div className="real-auth-file-list">
|
||||
{visibleFiles.map((file) => {
|
||||
@@ -490,17 +493,17 @@ export function AuthFileManagementPage() {
|
||||
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
|
||||
</div>
|
||||
<div className="auth-file-meta">
|
||||
<span>{readNumber(file, 'size') === null ? '大小未知' : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
|
||||
<span>{readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
|
||||
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
|
||||
{isRuntimeOnly(file) ? <span className="state-pill">运行时</span> : null}
|
||||
{isRuntimeOnly(file) ? <span className="state-pill">{t('authFiles.runtime')}</span> : null}
|
||||
</div>
|
||||
<div className="auth-file-actions">
|
||||
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? '已停用' : quotas[quotaKey(file)]?.status === 'loading' ? '查询中' : quotas[quotaKey(file)]?.status === 'success' ? '刷新额度' : '获取额度'}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title="设置开放模型">模型</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title="复制文件名">{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
|
||||
<button type="button" className="icon-button quiet" onClick={() => void downloadFile(file)} disabled={busy || isRuntimeOnly(file)} title="下载"><Download size={16} /></button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? '启用' : '停用'}</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title="删除"><Trash2 size={16} /></button>
|
||||
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? t('authFiles.status.disabled') : quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title={t('authFiles.models.settings')}>{t('authFiles.models.button')}</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
|
||||
<button type="button" className="icon-button quiet" onClick={() => void downloadFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.download')}><Download size={16} /></button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
|
||||
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} /> : null}
|
||||
</article>
|
||||
@@ -509,37 +512,37 @@ export function AuthFileManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{runtimeCount > 0 ? <p className="page-footnote">{runtimeCount} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。</p> : null}
|
||||
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
|
||||
|
||||
{oauthModelProvider ? (
|
||||
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
|
||||
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
|
||||
<div className="model-discovery-header">
|
||||
<div><h2 id="oauth-model-title">开放模型</h2><span>{oauthModelProviderLabel} · 默认全部开放,可取消不需要的模型</span></div>
|
||||
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title="关闭"><X size={18} /></button>
|
||||
<div><h2 id="oauth-model-title">{t('authFiles.models.title')}</h2><span>{t('authFiles.models.description', { provider: oauthModelProviderLabel })}</span></div>
|
||||
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title={t('common.close')}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder="搜索模型名称" />
|
||||
<input value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder={t('authFiles.models.search')} />
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-toolbar">
|
||||
<span>共 {oauthModels.length} 个 · 已开放 {openOauthModelNames.size} 个</span>
|
||||
<span>{t('authFiles.models.summary', { total: oauthModels.length, open: openOauthModelNames.size })}</span>
|
||||
<div>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? '关闭当前' : '开放当前'}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set(oauthModels.map((model) => model.id.toLowerCase())))} disabled={oauthModelLoading || oauthModels.length === 0 || openOauthModelNames.size === oauthModels.length}>全部开放</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>全部关闭</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? t('authFiles.models.closeVisible') : t('authFiles.models.openVisible')}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set(oauthModels.map((model) => model.id.toLowerCase())))} disabled={oauthModelLoading || oauthModels.length === 0 || openOauthModelNames.size === oauthModels.length}>{t('authFiles.models.openAll')}</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>{t('authFiles.models.closeAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-content">
|
||||
{oauthModelLoading ? (
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />正在读取模型定义</div>
|
||||
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('authFiles.models.loading')}</div>
|
||||
) : oauthModelError ? (
|
||||
<div className="model-discovery-message error"><strong>读取模型失败</strong><span>{oauthModelError}</span></div>
|
||||
<div className="model-discovery-message error"><strong>{t('authFiles.models.loadFailed')}</strong><span>{oauthModelError}</span></div>
|
||||
) : visibleOauthModels.length === 0 ? (
|
||||
<div className="model-discovery-message"><strong>{oauthModels.length ? '没有匹配的模型' : '暂无模型定义'}</strong></div>
|
||||
<div className="model-discovery-message"><strong>{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}</strong></div>
|
||||
) : (
|
||||
<div className="model-discovery-list">
|
||||
{visibleOauthModels.map((model) => {
|
||||
@@ -557,8 +560,8 @@ export function AuthFileManagementPage() {
|
||||
</div>
|
||||
|
||||
<div className="model-discovery-actions">
|
||||
<button type="button" className="secondary-button" onClick={closeOauthModels} disabled={oauthModelSaving}>取消</button>
|
||||
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0}>{oauthModelSaving ? '保存中' : `保存(开放 ${openOauthModelNames.size} 个)`}</button>
|
||||
<button type="button" className="secondary-button" onClick={closeOauthModels} disabled={oauthModelSaving}>{t('common.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0}>{oauthModelSaving ? t('common.saving') : t('authFiles.models.save', { count: openOauthModelNames.size })}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+60
-57
@@ -15,6 +15,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type CoreConfigSettings = {
|
||||
apiKeys: CoreApiKey[];
|
||||
@@ -32,11 +33,12 @@ type ConfigAction = 'add-key' | 'delete-key' | 'plugins' | 'routing' | null;
|
||||
type NoticeTone = 'success' | 'error';
|
||||
|
||||
const ROUTING_OPTIONS = [
|
||||
{ value: 'round-robin', label: '轮询' },
|
||||
{ value: 'fill-first', label: '优先填充' },
|
||||
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
|
||||
{ value: 'fill-first', labelKey: 'config.routing.fillFirst' },
|
||||
] as const;
|
||||
|
||||
export function ConfigPanelPage() {
|
||||
const { t } = useI18n();
|
||||
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -140,20 +142,20 @@ export function ConfigPanelPage() {
|
||||
event.preventDefault();
|
||||
const apiKey = newApiKey.trim();
|
||||
if (!apiKey) {
|
||||
setFormError('鉴权密钥不能为空');
|
||||
setFormError(t('config.error.emptyKey'));
|
||||
return;
|
||||
}
|
||||
if (!/^[\x21-\x7e]+$/.test(apiKey)) {
|
||||
setFormError('只能使用 ASCII 可见字符,且不能包含空格');
|
||||
setFormError(t('config.error.invalidKey'));
|
||||
return;
|
||||
}
|
||||
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey)) {
|
||||
setFormError('该鉴权密钥已经存在');
|
||||
setFormError(t('config.error.duplicateKey'));
|
||||
return;
|
||||
}
|
||||
const remark = newApiKeyRemark.trim();
|
||||
if (remark.length > 80) {
|
||||
setFormError('备注不能超过 80 个字符');
|
||||
setFormError(t('config.error.remarkTooLong'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,7 +163,7 @@ export function ConfigPanelPage() {
|
||||
'add-key',
|
||||
'add_core_api_key',
|
||||
{ apiKey, remark },
|
||||
'鉴权密钥已新增',
|
||||
t('config.notice.keyAdded'),
|
||||
);
|
||||
if (saved) {
|
||||
setAddDialogOpen(false);
|
||||
@@ -178,7 +180,7 @@ export function ConfigPanelPage() {
|
||||
'delete-key',
|
||||
'delete_core_api_key',
|
||||
{ apiKey: selectedDeleteKey },
|
||||
'鉴权密钥已删除',
|
||||
t('config.notice.keyDeleted'),
|
||||
);
|
||||
if (deleted) {
|
||||
setDeleteIndex(null);
|
||||
@@ -189,7 +191,7 @@ export function ConfigPanelPage() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
setCopiedIndex(index);
|
||||
showNotice('鉴权密钥已复制', 'success');
|
||||
showNotice(t('config.notice.keyCopied'), 'success');
|
||||
if (copyTimerRef.current !== null) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export function ConfigPanelPage() {
|
||||
copyTimerRef.current = null;
|
||||
}, 1800);
|
||||
} catch {
|
||||
showNotice('复制鉴权密钥失败', 'error');
|
||||
showNotice(t('config.notice.keyCopyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,7 +209,7 @@ export function ConfigPanelPage() {
|
||||
'plugins',
|
||||
'set_core_plugins_enabled',
|
||||
{ enabled },
|
||||
enabled ? '插件系统已启用' : '插件系统已停用',
|
||||
enabled ? t('config.notice.pluginsEnabled') : t('config.notice.pluginsDisabled'),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -219,7 +221,7 @@ export function ConfigPanelPage() {
|
||||
'routing',
|
||||
'set_core_routing_strategy',
|
||||
{ strategy },
|
||||
'路由策略已更新',
|
||||
t('config.notice.routingUpdated'),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -234,10 +236,10 @@ export function ConfigPanelPage() {
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<KeyRound size={18} aria-hidden="true" />
|
||||
<h2>鉴权密钥</h2>
|
||||
<h2>{t('config.keys.title')}</h2>
|
||||
</div>
|
||||
<div className="config-heading-actions">
|
||||
<span className="config-count" aria-label="密钥数量">
|
||||
<span className="config-count" aria-label={t('config.keys.count')}>
|
||||
{settings?.apiKeys.length ?? 0}
|
||||
</span>
|
||||
<button
|
||||
@@ -245,8 +247,8 @@ export function ConfigPanelPage() {
|
||||
className="icon-button"
|
||||
onClick={openAddDialog}
|
||||
disabled={controlsDisabled}
|
||||
title="新增鉴权密钥"
|
||||
aria-label="新增鉴权密钥"
|
||||
title={t('config.keys.add')}
|
||||
aria-label={t('config.keys.add')}
|
||||
>
|
||||
<Plus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -264,11 +266,11 @@ export function ConfigPanelPage() {
|
||||
) : loadError ? (
|
||||
<div className="config-unavailable">
|
||||
<AlertCircle size={24} aria-hidden="true" />
|
||||
<strong>配置不可用</strong>
|
||||
<strong>{t('config.unavailable')}</strong>
|
||||
<span title={loadError}>{loadError}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={loadSettings}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
重试
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : settings && settings.apiKeys.length > 0 ? (
|
||||
@@ -278,8 +280,8 @@ export function ConfigPanelPage() {
|
||||
<span className="config-key-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div className="config-key-details">
|
||||
<div className="config-key-label-line">
|
||||
<strong title={entry.remark || '未填写备注'}>
|
||||
{entry.remark || '未填写备注'}
|
||||
<strong title={entry.remark || t('config.keys.noRemark')}>
|
||||
{entry.remark || t('config.keys.noRemark')}
|
||||
</strong>
|
||||
</div>
|
||||
<code title={maskApiKey(entry.apiKey)}>{maskApiKey(entry.apiKey)}</code>
|
||||
@@ -291,8 +293,8 @@ export function ConfigPanelPage() {
|
||||
className="icon-button quiet"
|
||||
onClick={() => void copyApiKey(entry.apiKey, index)}
|
||||
disabled={controlsDisabled}
|
||||
title="复制鉴权密钥"
|
||||
aria-label={`复制第 ${index + 1} 个鉴权密钥`}
|
||||
title={t('config.keys.copy')}
|
||||
aria-label={t('config.keys.copyNth', { number: index + 1 })}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check size={16} aria-hidden="true" />
|
||||
@@ -301,8 +303,8 @@ export function ConfigPanelPage() {
|
||||
)}
|
||||
</button>
|
||||
{entry.builtIn ? (
|
||||
<span className="config-protected-key" title="内置密钥不能删除">
|
||||
内置
|
||||
<span className="config-protected-key" title={t('config.keys.builtInProtected')}>
|
||||
{t('config.keys.builtIn')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
@@ -310,8 +312,8 @@ export function ConfigPanelPage() {
|
||||
className="icon-button danger"
|
||||
onClick={() => setDeleteIndex(index)}
|
||||
disabled={controlsDisabled}
|
||||
title="删除鉴权密钥"
|
||||
aria-label={`删除第 ${index + 1} 个鉴权密钥`}
|
||||
title={t('config.keys.delete')}
|
||||
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -322,7 +324,7 @@ export function ConfigPanelPage() {
|
||||
) : (
|
||||
<div className="config-empty-list">
|
||||
<KeyRound size={26} aria-hidden="true" />
|
||||
<strong>暂无鉴权密钥</strong>
|
||||
<strong>{t('config.keys.empty')}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,24 +335,24 @@ export function ConfigPanelPage() {
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Plug size={18} aria-hidden="true" />
|
||||
<h2>插件系统</h2>
|
||||
<h2>{t('config.plugins.title')}</h2>
|
||||
</div>
|
||||
<span className={`state-pill ${settings?.pluginsEnabled ? 'success' : ''}`}>
|
||||
{loading
|
||||
? '读取中'
|
||||
? t('common.loading')
|
||||
: settings === null
|
||||
? '不可用'
|
||||
? t('common.unavailable')
|
||||
: settings.pluginsEnabled
|
||||
? '已启用'
|
||||
: '已停用'}
|
||||
? t('common.enabled')
|
||||
: t('common.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-single-control">
|
||||
<span>启用插件系统</span>
|
||||
<label className="switch-control" title="启用插件系统">
|
||||
<span>{t('config.plugins.enable')}</span>
|
||||
<label className="switch-control" title={t('config.plugins.enable')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="启用插件系统"
|
||||
aria-label={t('config.plugins.enable')}
|
||||
checked={Boolean(settings?.pluginsEnabled)}
|
||||
disabled={controlsDisabled}
|
||||
onChange={(event) => void togglePlugins(event.currentTarget.checked)}
|
||||
@@ -364,17 +366,17 @@ export function ConfigPanelPage() {
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Route size={18} aria-hidden="true" />
|
||||
<h2>路由策略</h2>
|
||||
<h2>{t('config.routing.title')}</h2>
|
||||
</div>
|
||||
<span className="state-pill" title={settings?.routingStrategy || undefined}>
|
||||
{loading
|
||||
? '读取中'
|
||||
? t('common.loading')
|
||||
: settings === null
|
||||
? '不可用'
|
||||
: routingStrategyLabel(settings.routingStrategy)}
|
||||
? t('common.unavailable')
|
||||
: routingStrategyLabel(settings.routingStrategy, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="routing-segmented" role="group" aria-label="路由策略">
|
||||
<div className="routing-segmented" role="group" aria-label={t('config.routing.title')}>
|
||||
{ROUTING_OPTIONS.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -385,7 +387,7 @@ export function ConfigPanelPage() {
|
||||
onClick={() => void changeRoutingStrategy(option.value)}
|
||||
title={option.value}
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -407,22 +409,22 @@ export function ConfigPanelPage() {
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<KeyRound size={19} aria-hidden="true" />
|
||||
<h2 id="add-api-key-title">新增鉴权密钥</h2>
|
||||
<h2 id="add-api-key-title">{t('config.keys.addTitle')}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={closeAddDialog}
|
||||
disabled={busyAction === 'add-key'}
|
||||
title="关闭"
|
||||
aria-label="关闭"
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="config-dialog-field">
|
||||
<span>鉴权密钥</span>
|
||||
<span>{t('config.keys.label')}</span>
|
||||
<div className="config-secret-input">
|
||||
<input
|
||||
autoFocus
|
||||
@@ -441,8 +443,8 @@ export function ConfigPanelPage() {
|
||||
className="icon-button quiet"
|
||||
onClick={() => setShowApiKey((visible) => !visible)}
|
||||
disabled={busyAction === 'add-key'}
|
||||
title={showApiKey ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={showApiKey ? '隐藏密钥' : '显示密钥'}
|
||||
title={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
||||
aria-label={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff size={17} aria-hidden="true" />
|
||||
@@ -454,7 +456,7 @@ export function ConfigPanelPage() {
|
||||
</label>
|
||||
|
||||
<label className="config-dialog-field">
|
||||
<span>备注</span>
|
||||
<span>{t('config.keys.remark')}</span>
|
||||
<input
|
||||
className="config-dialog-text-input"
|
||||
type="text"
|
||||
@@ -465,7 +467,7 @@ export function ConfigPanelPage() {
|
||||
setFormError('');
|
||||
}}
|
||||
disabled={busyAction === 'add-key'}
|
||||
placeholder="例如:开发环境、张三的密钥"
|
||||
placeholder={t('config.keys.remarkPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -481,11 +483,11 @@ export function ConfigPanelPage() {
|
||||
disabled={busyAction === 'add-key'}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
生成密钥
|
||||
{t('config.keys.generate')}
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={busyAction === 'add-key'}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{busyAction === 'add-key' ? '正在新增' : '新增'}
|
||||
{busyAction === 'add-key' ? t('config.keys.adding') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -507,7 +509,7 @@ export function ConfigPanelPage() {
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<Trash2 size={19} aria-hidden="true" />
|
||||
<h2 id="delete-api-key-title">删除鉴权密钥</h2>
|
||||
<h2 id="delete-api-key-title">{t('config.keys.deleteTitle')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<code className="config-delete-key">{maskApiKey(selectedDeleteKey)}</code>
|
||||
@@ -518,7 +520,7 @@ export function ConfigPanelPage() {
|
||||
onClick={() => setDeleteIndex(null)}
|
||||
disabled={busyAction === 'delete-key'}
|
||||
>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -527,7 +529,7 @@ export function ConfigPanelPage() {
|
||||
disabled={busyAction === 'delete-key'}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
{busyAction === 'delete-key' ? '正在删除' : '删除'}
|
||||
{busyAction === 'delete-key' ? t('common.deleting') : t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,9 +559,10 @@ function maskApiKey(apiKey: string) {
|
||||
return `${value.slice(0, visible)}${'*'.repeat(Math.max(6, 10 - visible * 2))}${value.slice(-visible)}`;
|
||||
}
|
||||
|
||||
function routingStrategyLabel(strategy?: string) {
|
||||
function routingStrategyLabel(strategy: string | undefined, t: ReturnType<typeof useI18n>['t']) {
|
||||
if (!strategy) {
|
||||
return '读取中';
|
||||
return t('common.loading');
|
||||
}
|
||||
return ROUTING_OPTIONS.find((option) => option.value === strategy)?.label ?? strategy;
|
||||
const option = ROUTING_OPTIONS.find((item) => item.value === strategy);
|
||||
return option ? t(option.labelKey) : strategy;
|
||||
}
|
||||
+131
-110
@@ -9,6 +9,7 @@ import claudeIcon from '../assets/icons/claude.svg';
|
||||
import geminiIcon from '../assets/icons/gemini.svg';
|
||||
import { clientApiProfiles, DEFAULT_CLIENT_API_KEY } from '../services/clientAccess';
|
||||
import packageMetadata from '../../package.json';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type CorePlatform = {
|
||||
os: string;
|
||||
@@ -105,6 +106,7 @@ function requestLatestCore() {
|
||||
}
|
||||
|
||||
export function KernelPage() {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
status: coreStatus,
|
||||
statusError,
|
||||
@@ -181,7 +183,7 @@ export function KernelPage() {
|
||||
}
|
||||
|
||||
if (task.result) {
|
||||
setMessage(task.message || `${task.result.version} 安装完成`);
|
||||
setMessage(task.message || t('kernel.install.completed', { version: task.result.version }));
|
||||
setMessageType('success');
|
||||
void refreshStatus();
|
||||
return;
|
||||
@@ -209,7 +211,7 @@ export function KernelPage() {
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) {
|
||||
setMessage(`监听下载进度失败: ${String(error)}`);
|
||||
setMessage(t('kernel.error.progressListener', { error: String(error) }));
|
||||
setMessageType('error');
|
||||
}
|
||||
});
|
||||
@@ -328,22 +330,24 @@ export function KernelPage() {
|
||||
) => {
|
||||
const actionLabel =
|
||||
command === 'start_core_process'
|
||||
? '启动'
|
||||
? t('kernel.action.start')
|
||||
: command === 'stop_core_process'
|
||||
? '关闭'
|
||||
: '重启';
|
||||
? t('kernel.action.stop')
|
||||
: t('kernel.action.restart');
|
||||
setProcessBusy(true);
|
||||
|
||||
try {
|
||||
const result = await invoke<CoreStatus>(command);
|
||||
publishStatus(result);
|
||||
showProcessNotice(messages?.success ?? `内核${actionLabel}成功`, 'success');
|
||||
showProcessNotice(messages?.success ?? t('kernel.notice.actionSuccess', { action: actionLabel }), 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
await refreshStatus();
|
||||
showProcessNotice(
|
||||
`${messages?.failure ?? `内核${actionLabel}失败`}:${errorMessage}`,
|
||||
messages?.failure
|
||||
? `${messages.failure}: ${errorMessage}`
|
||||
: t('kernel.notice.actionFailed', { action: actionLabel, error: errorMessage }),
|
||||
'error',
|
||||
);
|
||||
return false;
|
||||
@@ -390,11 +394,11 @@ export function KernelPage() {
|
||||
if (restartAfterSave) {
|
||||
if (coreStatus?.running) {
|
||||
await runCoreProcessCommand('restart_core_process', {
|
||||
success: '网络设置已保存,内核已重启',
|
||||
failure: '网络设置已保存,但内核重启失败',
|
||||
success: t('kernel.notice.networkRestarted'),
|
||||
failure: t('kernel.notice.networkRestartFailed'),
|
||||
});
|
||||
} else {
|
||||
showProcessNotice('网络设置已保存,下次启动内核时生效', 'info');
|
||||
showProcessNotice(t('kernel.notice.networkNextStart'), 'info');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -405,7 +409,7 @@ export function KernelPage() {
|
||||
setCustomPort(String(savedPortRef.current));
|
||||
setSettingsError(String(error));
|
||||
if (restartAfterSave) {
|
||||
showProcessNotice(`保存网络设置失败:${String(error)}`, 'error');
|
||||
showProcessNotice(t('kernel.notice.networkSaveFailed', { error: String(error) }), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -425,7 +429,7 @@ export function KernelPage() {
|
||||
const port = Number(customPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
setCustomPort(String(savedPortRef.current));
|
||||
setSettingsError('端口必须在 1 到 65535 之间');
|
||||
setSettingsError(t('kernel.error.port'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -478,14 +482,14 @@ export function KernelPage() {
|
||||
const task = await invoke<CoreInstallTask>('get_core_install_task');
|
||||
applyInstallTask(task, false);
|
||||
} catch (error) {
|
||||
setMessage(`读取安装任务失败: ${String(error)}`);
|
||||
setMessage(t('kernel.error.installTask', { error: String(error) }));
|
||||
setMessageType('error');
|
||||
}
|
||||
};
|
||||
|
||||
const installVersion = async (version: string) => {
|
||||
setInstalling(true);
|
||||
setMessage(`正在安装 ${version}`);
|
||||
setMessage(t('kernel.install.installingVersion', { version }));
|
||||
setMessageType('info');
|
||||
setCancellingInstall(false);
|
||||
setInstallDialogOpen(true);
|
||||
@@ -502,7 +506,7 @@ export function KernelPage() {
|
||||
|
||||
try {
|
||||
const result = await invoke<CoreInstallResult>('install_core_version', { version });
|
||||
setMessage(`${result.version} 安装完成`);
|
||||
setMessage(t('kernel.install.completed', { version: result.version }));
|
||||
setMessageType('success');
|
||||
setProgress({
|
||||
running: false,
|
||||
@@ -511,7 +515,7 @@ export function KernelPage() {
|
||||
downloaded: 1,
|
||||
total: 1,
|
||||
percent: 100,
|
||||
message: `${result.version} 安装完成`,
|
||||
message: t('kernel.install.completed', { version: result.version }),
|
||||
result,
|
||||
});
|
||||
await Promise.all([refreshStatus(), loadBundledCore()]);
|
||||
@@ -538,7 +542,7 @@ export function KernelPage() {
|
||||
if (!bundledCore) return;
|
||||
|
||||
setInstalling(true);
|
||||
setMessage(`正在安装内置内核 ${bundledCore.version}`);
|
||||
setMessage(t('kernel.install.installingBundled', { version: bundledCore.version }));
|
||||
setMessageType('info');
|
||||
setCancellingInstall(false);
|
||||
setInstallDialogOpen(true);
|
||||
@@ -554,7 +558,7 @@ export function KernelPage() {
|
||||
});
|
||||
try {
|
||||
const result = await invoke<CoreInstallResult>('install_bundled_core');
|
||||
setMessage(`${result.version} 内置内核安装完成`);
|
||||
setMessage(t('kernel.install.bundledCompleted', { version: result.version }));
|
||||
setMessageType('success');
|
||||
await Promise.all([refreshStatus(), loadBundledCore()]);
|
||||
} catch (error) {
|
||||
@@ -582,7 +586,7 @@ export function KernelPage() {
|
||||
}
|
||||
|
||||
setCancellingInstall(true);
|
||||
setMessage('正在取消下载');
|
||||
setMessage(t('kernel.install.cancelling'));
|
||||
setMessageType('info');
|
||||
|
||||
try {
|
||||
@@ -617,7 +621,7 @@ export function KernelPage() {
|
||||
copiedApiTimerRef.current = null;
|
||||
}, 1800);
|
||||
} catch {
|
||||
showProcessNotice('复制接入信息失败', 'error');
|
||||
showProcessNotice(t('kernel.notice.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -639,7 +643,7 @@ export function KernelPage() {
|
||||
try {
|
||||
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
|
||||
} catch (error) {
|
||||
setAppUpdateError(`打开更新页面失败:${String(error)}`);
|
||||
setAppUpdateError(t('kernel.error.openUpdate', { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -657,53 +661,53 @@ export function KernelPage() {
|
||||
const progressPercent = clampPercent(computedPercent ?? 0);
|
||||
const progressText = progress
|
||||
? progress.phase === '安装完成'
|
||||
? '已完成'
|
||||
? t('kernel.progress.completed')
|
||||
: progress.phase === '解压中'
|
||||
? '正在解压文件'
|
||||
? t('kernel.progress.extracting')
|
||||
: progress.total
|
||||
? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}`
|
||||
: progress.downloaded > 0
|
||||
? formatBytes(progress.downloaded)
|
||||
: '等待进度信息'
|
||||
: t('kernel.progress.waiting')
|
||||
: '';
|
||||
const statusTone = statusError ? 'error' : coreRunning ? 'success' : 'neutral';
|
||||
const statusLabel = coreStatus
|
||||
? coreRunning
|
||||
? '运行中'
|
||||
? t('kernel.status.running')
|
||||
: coreInstalled
|
||||
? '已停止'
|
||||
: '未安装'
|
||||
? t('kernel.status.stopped')
|
||||
: t('kernel.status.notInstalled')
|
||||
: statusError
|
||||
? '检测失败'
|
||||
: '检测中';
|
||||
? t('common.detectionFailed')
|
||||
: t('common.detecting');
|
||||
const appUpdateLabel = appUpdateError
|
||||
? '检查失败,重试'
|
||||
? t('kernel.update.failedRetry')
|
||||
: appUpdate?.updateAvailable
|
||||
? `更新至 ${displayAppVersion(appUpdate.latestVersion)}`
|
||||
? t('kernel.update.toVersion', { version: displayAppVersion(appUpdate.latestVersion) })
|
||||
: '';
|
||||
const appUpdateTone = appUpdateError ? 'error' : 'update';
|
||||
const latestLabel = checkingLatest
|
||||
? '检查中'
|
||||
: latestVersion || (latestError ? '检查失败' : '未检查');
|
||||
? t('kernel.update.checking')
|
||||
: latestVersion || (latestError ? t('kernel.update.failed') : t('kernel.update.notChecked'));
|
||||
const updateStateLabel = checkingLatest
|
||||
? '正在检查'
|
||||
? t('kernel.update.statusChecking')
|
||||
: latestError
|
||||
? '检查失败'
|
||||
? t('kernel.update.failed')
|
||||
: !latestVersion
|
||||
? '尚未检查'
|
||||
? t('kernel.update.notYetChecked')
|
||||
: currentVersion === latestVersion
|
||||
? '已是最新版本'
|
||||
: '可安装新版本';
|
||||
const platformOsLabel = platform?.os || (platformError ? '检测失败' : '检测中');
|
||||
const platformArchLabel = platform?.arch || (platformError ? '检测失败' : '检测中');
|
||||
? t('kernel.update.latest')
|
||||
: t('kernel.update.available');
|
||||
const platformOsLabel = platform?.os || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
||||
const platformArchLabel = platform?.arch || (platformError ? t('common.detectionFailed') : t('common.detecting'));
|
||||
const installTaskRunning = Boolean(installing || progress?.running);
|
||||
const offlineInstallRequired = Boolean(coreStatus && !coreInstalled && latestError);
|
||||
const versionStatusLabel = installTaskRunning
|
||||
? cancellingInstall
|
||||
? '正在取消下载'
|
||||
: progress?.phase || '正在安装'
|
||||
? t('kernel.install.cancelling')
|
||||
: progress?.phase ? localizeInstallPhase(progress.phase, t) : t('kernel.install.inProgress')
|
||||
: offlineInstallRequired
|
||||
? 'Github连接失败,请使用“离线安装”'
|
||||
? t('kernel.install.githubFailed')
|
||||
: message || updateStateLabel;
|
||||
const versionStatusTone: MessageType = installTaskRunning
|
||||
? 'info'
|
||||
@@ -723,23 +727,23 @@ export function KernelPage() {
|
||||
: 'info';
|
||||
const installDialogTitle = installTaskRunning
|
||||
? cancellingInstall
|
||||
? '正在取消下载'
|
||||
: '正在安装内核'
|
||||
? t('kernel.install.titleCancelling')
|
||||
: t('kernel.install.titleInstalling')
|
||||
: progress?.result
|
||||
? '安装完成'
|
||||
? t('kernel.install.titleCompleted')
|
||||
: progress?.phase === '已取消'
|
||||
? '下载已取消'
|
||||
: '安装失败';
|
||||
? t('kernel.install.titleCancelled')
|
||||
: t('kernel.install.titleFailed');
|
||||
const installDialogMessage = cancellingInstall
|
||||
? '正在等待下载任务停止'
|
||||
: progress?.message || (installTaskRunning ? message || '安装任务正在进行' : '');
|
||||
? t('kernel.install.waitingStop')
|
||||
: progress?.message || (installTaskRunning ? message || t('kernel.install.taskRunning') : '');
|
||||
const installDialogAction = installTaskRunning
|
||||
? cancellingInstall
|
||||
? '正在取消'
|
||||
? t('kernel.install.cancellingShort')
|
||||
: progress?.cancellable
|
||||
? '取消下载'
|
||||
: '处理中'
|
||||
: '关闭';
|
||||
? t('kernel.install.cancel')
|
||||
: t('common.processing')
|
||||
: t('common.close');
|
||||
const installDialogActionDisabled =
|
||||
installTaskRunning && (cancellingInstall || !progress?.cancellable);
|
||||
const apiPort = Number(customPort);
|
||||
@@ -761,19 +765,19 @@ export function KernelPage() {
|
||||
<div className="status-card status-card-primary">
|
||||
<span className={`status-dot ${statusTone}`} />
|
||||
<div>
|
||||
<span>内核状态</span>
|
||||
<span>{t('kernel.overview.coreStatus')}</span>
|
||||
<strong>{statusLabel}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<span>软件版本</span>
|
||||
<span>{t('kernel.overview.appVersion')}</span>
|
||||
<div className="software-version-line">
|
||||
<strong>{currentAppVersion}</strong>
|
||||
{appUpdateLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`software-version-action ${appUpdateTone}`}
|
||||
title={appUpdateError || '打开 GitHub 最新版本页面'}
|
||||
title={appUpdateError || t('kernel.update.openGithub')}
|
||||
disabled={checkingAppUpdate}
|
||||
onClick={() => void (appUpdateError ? checkAppUpdate() : openAppUpdate())}
|
||||
>
|
||||
@@ -785,8 +789,8 @@ export function KernelPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-card">
|
||||
<span>内核版本</span>
|
||||
<strong>{currentVersion || '未安装'}</strong>
|
||||
<span>{t('kernel.overview.coreVersion')}</span>
|
||||
<strong>{currentVersion || t('kernel.status.notInstalled')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -794,33 +798,33 @@ export function KernelPage() {
|
||||
<div className="panel control-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>运行控制</h2>
|
||||
<h2>{t('kernel.control.title')}</h2>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
|
||||
{processBusy || networkBusy ? '处理中' : statusLabel}
|
||||
{processBusy || networkBusy ? t('common.processing') : statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="panel-detail-grid">
|
||||
<div className="panel-detail-row">
|
||||
<dt>安装状态</dt>
|
||||
<dd>{coreStatus ? (coreInstalled ? '已安装' : '未安装') : '检测中'}</dd>
|
||||
<dt>{t('kernel.control.installStatus')}</dt>
|
||||
<dd>{coreStatus ? (coreInstalled ? t('kernel.control.installed') : t('kernel.status.notInstalled')) : t('common.detecting')}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>运行状态</dt>
|
||||
<dd>{coreStatus ? (coreRunning ? '运行中' : '未运行') : '检测中'}</dd>
|
||||
<dt>{t('kernel.control.runStatus')}</dt>
|
||||
<dd>{coreStatus ? (coreRunning ? t('kernel.status.running') : t('kernel.control.notRunning')) : t('common.detecting')}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>进程 PID</dt>
|
||||
<dd>{coreStatus?.processId || '无'}</dd>
|
||||
<dt>{t('kernel.control.pid')}</dt>
|
||||
<dd>{coreStatus?.processId || t('kernel.control.noPid')}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>允许局域网</dt>
|
||||
<dt>{t('kernel.control.allowLan')}</dt>
|
||||
<dd className="detail-control-cell">
|
||||
<span className="switch-control" title="切换后会自动重启正在运行的内核">
|
||||
<span className="switch-control" title={t('kernel.control.lanRestartHint')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="允许局域网访问"
|
||||
aria-label={t('kernel.control.allowLanAria')}
|
||||
disabled={!settingsLoaded || networkBusy || processBusy}
|
||||
checked={allowLanAccess}
|
||||
onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
|
||||
@@ -831,7 +835,7 @@ export function KernelPage() {
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>
|
||||
<label htmlFor="custom-port">端口</label>
|
||||
<label htmlFor="custom-port">{t('kernel.control.port')}</label>
|
||||
</dt>
|
||||
<dd className="detail-control-cell">
|
||||
<input
|
||||
@@ -841,8 +845,8 @@ export function KernelPage() {
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={5}
|
||||
placeholder="端口号"
|
||||
title={settingsError || '端口范围 1-65535'}
|
||||
placeholder={t('kernel.control.portPlaceholder')}
|
||||
title={settingsError || t('kernel.control.portRange')}
|
||||
aria-invalid={Boolean(settingsError)}
|
||||
disabled={!settingsLoaded || networkBusy || processBusy}
|
||||
value={customPort}
|
||||
@@ -868,21 +872,21 @@ export function KernelPage() {
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand(
|
||||
coreRunning ? 'stop_core_process' : 'start_core_process',
|
||||
{ success: coreRunning ? '内核已关闭' : '内核已启动' },
|
||||
{ success: coreRunning ? t('kernel.notice.stopped') : t('kernel.notice.started') },
|
||||
)
|
||||
}
|
||||
>
|
||||
{processBusy ? '处理中' : coreRunning ? '关闭' : '启动'}
|
||||
{processBusy ? t('common.processing') : coreRunning ? t('kernel.action.stop') : t('kernel.action.start')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy || networkBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand('restart_core_process', { success: '内核已重启' })
|
||||
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
|
||||
}
|
||||
>
|
||||
重启
|
||||
{t('kernel.action.restart')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -890,7 +894,7 @@ export function KernelPage() {
|
||||
disabled={processBusy || networkBusy}
|
||||
onClick={() => void refreshStatus()}
|
||||
>
|
||||
刷新状态
|
||||
{t('kernel.control.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -899,34 +903,34 @@ export function KernelPage() {
|
||||
<div className="panel version-panel">
|
||||
<div className="panel-heading">
|
||||
<div className="version-heading-inline">
|
||||
<h2>内核版本管理</h2>
|
||||
<h2>{t('kernel.versions.title')}</h2>
|
||||
<span className="version-offline-hint">
|
||||
GitHub 无法连接时,可使用离线安装使用内置内核。
|
||||
{t('kernel.versions.offlineHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="panel-detail-grid">
|
||||
<div className="panel-detail-row">
|
||||
<dt>当前版本</dt>
|
||||
<dd>{currentVersion || '未安装'}</dd>
|
||||
<dt>{t('kernel.versions.current')}</dt>
|
||||
<dd>{currentVersion || t('kernel.status.notInstalled')}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>最新版本</dt>
|
||||
<dt>{t('kernel.versions.latest')}</dt>
|
||||
<dd>{latestLabel}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>内置版本</dt>
|
||||
<dt>{t('kernel.versions.bundled')}</dt>
|
||||
<dd title={bundledCoreError || bundledCore?.assetName}>
|
||||
{bundledCore?.version ?? (bundledCoreError ? '检测失败' : '未包含')}
|
||||
{bundledCore?.version ?? (bundledCoreError ? t('common.detectionFailed') : t('kernel.versions.notIncluded'))}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>运行平台</dt>
|
||||
<dt>{t('kernel.versions.platform')}</dt>
|
||||
<dd title={platformError || undefined}>{platformOsLabel} / {platformArchLabel}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>更新状态</dt>
|
||||
<dt>{t('kernel.versions.updateStatus')}</dt>
|
||||
<dd className={versionStatusTone} title={versionStatusLabel}>
|
||||
{versionStatusLabel}
|
||||
</dd>
|
||||
@@ -940,34 +944,34 @@ export function KernelPage() {
|
||||
disabled={busy}
|
||||
onClick={checkLatest}
|
||||
>
|
||||
{checkingLatest ? '检查中' : '检查更新'}
|
||||
{checkingLatest ? t('kernel.update.checking') : t('kernel.versions.check')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
title={latestVersion ? `安装 ${latestVersion}` : '安装最新版'}
|
||||
title={latestVersion ? t('kernel.versions.installVersion', { version: latestVersion }) : t('kernel.versions.installLatest')}
|
||||
disabled={!latestVersion || installDisabled}
|
||||
onClick={() => installVersion(latestVersion)}
|
||||
>
|
||||
安装最新
|
||||
{t('kernel.versions.installLatest')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
title="重新安装当前版本"
|
||||
title={t('kernel.versions.reinstallTitle')}
|
||||
disabled={!currentVersion || installDisabled}
|
||||
onClick={() => installVersion(currentVersion)}
|
||||
>
|
||||
重新安装
|
||||
{t('kernel.versions.reinstall')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
title={(bundledCore?.assetName ?? bundledCoreError) || '当前发行包未包含内置内核'}
|
||||
title={(bundledCore?.assetName ?? bundledCoreError) || t('kernel.versions.noBundled')}
|
||||
disabled={!bundledCore || offlineInstallDisabled}
|
||||
onClick={() => void installBundledCore()}
|
||||
>
|
||||
离线安装
|
||||
{t('kernel.versions.offlineInstall')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -978,10 +982,10 @@ export function KernelPage() {
|
||||
<div className="panel-heading client-api-heading">
|
||||
<div>
|
||||
<h2>API URL</h2>
|
||||
<p>默认密钥:{DEFAULT_CLIENT_API_KEY}</p>
|
||||
<p>{t('kernel.access.defaultKey', { key: DEFAULT_CLIENT_API_KEY })}</p>
|
||||
</div>
|
||||
<span className={`state-pill ${coreRunning ? 'success' : ''}`}>
|
||||
{coreRunning ? '可连接' : '等待内核启动'}
|
||||
{coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1000,7 +1004,7 @@ export function KernelPage() {
|
||||
|
||||
<div className="client-api-values">
|
||||
<div className="client-api-value-row">
|
||||
<span>本机 URL</span>
|
||||
<span>{t('kernel.access.localUrl')}</span>
|
||||
<code title={profile.baseUrl}>{profile.baseUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1009,11 +1013,11 @@ export function KernelPage() {
|
||||
void copyApiValue(
|
||||
profile.baseUrl,
|
||||
`${profile.id}:base`,
|
||||
`${profile.name} 本机 URL 已复制`,
|
||||
t('kernel.access.localCopied', { name: profile.name }),
|
||||
)
|
||||
}
|
||||
title={`复制 ${profile.name} 本机 URL`}
|
||||
aria-label={`复制 ${profile.name} 本机 URL`}
|
||||
title={t('kernel.access.copyLocal', { name: profile.name })}
|
||||
aria-label={t('kernel.access.copyLocal', { name: profile.name })}
|
||||
>
|
||||
{copiedApiField === `${profile.id}:base` ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
@@ -1024,11 +1028,11 @@ export function KernelPage() {
|
||||
</div>
|
||||
{allowLanAccess ? (
|
||||
<div className="client-api-value-row">
|
||||
<span>局域网 URL</span>
|
||||
<span>{t('kernel.access.lanUrl')}</span>
|
||||
<code title={profile.lanUrl || undefined}>
|
||||
{!lanIpChecked
|
||||
? '正在检测局域网 IP'
|
||||
: profile.lanUrl || '未检测到局域网 IP'}
|
||||
? t('kernel.access.detectingIp')
|
||||
: profile.lanUrl || t('kernel.access.noIp')}
|
||||
</code>
|
||||
{profile.lanUrl ? (
|
||||
<button
|
||||
@@ -1038,11 +1042,11 @@ export function KernelPage() {
|
||||
void copyApiValue(
|
||||
profile.lanUrl!,
|
||||
`${profile.id}:lan`,
|
||||
`${profile.name} 局域网 URL 已复制`,
|
||||
t('kernel.access.lanCopied', { name: profile.name }),
|
||||
)
|
||||
}
|
||||
title={`复制 ${profile.name} 局域网 URL`}
|
||||
aria-label={`复制 ${profile.name} 局域网 URL`}
|
||||
title={t('kernel.access.copyLan', { name: profile.name })}
|
||||
aria-label={t('kernel.access.copyLan', { name: profile.name })}
|
||||
>
|
||||
{copiedApiField === `${profile.id}:lan` ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
@@ -1074,13 +1078,13 @@ export function KernelPage() {
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="install-dialog-heading">
|
||||
<span>内核安装</span>
|
||||
<span>{t('kernel.dialog.install')}</span>
|
||||
<h2 id="install-dialog-title">{installDialogTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="install-dialog-phase">
|
||||
<span>当前阶段</span>
|
||||
<strong>{cancellingInstall ? '正在取消' : progress.phase}</strong>
|
||||
<span>{t('kernel.dialog.phase')}</span>
|
||||
<strong>{cancellingInstall ? t('kernel.install.cancellingShort') : localizeInstallPhase(progress.phase, t)}</strong>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1095,7 +1099,7 @@ export function KernelPage() {
|
||||
</div>
|
||||
|
||||
<div className="install-progress-meta">
|
||||
<strong>{progressKnown ? `${progressPercent.toFixed(1)}%` : '进度未知'}</strong>
|
||||
<strong>{progressKnown ? `${progressPercent.toFixed(1)}%` : t('kernel.dialog.unknownProgress')}</strong>
|
||||
<span>{progressText}</span>
|
||||
</div>
|
||||
|
||||
@@ -1139,6 +1143,23 @@ export function KernelPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function localizeInstallPhase(
|
||||
phase: string,
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
) {
|
||||
const keys = {
|
||||
'准备下载': 'kernel.phase.preparingDownload',
|
||||
'下载中': 'kernel.phase.downloading',
|
||||
'解压中': 'kernel.phase.extracting',
|
||||
'准备内置内核': 'kernel.phase.preparingBundled',
|
||||
'安装完成': 'kernel.phase.completed',
|
||||
'安装失败': 'kernel.phase.failed',
|
||||
'已取消': 'kernel.phase.cancelled',
|
||||
} as const;
|
||||
const key = keys[phase as keyof typeof keys];
|
||||
return key ? t(key) : phase;
|
||||
}
|
||||
|
||||
function clampPercent(percent: number) {
|
||||
return Math.min(100, Math.max(0, percent));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
import grokIcon from '../assets/icons/grok.svg';
|
||||
import kimiIcon from '../assets/icons/kimi-light.svg';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai';
|
||||
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
|
||||
@@ -59,6 +60,7 @@ const OAUTH_SUCCESS_RESET_MS = 5000;
|
||||
const OAUTH_POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export function OAuthLoginPage() {
|
||||
const { t } = useI18n();
|
||||
const [states, setStates] = useState<Partial<Record<OAuthProviderId, OAuthProviderState>>>({});
|
||||
const [notice, setNotice] = useState<{
|
||||
message: string;
|
||||
@@ -153,16 +155,19 @@ export function OAuthLoginPage() {
|
||||
const status = (result.status || '').toLowerCase();
|
||||
if (status === 'ok') {
|
||||
completeProviderAuth(provider);
|
||||
showNotice(`${providerLabel(provider)} 登录成功`, 'success');
|
||||
showNotice(t('oauth.loginSuccess', { provider: providerLabel(provider) }), 'success');
|
||||
} else if (status === 'error') {
|
||||
updateProviderState(provider, {
|
||||
status: 'error',
|
||||
error: result.error || '认证失败',
|
||||
error: result.error || t('oauth.authFailed'),
|
||||
polling: false,
|
||||
});
|
||||
clearPollingTimer(provider);
|
||||
showNotice(
|
||||
`${providerLabel(provider)} 登录失败${result.error ? `:${result.error}` : ''}`,
|
||||
t('oauth.loginFailed', {
|
||||
provider: providerLabel(provider),
|
||||
detail: result.error ? `: ${result.error}` : '',
|
||||
}),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
@@ -183,7 +188,7 @@ export function OAuthLoginPage() {
|
||||
OAUTH_POLL_INTERVAL_MS,
|
||||
);
|
||||
},
|
||||
[clearPollingTimer, completeProviderAuth, showNotice, updateProviderState],
|
||||
[clearPollingTimer, completeProviderAuth, showNotice, t, updateProviderState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -218,10 +223,10 @@ export function OAuthLoginPage() {
|
||||
url: result.url,
|
||||
state: undefined,
|
||||
status: 'error',
|
||||
error: '内核未返回 OAuth state',
|
||||
error: t('oauth.missingState'),
|
||||
polling: false,
|
||||
});
|
||||
showNotice('内核未返回 OAuth state,无法轮询登录状态', 'error');
|
||||
showNotice(t('oauth.missingStatePolling'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,8 +241,8 @@ export function OAuthLoginPage() {
|
||||
if (!result.opened) {
|
||||
showNotice(
|
||||
result.openError
|
||||
? `已获取登录链接,但自动打开浏览器失败:${result.openError}`
|
||||
: '已获取登录链接,但自动打开浏览器失败,请手动打开',
|
||||
? t('oauth.openFailedDetail', { error: result.openError })
|
||||
: t('oauth.openFailed'),
|
||||
'info',
|
||||
);
|
||||
}
|
||||
@@ -264,9 +269,9 @@ export function OAuthLoginPage() {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showNotice('登录链接已复制', 'success');
|
||||
showNotice(t('oauth.linkCopied'), 'success');
|
||||
} catch {
|
||||
showNotice('复制登录链接失败', 'error');
|
||||
showNotice(t('oauth.linkCopyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -275,7 +280,7 @@ export function OAuthLoginPage() {
|
||||
const callbackInput = (current?.callbackUrl || '').trim();
|
||||
if (!callbackInput) {
|
||||
showNotice(
|
||||
provider === 'xai' ? '请粘贴 xAI 返回的 code 或回调 URL' : '请粘贴回调 URL',
|
||||
provider === 'xai' ? t('oauth.pasteXaiCallback') : t('oauth.pasteCallback'),
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
@@ -285,8 +290,8 @@ export function OAuthLoginPage() {
|
||||
if (!redirectUrl) {
|
||||
showNotice(
|
||||
provider === 'xai'
|
||||
? '无法从输入内容构造 xAI 回调,请检查 code 与 state'
|
||||
: '回调内容无效',
|
||||
? t('oauth.invalidXaiCallback')
|
||||
: t('oauth.invalidCallback'),
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
@@ -303,7 +308,7 @@ export function OAuthLoginPage() {
|
||||
callbackSubmitting: false,
|
||||
callbackStatus: 'success',
|
||||
});
|
||||
showNotice('回调已提交,正在等待认证完成', 'success');
|
||||
showNotice(t('oauth.callbackSubmittedNotice'), 'success');
|
||||
} catch (error) {
|
||||
updateProviderState(provider, {
|
||||
callbackSubmitting: false,
|
||||
@@ -317,7 +322,7 @@ export function OAuthLoginPage() {
|
||||
return (
|
||||
<section className="page management-page">
|
||||
<header className="management-header">
|
||||
<div><span>OAuth</span><h1>OAuth 登录</h1></div>
|
||||
<div><span>OAuth</span><h1>{t('oauth.title')}</h1></div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
@@ -331,10 +336,10 @@ export function OAuthLoginPage() {
|
||||
const state = states[provider.id] ?? { status: 'idle' as const };
|
||||
const canSubmitCallback = OAUTH_CALLBACK_SUPPORTED.has(provider.id) && Boolean(state.url);
|
||||
const loginLabel = state.status === 'success'
|
||||
? '登录另一个账号'
|
||||
? t('oauth.loginAnother')
|
||||
: state.polling
|
||||
? '登录中…'
|
||||
: '开始登录';
|
||||
? t('oauth.loggingIn')
|
||||
: t('oauth.startLogin');
|
||||
|
||||
return (
|
||||
<section className="panel oauth-card" key={provider.id}>
|
||||
@@ -343,23 +348,23 @@ export function OAuthLoginPage() {
|
||||
<div>
|
||||
<h2>{provider.name}</h2>
|
||||
<span className={`state-pill ${state.status === 'success' ? 'success' : state.status === 'error' ? 'error' : ''}`}>
|
||||
{oauthStatusLabel(state)}
|
||||
{oauthStatusLabel(state, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oauth-card-body">
|
||||
<p className="oauth-hint">点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接</p>
|
||||
<p className="oauth-hint">{t('oauth.hint')}</p>
|
||||
{state.url ? (
|
||||
<div className="oauth-auth-url-box">
|
||||
<div className="oauth-auth-url-label">授权链接</div>
|
||||
<div className="oauth-auth-url-label">{t('oauth.authorizationLink')}</div>
|
||||
<div className="oauth-auth-url-value" title={state.url}>{state.url}</div>
|
||||
<div className="oauth-auth-url-actions">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void copyAuthUrl(state.url)}>
|
||||
<Copy size={15} aria-hidden="true" />复制链接
|
||||
<Copy size={15} aria-hidden="true" />{t('oauth.copyLink')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void openAuthUrl(state.url)}>
|
||||
<ExternalLink size={15} aria-hidden="true" />打开链接
|
||||
<ExternalLink size={15} aria-hidden="true" />{t('oauth.openLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -378,18 +383,18 @@ export function OAuthLoginPage() {
|
||||
callbackError: undefined,
|
||||
});
|
||||
}}
|
||||
placeholder={provider.id === 'xai' ? '粘贴 code 或完整回调 URL' : '粘贴完整回调 URL'}
|
||||
placeholder={provider.id === 'xai' ? t('oauth.xaiCallbackPlaceholder') : t('oauth.callbackPlaceholder')}
|
||||
/>
|
||||
<button type="button" className="secondary-button" disabled={state.callbackSubmitting} onClick={() => void submitCallback(provider.id)}>
|
||||
{state.callbackSubmitting ? <LoaderCircle size={16} className="spin" aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
|
||||
提交回调
|
||||
{t('oauth.submitCallback')}
|
||||
</button>
|
||||
</div>
|
||||
{state.callbackStatus === 'success' && state.status === 'waiting' ? (
|
||||
<div className="oauth-inline-status success">回调已提交,等待认证完成</div>
|
||||
<div className="oauth-inline-status success">{t('oauth.callbackSubmitted')}</div>
|
||||
) : null}
|
||||
{state.callbackStatus === 'error' ? (
|
||||
<div className="oauth-inline-status error">回调提交失败{state.callbackError ? `:${state.callbackError}` : ''}</div>
|
||||
<div className="oauth-inline-status error">{t('oauth.callbackFailed', { detail: state.callbackError ? `: ${state.callbackError}` : '' })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -406,7 +411,7 @@ export function OAuthLoginPage() {
|
||||
</button>
|
||||
{state.url ? (
|
||||
<button type="button" className="secondary-button" onClick={() => void openAuthUrl(state.url)}>
|
||||
<ExternalLink size={16} aria-hidden="true" />打开浏览器
|
||||
<ExternalLink size={16} aria-hidden="true" />{t('oauth.openBrowser')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -418,11 +423,11 @@ export function OAuthLoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function oauthStatusLabel(state: OAuthProviderState) {
|
||||
if (state.status === 'success') return '已完成';
|
||||
if (state.status === 'error') return '失败';
|
||||
if (state.status === 'waiting' || state.polling) return '等待回调';
|
||||
return '未登录';
|
||||
function oauthStatusLabel(state: OAuthProviderState, t: ReturnType<typeof useI18n>['t']) {
|
||||
if (state.status === 'success') return t('oauth.status.completed');
|
||||
if (state.status === 'error') return t('oauth.status.failed');
|
||||
if (state.status === 'waiting' || state.polling) return t('oauth.status.waiting');
|
||||
return t('oauth.status.signedOut');
|
||||
}
|
||||
|
||||
function providerLabel(provider: OAuthProviderId) {
|
||||
|
||||
+21
-18
@@ -26,6 +26,7 @@ import {
|
||||
useQuotaCache,
|
||||
} from '../services/quotaCache';
|
||||
import { dedupeAuthFiles } from '../services/authFiles';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const providerMeta: Record<QuotaProvider, { label: string; icon: string }> = {
|
||||
claude: { label: 'Claude', icon: claudeIcon },
|
||||
@@ -39,6 +40,7 @@ const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai',
|
||||
const REFRESH_CONCURRENCY = 4;
|
||||
|
||||
export function QuotaPage() {
|
||||
const { locale, t } = useI18n();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const quotas = useQuotaCache();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -87,13 +89,13 @@ export function QuotaPage() {
|
||||
|
||||
const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
|
||||
const confirmed = window.confirm([
|
||||
`确认重置「${fileName(file)}」的 Codex 额度吗?`,
|
||||
t('quota.confirm.title', { name: fileName(file) }),
|
||||
'',
|
||||
'本操作将消耗 1 次主动重置机会。',
|
||||
`当前可用:${quota.resetCredits ?? '未记录'} 次`,
|
||||
`最早过期:${formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}`,
|
||||
t('quota.confirm.cost'),
|
||||
t('quota.confirm.available', { count: quota.resetCredits ?? '—' }),
|
||||
t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }),
|
||||
'',
|
||||
'只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。',
|
||||
t('quota.confirm.warning'),
|
||||
].join('\n'));
|
||||
if (!confirmed) return;
|
||||
const key = quotaKey(file);
|
||||
@@ -116,7 +118,7 @@ export function QuotaPage() {
|
||||
}));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [locale, t]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
@@ -156,27 +158,27 @@ export function QuotaPage() {
|
||||
return (
|
||||
<section className="page management-page quota-page">
|
||||
<header className="management-header">
|
||||
<div><span>Quota</span><h1>配额</h1></div>
|
||||
<div><span>Quota</span><h1>{t('quota.title')}</h1></div>
|
||||
<div className="management-heading-actions">
|
||||
<span className="muted-summary">{files.length} 个可查询凭据</span>
|
||||
<span className="muted-summary">{t(files.length === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: files.length })}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || refreshing}>
|
||||
<RefreshCw size={16} />读取列表
|
||||
<RefreshCw size={16} />{t('quota.readList')}
|
||||
</button>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void refreshAll()} disabled={refreshing || loading || files.length === 0}>
|
||||
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />刷新全部
|
||||
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />{t('quota.refreshAll')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取认证文件中</div>
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('quota.loadingFiles')}</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<div className="management-empty"><AlertCircle size={24} /><strong>暂无可查询配额</strong><span>先在认证文件中添加支持配额查询的凭据。</span></div>
|
||||
<div className="management-empty"><AlertCircle size={24} /><strong>{t('quota.empty.title')}</strong><span>{t('quota.empty.description')}</span></div>
|
||||
) : (
|
||||
<div className="quota-group-list">
|
||||
{grouped.map(([provider, items]) => (
|
||||
<section className="quota-provider-group" key={provider}>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className="provider-logo" /><h2>{providerMeta[provider].label}</h2></div><span>{items.length} 个凭据</span></div>
|
||||
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className="provider-logo" /><h2>{providerMeta[provider].label}</h2></div><span>{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span></div>
|
||||
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
|
||||
</section>
|
||||
))}
|
||||
@@ -187,17 +189,18 @@ export function QuotaPage() {
|
||||
}
|
||||
|
||||
export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void }) {
|
||||
const { locale, t } = useI18n();
|
||||
const provider = providerForFile(file);
|
||||
const name = fileName(file);
|
||||
const disabled = readBoolean(file, 'disabled');
|
||||
return (
|
||||
<article className="panel real-quota-card">
|
||||
<div className="real-quota-card-header"><div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : '未知'}{quota.plan ? ` · ${quota.plan}` : ''}</span></div><div className="quota-card-actions">{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={disabled || quota.status === 'loading'}>重置额度</button> : null}<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? '认证文件已停用' : '获取/刷新额度'}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button></div></div>
|
||||
{quota.status === 'idle' ? <div className="quota-card-message"><span>{disabled ? '认证文件已停用' : '尚未获取额度'}</span><button type="button" className="secondary-button compact-button" onClick={onRefresh} disabled={disabled}>{disabled ? '已停用' : '获取额度'}</button></div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />查询中</div> : null}
|
||||
<div className="real-quota-card-header"><div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ` · ${quota.plan}` : ''}</span></div><div className="quota-card-actions">{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={disabled || quota.status === 'loading'}>{t('quota.reset')}</button> : null}<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button></div></div>
|
||||
{quota.status === 'idle' ? <div className="quota-card-message"><span>{disabled ? t('quota.fileDisabled') : t('quota.notFetched')}</span><button type="button" className="secondary-button compact-button" onClick={onRefresh} disabled={disabled}>{disabled ? t('quota.disabled') : t('quota.fetch')}</button></div> : null}
|
||||
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />{t('quota.querying')}</div> : null}
|
||||
{quota.status === 'error' ? <div className="quota-card-error"><AlertCircle size={18} />{quota.error}</div> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary"><span>主动重置次数 <strong>{quota.resetCredits ?? '未记录'}</strong></span><span>最早过期 <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry)}</strong></span></div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => <div className="real-quota-row" key={`${row.label}-${index}`}><div><span>{row.label}</span><strong>{row.remainingPercent === null ? '—' : `剩余 ${Math.round(row.remainingPercent)}%`}</strong></div><div className="real-quota-track"><span style={{ width: `${Math.max(0, Math.min(100, row.remainingPercent ?? 0))}%` }} /></div><small>{row.detail ?? ''}{row.reset ? `${row.detail ? ' · ' : ''}${row.reset}` : ''}</small></div>)}</div> : null}
|
||||
{quota.status === 'success' && provider === 'codex' ? <div className="quota-reset-credit-summary"><span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span><span>{t('quota.earliestExpiry')} <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}</strong></span></div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => <div className="real-quota-row" key={`${row.label}-${index}`}><div><span>{row.label}</span><strong>{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}</strong></div><div className="real-quota-track"><span style={{ width: `${Math.max(0, Math.min(100, row.remainingPercent ?? 0))}%` }} /></div><small>{row.detail ?? ''}{row.reset ? `${row.detail ? ' · ' : ''}${row.reset}` : ''}</small></div>)}</div> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
|
||||
type PresetThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
|
||||
@@ -37,23 +38,25 @@ type ThinkingAliasSource = {
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
const effortOptions: { value: PresetThinkingEffort; label: string; hint: string }[] = [
|
||||
{ value: 'low', label: 'Low', hint: '轻量' },
|
||||
{ value: 'medium', label: 'Medium', hint: '均衡' },
|
||||
{ value: 'high', label: 'High', hint: '深入' },
|
||||
{ value: 'xhigh', label: 'XHigh', hint: '超高' },
|
||||
{ value: 'max', label: 'Max', hint: '最大' },
|
||||
];
|
||||
const effortOptions = [
|
||||
{ value: 'low', label: 'Low', hintKey: 'aliases.effort.low' },
|
||||
{ value: 'medium', label: 'Medium', hintKey: 'aliases.effort.medium' },
|
||||
{ value: 'high', label: 'High', hintKey: 'aliases.effort.high' },
|
||||
{ value: 'xhigh', label: 'XHigh', hintKey: 'aliases.effort.xhigh' },
|
||||
{ value: 'max', label: 'Max', hintKey: 'aliases.effort.max' },
|
||||
] as const satisfies ReadonlyArray<{ value: PresetThinkingEffort; label: string; hintKey: string }>;
|
||||
|
||||
export const thinkingAliasSourceKindLabel = (kind: string) => {
|
||||
if (kind === 'codex-oauth') return 'Codex OAuth';
|
||||
if (kind === 'codex-api') return 'Codex API';
|
||||
if (kind === 'openai-compatible') return 'OpenAI 兼容';
|
||||
return '其他来源';
|
||||
if (kind === 'openai-compatible') return translate(getCurrentLocale(), 'aliases.source.openAiCompatible');
|
||||
return translate(getCurrentLocale(), 'aliases.source.other');
|
||||
};
|
||||
|
||||
const thinkingAliasProviderDetail = (kind: string, provider: string) => (
|
||||
provider === thinkingAliasSourceKindLabel(kind) ? '内核当前可用模型' : provider
|
||||
provider === thinkingAliasSourceKindLabel(kind)
|
||||
? translate(getCurrentLocale(), 'aliases.source.available')
|
||||
: provider
|
||||
);
|
||||
|
||||
const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
|
||||
@@ -61,6 +64,7 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
|
||||
);
|
||||
|
||||
export function ThinkingAliasesPage() {
|
||||
const { t } = useI18n();
|
||||
const [entries, setEntries] = useState<ThinkingAliasEntry[]>([]);
|
||||
const [sources, setSources] = useState<ThinkingAliasSource[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState('');
|
||||
@@ -187,16 +191,16 @@ export function ThinkingAliasesPage() {
|
||||
|
||||
const createAlias = async () => {
|
||||
if (!selectedSource) {
|
||||
setError('请先选择原模型');
|
||||
setError(t('aliases.error.selectModel'));
|
||||
return;
|
||||
}
|
||||
const normalizedAlias = alias.trim();
|
||||
if (!normalizedAlias) {
|
||||
setError('别名模型不能为空');
|
||||
setError(t('aliases.error.emptyAlias'));
|
||||
return;
|
||||
}
|
||||
if (!normalizedEffort) {
|
||||
setError('思考强度不能为空');
|
||||
setError(t('aliases.error.emptyEffort'));
|
||||
return;
|
||||
}
|
||||
setBusyAlias(normalizedAlias);
|
||||
@@ -210,7 +214,7 @@ export function ThinkingAliasesPage() {
|
||||
effort: normalizedEffort,
|
||||
});
|
||||
setEntries(nextEntries);
|
||||
setNotice(`已创建 ${normalizedAlias},思考强度固定为 ${normalizedEffort}`);
|
||||
setNotice(t('aliases.created', { alias: normalizedAlias, effort: normalizedEffort }));
|
||||
setAlias('');
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -222,7 +226,7 @@ export function ThinkingAliasesPage() {
|
||||
|
||||
const deleteAlias = async (entry: ThinkingAliasEntry) => {
|
||||
if (!window.confirm(
|
||||
`确定删除别名模型「${entry.alias}」吗?\n将同时移除对应的思考强度覆盖规则。`,
|
||||
t('aliases.deleteConfirm', { alias: entry.alias }),
|
||||
)) return;
|
||||
setBusyAlias(entry.alias);
|
||||
setBusyAction('delete');
|
||||
@@ -233,7 +237,7 @@ export function ThinkingAliasesPage() {
|
||||
alias: entry.alias,
|
||||
});
|
||||
setEntries(nextEntries);
|
||||
setNotice(`已删除 ${entry.alias}`);
|
||||
setNotice(t('aliases.deleted', { alias: entry.alias }));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
@@ -247,7 +251,7 @@ export function ThinkingAliasesPage() {
|
||||
<header className="management-header">
|
||||
<div>
|
||||
<span>Model Alias</span>
|
||||
<h1>思考别名</h1>
|
||||
<h1>{t('aliases.title')}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -259,16 +263,16 @@ export function ThinkingAliasesPage() {
|
||||
<div className="thinking-alias-guide">
|
||||
<BrainCircuit size={20} />
|
||||
<div className="thinking-alias-guide-copy">
|
||||
<strong>为原模型新增一个固定思考强度的自定义别名</strong>
|
||||
<p>别名名称由你填写,客户端调用它时 CPA 才会注入所选思考强度;直接调用原模型时,原有名称、配置和行为全部保持不变。</p>
|
||||
<span>原模型不受影响</span>
|
||||
<strong>{t('aliases.intro.title')}</strong>
|
||||
<p>{t('aliases.intro.description')}</p>
|
||||
<span>{t('aliases.intro.badge')}</span>
|
||||
</div>
|
||||
<div className="thinking-alias-flow" aria-label="思考别名工作流程">
|
||||
<span><small>原模型</small><code>deepseek-v4-pro</code></span>
|
||||
<div className="thinking-alias-flow" aria-label={t('aliases.flow.aria')}>
|
||||
<span><small>{t('aliases.flow.original')}</small><code>deepseek-v4-pro</code></span>
|
||||
<ArrowRight size={14} />
|
||||
<div>
|
||||
<span><small>直接调用</small><code>deepseek-v4-pro · 保持不变</code></span>
|
||||
<span><small>别名调用</small><code>deepseek-v4-pro-max · 固定max强度</code></span>
|
||||
<span><small>{t('aliases.flow.direct')}</small><code>{t('aliases.flow.directValue')}</code></span>
|
||||
<span><small>{t('aliases.flow.alias')}</small><code>{t('aliases.flow.aliasValue')}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,13 +282,13 @@ export function ThinkingAliasesPage() {
|
||||
<div className="thinking-alias-panel-heading">
|
||||
<span><GitFork size={18} /></span>
|
||||
<div>
|
||||
<h2>创建思考别名</h2>
|
||||
<p>支持 Codex OAuth、Codex API 和 OpenAI 兼容接入</p>
|
||||
<h2>{t('aliases.create.title')}</h2>
|
||||
<p>{t('aliases.create.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field thinking-model-field">
|
||||
<label htmlFor="thinking-model-search">原模型</label>
|
||||
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
|
||||
<div className="thinking-model-picker" ref={modelPickerRef}>
|
||||
<div className="thinking-model-search">
|
||||
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
|
||||
@@ -308,7 +312,7 @@ export function ThinkingAliasesPage() {
|
||||
setModelPickerOpen(true);
|
||||
}}
|
||||
onKeyDown={handleModelSearchKeyDown}
|
||||
placeholder={loading ? '正在读取模型' : '搜索并选择原模型'}
|
||||
placeholder={loading ? t('aliases.loadingModels') : t('aliases.searchModel')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
@@ -324,11 +328,11 @@ export function ThinkingAliasesPage() {
|
||||
className="thinking-model-list"
|
||||
id="thinking-model-options"
|
||||
role="listbox"
|
||||
aria-label="可用原模型"
|
||||
aria-label={t('aliases.availableModels')}
|
||||
>
|
||||
{filteredSources.length === 0 ? (
|
||||
<div className="thinking-model-empty">
|
||||
{sources.length ? '没有匹配的模型或来源' : '当前没有可选模型'}
|
||||
{sources.length ? t('aliases.noMatch') : t('aliases.noModels')}
|
||||
</div>
|
||||
) : filteredSources.map((source, index) => {
|
||||
const selected = source.id === selectedSourceId;
|
||||
@@ -369,18 +373,18 @@ export function ThinkingAliasesPage() {
|
||||
<div className="thinking-model-selection">
|
||||
<span>{thinkingAliasSourceKindLabel(selectedSource.kind)}</span>
|
||||
<strong title={thinkingAliasSourceDetail(selectedSource)}>
|
||||
来源:{thinkingAliasSourceDetail(selectedSource)}
|
||||
{t('aliases.sourceLabel', { source: thinkingAliasSourceDetail(selectedSource) })}
|
||||
</strong>
|
||||
</div>
|
||||
) : (
|
||||
<small className="thinking-model-hint">同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出</small>
|
||||
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field">
|
||||
<div className="thinking-field-heading">
|
||||
<strong>思考强度</strong>
|
||||
<span>选择常用等级,或输入上游支持的自定义值</span>
|
||||
<strong>{t('aliases.effort.title')}</strong>
|
||||
<span>{t('aliases.effort.description')}</span>
|
||||
</div>
|
||||
<div className="thinking-effort-options">
|
||||
{effortOptions.map((option) => (
|
||||
@@ -393,7 +397,7 @@ export function ThinkingAliasesPage() {
|
||||
setCustomEffortOpen(false);
|
||||
}}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={option.hint}
|
||||
title={t(option.hintKey)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
@@ -407,10 +411,10 @@ export function ThinkingAliasesPage() {
|
||||
}}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={customEffortSelected
|
||||
? `自定义等级:${normalizedEffort}`
|
||||
: '输入模型提供商支持的其他思考等级'}
|
||||
? t('aliases.customLevel', { effort: normalizedEffort })
|
||||
: t('aliases.customLevelHint')}
|
||||
>
|
||||
{customEffortSelected ? normalizedEffort : '自定义'}
|
||||
{customEffortSelected ? normalizedEffort : t('aliases.custom')}
|
||||
</button>
|
||||
</div>
|
||||
{customEffortOpen ? <div className="thinking-effort-custom">
|
||||
@@ -418,7 +422,7 @@ export function ThinkingAliasesPage() {
|
||||
id="thinking-effort-custom"
|
||||
value={customEffortSelected ? effort : ''}
|
||||
onChange={(event) => chooseEffort(event.currentTarget.value)}
|
||||
placeholder="例如 minimal、auto、ultra 或厂商等级"
|
||||
placeholder={t('aliases.customPlaceholder')}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
@@ -428,19 +432,19 @@ export function ThinkingAliasesPage() {
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() => setCustomEffortOpen(false)}
|
||||
title="收起自定义输入"
|
||||
aria-label="收起自定义输入"
|
||||
title={t('aliases.collapseCustom')}
|
||||
aria-label={t('aliases.collapseCustom')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<small>仅填写目标模型实际支持的等级名称</small>
|
||||
<small>{t('aliases.customHelp')}</small>
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field">
|
||||
<div className="thinking-field-heading">
|
||||
<strong>别名模型名称</strong>
|
||||
<span>由你填写,仅新增入口,不会改名原模型</span>
|
||||
<strong>{t('aliases.aliasName.title')}</strong>
|
||||
<span>{t('aliases.aliasName.description')}</span>
|
||||
</div>
|
||||
<input
|
||||
id="thinking-alias-name"
|
||||
@@ -448,8 +452,8 @@ export function ThinkingAliasesPage() {
|
||||
value={alias}
|
||||
onChange={(event) => setAlias(event.currentTarget.value)}
|
||||
placeholder={selectedSource
|
||||
? `例如 ${selectedSource.model}-${normalizedEffort || 'high'}`
|
||||
: '先选择原模型,再填写客户端使用的别名'}
|
||||
? t('aliases.aliasName.example', { model: selectedSource.model, effort: normalizedEffort || 'high' })
|
||||
: t('aliases.aliasName.selectFirst')}
|
||||
disabled={Boolean(busyAlias)}
|
||||
/>
|
||||
</div>
|
||||
@@ -457,8 +461,8 @@ export function ThinkingAliasesPage() {
|
||||
<div className="thinking-alias-preview">
|
||||
<BrainCircuit size={18} />
|
||||
<div>
|
||||
<span>{selectedSource?.model || '未选择模型'} <ArrowRight size={13} /> {alias || '请输入别名模型名称'}</span>
|
||||
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || '未填写'}</code>
|
||||
<span>{selectedSource?.model || t('aliases.notSelected')} <ArrowRight size={13} /> {alias || t('aliases.enterAlias')}</span>
|
||||
<code>{selectedSource?.protocol === 'openai' ? 'reasoning_effort' : 'reasoning.effort'} = {normalizedEffort || t('aliases.notSet')}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -469,27 +473,27 @@ export function ThinkingAliasesPage() {
|
||||
disabled={loading || Boolean(busyAlias) || !selectedSource || !effort.trim() || !alias.trim()}
|
||||
>
|
||||
{busyAction === 'create' ? <LoaderCircle size={16} className="spin" /> : <GitFork size={16} />}
|
||||
{busyAction === 'create' ? '创建中' : '创建别名'}
|
||||
{busyAction === 'create' ? t('aliases.creating') : t('aliases.create')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="panel thinking-alias-list-panel">
|
||||
<div className="thinking-alias-list-heading">
|
||||
<div>
|
||||
<h2>已创建别名</h2>
|
||||
<span>每个别名都保留原模型、来源和固定思考强度</span>
|
||||
<h2>{t('aliases.createdList.title')}</h2>
|
||||
<span>{t('aliases.createdList.description')}</span>
|
||||
</div>
|
||||
<strong>{entries.length}</strong>
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-list">
|
||||
{loading ? (
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />读取配置中</div>
|
||||
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('aliases.loadingConfig')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="management-empty">
|
||||
<GitFork size={25} />
|
||||
<strong>尚未创建思考别名</strong>
|
||||
<span>从左侧选择模型和强度即可创建</span>
|
||||
<strong>{t('aliases.empty.title')}</strong>
|
||||
<span>{t('aliases.empty.description')}</span>
|
||||
</div>
|
||||
) : entries.map((entry) => (
|
||||
<article className="thinking-alias-row" key={`${entry.kind}:${entry.provider}:${entry.alias}`}>
|
||||
@@ -507,14 +511,14 @@ export function ThinkingAliasesPage() {
|
||||
<strong title={entry.alias}>{entry.alias}</strong>
|
||||
</div>
|
||||
<span className={`thinking-effort-badge ${entry.effort ? '' : 'missing'}`}>
|
||||
{entry.effort ?? '未绑定强度'}
|
||||
{entry.effort ?? t('aliases.unboundEffort')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger"
|
||||
onClick={() => void deleteAlias(entry)}
|
||||
disabled={Boolean(busyAlias)}
|
||||
title={`删除 ${entry.alias}`}
|
||||
title={t('aliases.delete', { alias: entry.alias })}
|
||||
>
|
||||
{busyAction === 'delete' && busyAlias === entry.alias
|
||||
? <LoaderCircle size={15} className="spin" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events';
|
||||
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
|
||||
@@ -141,14 +142,14 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string):
|
||||
return { start: new Date(now.getTime() - hours * 3_600_000).toISOString(), end: now.toISOString() };
|
||||
};
|
||||
|
||||
const compactNumber = (value: number) => new Intl.NumberFormat('zh-CN', {
|
||||
const compactNumber = (value: number) => new Intl.NumberFormat(getCurrentLocale(), {
|
||||
notation: value >= 10_000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(Number.isFinite(value) ? value : 0);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', {
|
||||
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
@@ -156,6 +157,7 @@ const formatTime = (value: string) => {
|
||||
const filterOptions = (items: UsageCategory[]) => items.filter((item) => item.key && item.label);
|
||||
|
||||
export function UsageRecordsPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<UsageTab>(loadTab);
|
||||
const [range, setRange] = useState<UsageRange>(loadRange);
|
||||
const [customStart, setCustomStart] = useState('');
|
||||
@@ -293,38 +295,38 @@ export function UsageRecordsPage() {
|
||||
<header className="management-header usage-records-header">
|
||||
<div>
|
||||
<span>Local Usage</span>
|
||||
<h1>使用记录</h1>
|
||||
<h1>{t('usage.title')}</h1>
|
||||
</div>
|
||||
<div className={`usage-collector-state ${collectorTone}`} title={status?.message}>
|
||||
<span className="status-dot" />
|
||||
<div>
|
||||
<strong>{status?.state === 'collecting' ? '本地采集中' : status?.state === 'error' ? '采集异常' : '等待内核'}</strong>
|
||||
<span>{compactNumber(status?.totalRecords ?? 0)} 条长期记录</span>
|
||||
<strong>{status?.state === 'collecting' ? t('usage.collector.collecting') : status?.state === 'error' ? t('usage.collector.error') : t('usage.collector.waiting')}</strong>
|
||||
<span>{t('usage.longTermRecords', { count: compactNumber(status?.totalRecords ?? 0) })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
|
||||
<div className="usage-tabs" role="tablist" aria-label="使用记录页面">
|
||||
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />总览</button>
|
||||
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />分析</button>
|
||||
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />请求明细</button>
|
||||
<div className="usage-tabs" role="tablist" aria-label={t('usage.pageLabel')}>
|
||||
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />{t('usage.tab.overview')}</button>
|
||||
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />{t('usage.tab.analysis')}</button>
|
||||
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />{t('usage.tab.events')}</button>
|
||||
</div>
|
||||
|
||||
<section className="panel usage-filter-panel">
|
||||
<select value={range} onChange={(event) => { setRange(event.currentTarget.value as UsageRange); setPage(1); }} aria-label="时间范围">
|
||||
<option value="4h">最近 4 小时</option><option value="24h">最近 24 小时</option><option value="today">今天</option><option value="7d">最近 7 天</option><option value="30d">最近 30 天</option><option value="all">全部时间</option><option value="custom">自定义</option>
|
||||
<select value={range} onChange={(event) => { setRange(event.currentTarget.value as UsageRange); setPage(1); }} aria-label={t('usage.filter.timeRange')}>
|
||||
<option value="4h">{t('usage.range.4h')}</option><option value="24h">{t('usage.range.24h')}</option><option value="today">{t('usage.range.today')}</option><option value="7d">{t('usage.range.7d')}</option><option value="30d">{t('usage.range.30d')}</option><option value="all">{t('usage.range.all')}</option><option value="custom">{t('usage.range.custom')}</option>
|
||||
</select>
|
||||
<select value={model} onChange={(event) => changeFilter(setModel, event.currentTarget.value)} aria-label="模型"><option value="">全部模型</option>{filterOptions(optionsAnalysis.models).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={provider} onChange={(event) => changeFilter(setProvider, event.currentTarget.value)} aria-label="Provider"><option value="">全部 Provider</option>{filterOptions(optionsAnalysis.providers).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={source} onChange={(event) => changeFilter(setSource, event.currentTarget.value)} aria-label="来源"><option value="">全部来源</option>{filterOptions(optionsAnalysis.sources).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={apiKeyHash} onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)} aria-label="API Key"><option value="">全部密钥</option>{filterOptions(optionsAnalysis.apiKeys).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={result} onChange={(event) => changeFilter(setResult, event.currentTarget.value)} aria-label="请求结果"><option value="all">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select>
|
||||
{range === 'custom' ? <div className="usage-custom-range"><input type="datetime-local" value={customStart} onChange={(event) => setCustomStart(event.currentTarget.value)} aria-label="开始时间" /><span>至</span><input type="datetime-local" value={customEnd} onChange={(event) => setCustomEnd(event.currentTarget.value)} aria-label="结束时间" /></div> : null}
|
||||
<select value={model} onChange={(event) => changeFilter(setModel, event.currentTarget.value)} aria-label={t('usage.filter.model')}><option value="">{t('usage.filter.allModels')}</option>{filterOptions(optionsAnalysis.models).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={provider} onChange={(event) => changeFilter(setProvider, event.currentTarget.value)} aria-label="Provider"><option value="">{t('usage.filter.allProviders')}</option>{filterOptions(optionsAnalysis.providers).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={source} onChange={(event) => changeFilter(setSource, event.currentTarget.value)} aria-label={t('usage.filter.source')}><option value="">{t('usage.filter.allSources')}</option>{filterOptions(optionsAnalysis.sources).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={apiKeyHash} onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)} aria-label="API Key"><option value="">{t('usage.filter.allKeys')}</option>{filterOptions(optionsAnalysis.apiKeys).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={result} onChange={(event) => changeFilter(setResult, event.currentTarget.value)} aria-label={t('usage.filter.result')}><option value="all">{t('usage.filter.allResults')}</option><option value="success">{t('usage.result.success')}</option><option value="failed">{t('usage.result.failed')}</option></select>
|
||||
{range === 'custom' ? <div className="usage-custom-range"><input type="datetime-local" value={customStart} onChange={(event) => setCustomStart(event.currentTarget.value)} aria-label={t('usage.filter.startTime')} /><span>{t('usage.filter.to')}</span><input type="datetime-local" value={customEnd} onChange={(event) => setCustomEnd(event.currentTarget.value)} aria-label={t('usage.filter.endTime')} /></div> : null}
|
||||
</section>
|
||||
|
||||
{showInitialLoading ? <div className="usage-initial-loading"><Database size={22} /><span>正在读取本地 SQLite 使用记录</span></div> : null}
|
||||
{showInitialLoading ? <div className="usage-initial-loading"><Database size={22} /><span>{t('usage.loading')}</span></div> : null}
|
||||
|
||||
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
|
||||
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
|
||||
@@ -334,24 +336,26 @@ export function UsageRecordsPage() {
|
||||
}
|
||||
|
||||
function OverviewView({ overview }: { overview: UsageOverview }) {
|
||||
const { t } = useI18n();
|
||||
const cards = [
|
||||
{ icon: Activity, label: '请求总数', value: compactNumber(overview.totalRequests), meta: `${overview.successCount} 成功 · ${overview.failureCount} 失败` },
|
||||
{ icon: Sparkles, label: 'Token 总量', value: compactNumber(overview.totalTokens), meta: `输入 ${compactNumber(overview.inputTokens)} · 输出 ${compactNumber(overview.outputTokens)}` },
|
||||
{ icon: ShieldCheck, label: '成功率', value: `${overview.successRate.toFixed(1)}%`, meta: `思考 ${compactNumber(overview.reasoningTokens)} Token` },
|
||||
{ icon: Clock3, label: '平均延迟', value: `${Math.round(overview.averageLatencyMs)} ms`, meta: `${overview.rpm.toFixed(2)} RPM · ${compactNumber(overview.tpm)} TPM` },
|
||||
{ icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: overview.successCount, failed: overview.failureCount }) },
|
||||
{ icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) },
|
||||
{ icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) },
|
||||
{ icon: Clock3, label: t('usage.stat.averageLatency'), value: `${Math.round(overview.averageLatencyMs)} ms`, meta: `${overview.rpm.toFixed(2)} RPM · ${compactNumber(overview.tpm)} TPM` },
|
||||
];
|
||||
return <div className="usage-overview-layout">
|
||||
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
|
||||
<section className="panel usage-trend-panel"><div className="usage-section-heading"><div><strong>请求与 Token 趋势</strong><span>按系统本地小时聚合</span></div></div>{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}</section>
|
||||
<section className="panel usage-health-panel"><div className="usage-section-heading"><div><strong>Token 构成</strong><span>缓存、思考与生成消耗</span></div></div><div className="usage-token-breakdown"><TokenMetric label="输入" value={overview.inputTokens} total={overview.totalTokens} /><TokenMetric label="输出" value={overview.outputTokens} total={overview.totalTokens} /><TokenMetric label="思考" value={overview.reasoningTokens} total={overview.totalTokens} /><TokenMetric label="缓存读取" value={overview.cacheReadTokens} total={overview.totalTokens} /><TokenMetric label="缓存创建" value={overview.cacheCreationTokens} total={overview.totalTokens} /></div></section>
|
||||
<section className="panel usage-trend-panel"><div className="usage-section-heading"><div><strong>{t('usage.trend.title')}</strong><span>{t('usage.trend.description')}</span></div></div>{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}</section>
|
||||
<section className="panel usage-health-panel"><div className="usage-section-heading"><div><strong>{t('usage.token.title')}</strong><span>{t('usage.token.description')}</span></div></div><div className="usage-token-breakdown"><TokenMetric label={t('usage.token.input')} value={overview.inputTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.output')} value={overview.outputTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.reasoning')} value={overview.reasoningTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.cacheRead')} value={overview.cacheReadTokens} total={overview.totalTokens} /><TokenMetric label={t('usage.token.cacheCreation')} value={overview.cacheCreationTokens} total={overview.totalTokens} /></div></section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UsageTrend({ points }: { points: TimelinePoint[] }) {
|
||||
const { t } = useI18n();
|
||||
const recent = points.slice(-48);
|
||||
const max = Math.max(...recent.map((point) => point.requests), 1);
|
||||
const polyline = recent.map((point, index) => `${recent.length === 1 ? 50 : index * 100 / (recent.length - 1)},${28 - point.requests * 24 / max}`).join(' ');
|
||||
return <div className="usage-trend"><svg viewBox="0 0 100 32" preserveAspectRatio="none" aria-label="请求趋势"><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /></svg><div className="usage-trend-labels"><span>{recent[0]?.hour ?? ''}</span><strong>{compactNumber(recent.reduce((sum, point) => sum + point.tokens, 0))} Token</strong><span>{recent[recent.length - 1]?.hour ?? ''}</span></div></div>;
|
||||
return <div className="usage-trend"><svg viewBox="0 0 100 32" preserveAspectRatio="none" aria-label={t('usage.trend.aria')}><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /></svg><div className="usage-trend-labels"><span>{recent[0]?.hour ?? ''}</span><strong>{compactNumber(recent.reduce((sum, point) => sum + point.tokens, 0))} Token</strong><span>{recent[recent.length - 1]?.hour ?? ''}</span></div></div>;
|
||||
}
|
||||
|
||||
function TokenMetric({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
@@ -360,6 +364,7 @@ function TokenMetric({ label, value, total }: { label: string; value: number; to
|
||||
}
|
||||
|
||||
function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overview: UsageOverview | null }) {
|
||||
const { t } = useI18n();
|
||||
const hours = (overview?.timeline ?? []).map((point) => ({
|
||||
key: point.hour,
|
||||
label: point.hour,
|
||||
@@ -367,19 +372,22 @@ function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overvie
|
||||
failures: point.failure,
|
||||
tokens: point.tokens,
|
||||
})).sort((left, right) => right.tokens - left.tokens);
|
||||
return <div className="usage-analysis-grid"><CategoryPanel title="模型使用" items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title="请求来源" items={analysis.sources} /><CategoryPanel title="鉴权密钥" items={analysis.apiKeys} /><CategoryPanel title="时段分布" items={hours} /></div>;
|
||||
return <div className="usage-analysis-grid"><CategoryPanel title={t('usage.analysis.models')} items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title={t('usage.analysis.sources')} items={analysis.sources} /><CategoryPanel title={t('usage.analysis.keys')} items={analysis.apiKeys} /><CategoryPanel title={t('usage.analysis.hours')} items={hours} /></div>;
|
||||
}
|
||||
|
||||
function CategoryPanel({ title, items }: { title: string; items: UsageCategory[] }) {
|
||||
const { t } = useI18n();
|
||||
const max = Math.max(...items.map((item) => item.tokens), 1);
|
||||
const total = items.reduce((sum, item) => sum + item.tokens, 0);
|
||||
return <section className="panel usage-category-panel"><div className="usage-section-heading"><div><strong>{title}</strong><span>按 Token 排序</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{compactNumber(item.requests)} 次 · {total ? (item.tokens * 100 / total).toFixed(1) : '0.0'}% · {compactNumber(item.tokens)} Token</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
|
||||
return <section className="panel usage-category-panel"><div className="usage-section-heading"><div><strong>{title}</strong><span>{t('usage.analysis.sortedByTokens')}</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
|
||||
}
|
||||
|
||||
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>共 {compactNumber(events.total)} 条记录</span><span>第 {events.page} / {events.totalPages} 页</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>时间</th><th>模型</th><th>Provider</th><th>来源</th><th>API Key</th><th>结果</th><th>延迟</th><th>TTFT</th><th>输入</th><th>输出</th><th>思考</th><th>缓存</th><th>总计</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || '未备注'}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? '失败' : '成功'}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>上一页</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>下一页</button></div></section>;
|
||||
const { t } = useI18n();
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
|
||||
}
|
||||
|
||||
function UsageEmpty() {
|
||||
return <div className="usage-empty"><TriangleAlert size={18} /><span>当前筛选范围没有使用记录</span></div>;
|
||||
const { t } = useI18n();
|
||||
return <div className="usage-empty"><TriangleAlert size={18} /><span>{t('usage.empty')}</span></div>;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { readBoolean, readString } from './managementApi';
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
export type AuthFileRecord = Record<string, unknown>;
|
||||
|
||||
export const authFileName = (file: AuthFileRecord) =>
|
||||
readString(file, 'name') || '未命名认证文件';
|
||||
readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed');
|
||||
|
||||
export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) =>
|
||||
readBoolean(file, 'runtime_only', 'runtimeOnly');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
export const DEFAULT_CLIENT_API_KEY = '123456';
|
||||
|
||||
export type ClientApiProfile = {
|
||||
@@ -17,21 +19,21 @@ export function clientApiProfiles(port: number, lanIpv4?: string | null): Client
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: '适用于 OpenAI SDK、OpenCode 等兼容客户端',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.openaiDescription'),
|
||||
baseUrl: `${origin}/v1`,
|
||||
lanUrl: lanOrigin ? `${lanOrigin}/v1` : null,
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
description: '适用于 Claude SDK 与 Claude Code',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.claudeDescription'),
|
||||
baseUrl: origin,
|
||||
lanUrl: lanOrigin,
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
description: '适用于 Gemini 原生兼容客户端',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.geminiDescription'),
|
||||
baseUrl: origin,
|
||||
lanUrl: lanOrigin,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
export type ManagementJson = Record<string, unknown> | unknown[] | string | number | boolean | null;
|
||||
|
||||
@@ -117,7 +118,7 @@ export function responseList(payload: unknown, key: string): Record<string, unkn
|
||||
export function maskSecret(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return '未配置';
|
||||
return translate(getCurrentLocale(), 'management.notConfigured');
|
||||
}
|
||||
if (normalized.length <= 8) {
|
||||
return `${normalized.slice(0, 2)}••••`;
|
||||
@@ -136,7 +137,7 @@ export function formatDate(value: unknown): string {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -181,10 +182,12 @@ const messageFromPayload = (value: unknown, depth = 0): string => {
|
||||
|
||||
export function apiCallErrorMessage(
|
||||
response: Record<string, unknown>,
|
||||
fallback = '上游请求失败',
|
||||
fallback = translate(getCurrentLocale(), 'management.error.upstream'),
|
||||
): string {
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
const message = messageFromPayload(response.body ?? response.bodyText);
|
||||
if (message) return message;
|
||||
return status > 0 ? `上游返回 HTTP ${status}` : fallback;
|
||||
return status > 0
|
||||
? translate(getCurrentLocale(), 'management.error.upstreamHttp', { status })
|
||||
: fallback;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { apiCallErrorMessage, isRecord, managementApi, readString } from './managementApi';
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
|
||||
const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrentLocale(), key);
|
||||
|
||||
export type ModelOption = {
|
||||
name: string;
|
||||
@@ -18,10 +21,10 @@ export function normalizeBaseUrl(value: string): string {
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('Base URL 不是有效地址,请输入类似 https://api.example.com 的 URL');
|
||||
throw new Error(modelText('model.error.invalidBaseUrl'));
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
|
||||
throw new Error('Base URL 只支持 http:// 或 https:// 地址');
|
||||
throw new Error(modelText('model.error.unsupportedBaseUrl'));
|
||||
}
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
@@ -90,7 +93,7 @@ export async function fetchModels(
|
||||
): Promise<ModelOption[]> {
|
||||
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
|
||||
const candidates = endpointCandidates(provider, normalized);
|
||||
if (candidates.length === 0) throw new Error('请先输入 Base URL');
|
||||
if (candidates.length === 0) throw new Error(modelText('model.error.baseUrlRequired'));
|
||||
const headers: Record<string, string> = { ...customHeaders };
|
||||
const hasHeader = (name: string) =>
|
||||
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
||||
@@ -188,5 +191,5 @@ export async function fetchModels(
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(lastError || '模型接口无响应');
|
||||
throw new Error(lastError || modelText('model.error.noResponse'));
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
readString,
|
||||
} from './managementApi';
|
||||
import { authFileName } from './authFiles';
|
||||
import { getCurrentLocale, translate, type AppLocale } from '../i18n';
|
||||
|
||||
const quotaText = (
|
||||
key: Parameters<typeof translate>[1],
|
||||
variables?: Parameters<typeof translate>[2],
|
||||
) => translate(getCurrentLocale(), key, variables);
|
||||
|
||||
export type AuthFile = Record<string, unknown>;
|
||||
export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity';
|
||||
@@ -124,7 +130,7 @@ const quotaFraction = (value: unknown): number | null => {
|
||||
|
||||
const formatDateTime = (date: Date): string | undefined => {
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(getCurrentLocale(), {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -133,11 +139,11 @@ const formatDateTime = (date: Date): string | undefined => {
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export const formatQuotaTimestamp = (value: string | undefined): string => {
|
||||
export const formatQuotaTimestamp = (value: string | undefined, locale: AppLocale = 'zh-CN'): string => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -166,18 +172,22 @@ const relativeResetLabel = (value: unknown): string | undefined => {
|
||||
const remainingHours = Math.floor((minutes % 1440) / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (days > 0) {
|
||||
return remainingHours > 0 ? `${days} 天 ${remainingHours} 小时后` : `${days} 天后`;
|
||||
return remainingHours > 0
|
||||
? quotaText('quota.service.relative.daysHours', { days, hours: remainingHours })
|
||||
: quotaText('quota.service.relative.days', { days });
|
||||
}
|
||||
if (hours > 0) {
|
||||
return remainingMinutes > 0 ? `${hours} 小时 ${remainingMinutes} 分钟后` : `${hours} 小时后`;
|
||||
return remainingMinutes > 0
|
||||
? quotaText('quota.service.relative.hoursMinutes', { hours, minutes: remainingMinutes })
|
||||
: quotaText('quota.service.relative.hours', { hours });
|
||||
}
|
||||
return `${minutes} 分钟后`;
|
||||
return quotaText('quota.service.relative.minutes', { minutes });
|
||||
};
|
||||
|
||||
const formatUsdFromCents = (value: number | null) =>
|
||||
value === null
|
||||
? undefined
|
||||
: new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value / 100);
|
||||
: new Intl.NumberFormat(getCurrentLocale(), { style: 'currency', currency: 'USD' }).format(value / 100);
|
||||
|
||||
const codexResetLabel = (window: Record<string, unknown>): string | undefined =>
|
||||
absoluteResetLabel(window.reset_at ?? window.resetAt)
|
||||
@@ -189,14 +199,14 @@ const MIN_MONTH_SECONDS = 28 * 86_400;
|
||||
const MAX_MONTH_SECONDS = 31 * 86_400;
|
||||
|
||||
const formatWindowDuration = (seconds: number | null): string => {
|
||||
if (seconds === null || seconds <= 0) return '未知周期';
|
||||
if (seconds === null || seconds <= 0) return quotaText('quota.service.duration.unknown');
|
||||
const day = 86_400;
|
||||
const hour = 3_600;
|
||||
const minute = 60;
|
||||
if (seconds % day === 0) return `${seconds / day} 天`;
|
||||
if (seconds % hour === 0) return `${seconds / hour} 小时`;
|
||||
if (seconds % minute === 0) return `${seconds / minute} 分钟`;
|
||||
return `${seconds} 秒`;
|
||||
if (seconds % day === 0) return quotaText('quota.service.duration.days', { count: seconds / day });
|
||||
if (seconds % hour === 0) return quotaText('quota.service.duration.hours', { count: seconds / hour });
|
||||
if (seconds % minute === 0) return quotaText('quota.service.duration.minutes', { count: seconds / minute });
|
||||
return quotaText('quota.service.duration.seconds', { count: seconds });
|
||||
};
|
||||
|
||||
const codexWindowLabel = (
|
||||
@@ -205,14 +215,16 @@ const codexWindowLabel = (
|
||||
kind: 'primary' | 'secondary',
|
||||
teamPlan: boolean,
|
||||
) => {
|
||||
if (duration === FIVE_HOUR_SECONDS) return `${prefix}5 小时限额`;
|
||||
if (duration === WEEK_SECONDS) return `${prefix}周限额`;
|
||||
if (duration === FIVE_HOUR_SECONDS) return `${prefix}${quotaText('quota.service.limit.fiveHours')}`;
|
||||
if (duration === WEEK_SECONDS) return `${prefix}${quotaText('quota.service.limit.week')}`;
|
||||
if (duration !== null && duration >= MIN_MONTH_SECONDS && duration <= MAX_MONTH_SECONDS) {
|
||||
return `${prefix}月限额`;
|
||||
return `${prefix}${quotaText('quota.service.limit.month')}`;
|
||||
}
|
||||
if (duration !== null) return `${prefix}${formatWindowDuration(duration)}限额`;
|
||||
if (kind === 'primary') return `${prefix}5 小时限额`;
|
||||
return `${prefix}${teamPlan ? '月限额' : '周限额'}`;
|
||||
if (duration !== null) {
|
||||
return `${prefix}${quotaText('quota.service.limit.duration', { duration: formatWindowDuration(duration) })}`;
|
||||
}
|
||||
if (kind === 'primary') return `${prefix}${quotaText('quota.service.limit.fiveHours')}`;
|
||||
return `${prefix}${quotaText(teamPlan ? 'quota.service.limit.month' : 'quota.service.limit.week')}`;
|
||||
};
|
||||
|
||||
const codexWindowRows = (value: Record<string, unknown>): QuotaRow[] => {
|
||||
@@ -231,12 +243,16 @@ const codexWindowRows = (value: Record<string, unknown>): QuotaRow[] => {
|
||||
};
|
||||
|
||||
addRateLimit(value.rate_limit ?? value.rateLimit, '');
|
||||
addRateLimit(value.code_review_rate_limit ?? value.codeReviewRateLimit, '代码审查 ');
|
||||
addRateLimit(
|
||||
value.code_review_rate_limit ?? value.codeReviewRateLimit,
|
||||
`${quotaText('quota.service.codeReview')} `,
|
||||
);
|
||||
const additional = value.additional_rate_limits ?? value.additionalRateLimits;
|
||||
if (Array.isArray(additional)) {
|
||||
additional.forEach((item, index) => {
|
||||
if (!isRecord(item)) return;
|
||||
const name = readString(item, 'limit_name', 'limitName', 'metered_feature', 'meteredFeature') || `附加 ${index + 1}`;
|
||||
const name = readString(item, 'limit_name', 'limitName', 'metered_feature', 'meteredFeature')
|
||||
|| quotaText('quota.service.additional', { index: index + 1 });
|
||||
addRateLimit(item.rate_limit ?? item.rateLimit, `${name} `);
|
||||
});
|
||||
}
|
||||
@@ -307,12 +323,12 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
|
||||
if (provider === 'claude') {
|
||||
const labels: Record<string, string> = {
|
||||
five_hour: '5 小时窗口',
|
||||
seven_day: '7 天窗口',
|
||||
seven_day_oauth_apps: '7 天 OAuth 应用窗口',
|
||||
seven_day_opus: '7 天 Opus 窗口',
|
||||
seven_day_sonnet: '7 天 Sonnet 窗口',
|
||||
seven_day_cowork: '7 天 Cowork 窗口',
|
||||
five_hour: quotaText('quota.service.window.fiveHour'),
|
||||
seven_day: quotaText('quota.service.window.sevenDay'),
|
||||
seven_day_oauth_apps: quotaText('quota.service.window.sevenDayOAuth'),
|
||||
seven_day_opus: quotaText('quota.service.window.sevenDayOpus'),
|
||||
seven_day_sonnet: quotaText('quota.service.window.sevenDaySonnet'),
|
||||
seven_day_cowork: quotaText('quota.service.window.sevenDayCowork'),
|
||||
iguana_necktie: 'Iguana Necktie',
|
||||
};
|
||||
const rows = Object.entries(value)
|
||||
@@ -340,11 +356,13 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
const usedLabel = formatUsdFromCents(usedCredits);
|
||||
const limitLabel = formatUsdFromCents(monthlyLimit);
|
||||
rows.push({
|
||||
label: '额外用量',
|
||||
label: quotaText('quota.service.extraUsage'),
|
||||
remainingPercent:
|
||||
remainingFromUsedPercent(extraUsage.utilization)
|
||||
?? clampPercent(computedRemaining),
|
||||
detail: usedLabel && limitLabel ? `已用 ${usedLabel} / ${limitLabel}` : undefined,
|
||||
detail: usedLabel && limitLabel
|
||||
? quotaText('quota.service.usedOf', { used: usedLabel, limit: limitLabel })
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
@@ -352,7 +370,9 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
|
||||
if (provider === 'kimi') {
|
||||
const items: unknown[] = [];
|
||||
if (isRecord(value.usage)) items.push({ ...value.usage, label: '每周额度' });
|
||||
if (isRecord(value.usage)) {
|
||||
items.push({ ...value.usage, label: quotaText('quota.service.weekly') });
|
||||
}
|
||||
if (Array.isArray(value.limits)) items.push(...value.limits);
|
||||
return items
|
||||
.map((raw, index): QuotaRow | null => {
|
||||
@@ -368,23 +388,26 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
const unit = readString(window, 'timeUnit', 'time_unit')
|
||||
|| readString(raw, 'timeUnit', 'time_unit')
|
||||
|| readString(detail, 'timeUnit', 'time_unit');
|
||||
const durationLabel = duration !== null && duration > 0
|
||||
? `${unit.toLowerCase().startsWith('day')
|
||||
? `${duration} 天`
|
||||
const durationText = duration !== null && duration > 0
|
||||
? unit.toLowerCase().startsWith('day')
|
||||
? quotaText('quota.service.duration.days', { count: duration })
|
||||
: unit.toLowerCase().startsWith('hour')
|
||||
? `${duration} 小时`
|
||||
? quotaText('quota.service.duration.hours', { count: duration })
|
||||
: unit.toLowerCase().startsWith('second')
|
||||
? `${duration} 秒`
|
||||
? quotaText('quota.service.duration.seconds', { count: duration })
|
||||
: duration % 60 === 0
|
||||
? `${duration / 60} 小时`
|
||||
: `${duration} 分钟`}窗口`
|
||||
? quotaText('quota.service.duration.hours', { count: duration / 60 })
|
||||
: quotaText('quota.service.duration.minutes', { count: duration })
|
||||
: '';
|
||||
const durationLabel = durationText
|
||||
? quotaText('quota.service.window.duration', { duration: durationText })
|
||||
: '';
|
||||
return {
|
||||
label:
|
||||
readString(raw, 'label', 'name', 'title', 'scope')
|
||||
|| readString(detail, 'name', 'title', 'scope')
|
||||
|| durationLabel
|
||||
|| `限制 ${index + 1}`,
|
||||
|| quotaText('quota.service.limit.numbered', { index: index + 1 }),
|
||||
remainingPercent: clampPercent(
|
||||
limit !== null && limit > 0
|
||||
? (Math.max(0, limit - (usedValue ?? 0)) / limit) * 100
|
||||
@@ -422,7 +445,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
|
||||
if (weeklyUsed !== null || periodType.includes('week')) {
|
||||
rows.push({
|
||||
label: '每周额度',
|
||||
label: quotaText('quota.service.weekly'),
|
||||
remainingPercent: remainingFromUsedPercent(weeklyUsed),
|
||||
reset: absoluteResetLabel(currentPeriod?.end),
|
||||
});
|
||||
@@ -434,7 +457,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
productUsage.forEach((item, index) => {
|
||||
if (!isRecord(item)) return;
|
||||
rows.push({
|
||||
label: readString(item, 'product') || `产品 ${index + 1}`,
|
||||
label: readString(item, 'product')
|
||||
|| quotaText('quota.service.product.numbered', { index: index + 1 }),
|
||||
remainingPercent: remainingFromUsedPercent(
|
||||
item.usage_percent ?? item.usagePercent,
|
||||
),
|
||||
@@ -455,7 +479,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
? ((limit - includedUsed) / limit) * 100
|
||||
: null;
|
||||
rows.push({
|
||||
label: '月度包含额度',
|
||||
label: quotaText('quota.service.monthlyIncluded'),
|
||||
remainingPercent: clampPercent(remaining),
|
||||
reset: absoluteResetLabel(config.billing_period_end ?? config.billingPeriodEnd),
|
||||
detail: limit !== null
|
||||
@@ -470,7 +494,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
?? (used !== null && limit !== null ? Math.max(0, used - limit) : null);
|
||||
if (onDemandCap !== null && onDemandCap > 0) {
|
||||
rows.push({
|
||||
label: '按量付费额度',
|
||||
label: quotaText('quota.service.onDemand'),
|
||||
remainingPercent: clampPercent(
|
||||
onDemandUsed === null ? null : ((onDemandCap - onDemandUsed) / onDemandCap) * 100,
|
||||
),
|
||||
@@ -493,7 +517,8 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
return groups.flatMap((group) => {
|
||||
if (!isRecord(group) || !Array.isArray(group.buckets)) return [];
|
||||
const buckets = group.buckets;
|
||||
const groupLabel = readString(group, 'display_name', 'displayName') || '配额';
|
||||
const groupLabel = readString(group, 'display_name', 'displayName')
|
||||
|| quotaText('quota.service.quota');
|
||||
const groupDescription = readString(group, 'description');
|
||||
return buckets
|
||||
.map((bucket, index): QuotaRow | null => {
|
||||
@@ -505,7 +530,7 @@ export const quotaRowsFor = (provider: QuotaProvider, payload: unknown): QuotaRo
|
||||
? `${groupLabel} · ${bucketLabel}`
|
||||
: groupLabel;
|
||||
return {
|
||||
label: label || `配额 ${index + 1}`,
|
||||
label: label || quotaText('quota.service.quota.numbered', { index: index + 1 }),
|
||||
remainingPercent: remaining * 100,
|
||||
reset: absoluteResetLabel(bucket.reset_time ?? bucket.resetTime),
|
||||
detail: readString(bucket, 'description') || groupDescription || undefined,
|
||||
@@ -652,7 +677,7 @@ const requestQuotaPayload = async (
|
||||
|
||||
const callXaiQuota = async (file: AuthFile): Promise<unknown> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询配额');
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
const header = { ...headersByProvider.xai };
|
||||
const userId = await resolveXaiUserId(file);
|
||||
if (userId) header['x-userid'] = userId;
|
||||
@@ -752,14 +777,16 @@ async function callUpstreamQuota(
|
||||
resolvedCodexAccountId?: string,
|
||||
): Promise<unknown> {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询配额');
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingAuthIndex'));
|
||||
const header = { ...headersByProvider[provider] };
|
||||
if (provider === 'codex') {
|
||||
const accountId = resolvedCodexAccountId ?? await resolveCodexAccountId(file);
|
||||
if (accountId) header['Chatgpt-Account-Id'] = accountId;
|
||||
}
|
||||
const project = provider === 'antigravity' ? await resolveProjectId(file) : '';
|
||||
if (provider === 'antigravity' && !project) throw new Error('缺少 Antigravity project ID');
|
||||
if (provider === 'antigravity' && !project) {
|
||||
throw new Error(quotaText('quota.service.error.missingProject'));
|
||||
}
|
||||
const urls = provider === 'antigravity'
|
||||
? [endpointByProvider.antigravity, 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary', 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary']
|
||||
: [endpointByProvider[provider]];
|
||||
@@ -777,7 +804,7 @@ async function callUpstreamQuota(
|
||||
if (provider === 'antigravity') {
|
||||
hadSuccessfulResponse = true;
|
||||
if (quotaRowsFor('antigravity', payload).length === 0) {
|
||||
lastError = 'Antigravity 返回成功,但没有可识别的配额分组';
|
||||
lastError = quotaText('quota.service.error.antigravityEmpty');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -787,7 +814,11 @@ async function callUpstreamQuota(
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
lastError || (hadSuccessfulResponse ? '上游返回了空配额数据' : '配额接口无响应'),
|
||||
lastError || quotaText(
|
||||
hadSuccessfulResponse
|
||||
? 'quota.service.error.upstreamEmpty'
|
||||
: 'quota.service.error.noResponse',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -796,7 +827,7 @@ const callCodexResetCredits = async (
|
||||
accountId: string,
|
||||
): Promise<{ availableCount?: number; earliestExpiry?: string }> => {
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法查询主动重置次数');
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingResetAuthIndex'));
|
||||
const header: Record<string, string> = {
|
||||
...headersByProvider.codex,
|
||||
Accept: 'application/json',
|
||||
@@ -810,7 +841,13 @@ const callCodexResetCredits = async (
|
||||
|
||||
export async function loadQuota(file: AuthFile): Promise<QuotaState> {
|
||||
const provider = providerForFile(file);
|
||||
if (!provider) return { status: 'error', rows: [], error: '暂不支持该提供商的配额查询' };
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 'error',
|
||||
rows: [],
|
||||
error: quotaText('quota.service.error.unsupportedProvider'),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const codexAccountId = provider === 'codex' ? await resolveCodexAccountId(file) : '';
|
||||
const payloadPromise = provider === 'xai'
|
||||
@@ -830,7 +867,13 @@ export async function loadQuota(file: AuthFile): Promise<QuotaState> {
|
||||
resetCreditsPromise,
|
||||
]);
|
||||
const rows = quotaRowsFor(provider, payload);
|
||||
if (rows.length === 0) return { status: 'error', rows: [], error: '上游返回了配额数据,但无法识别' };
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
status: 'error',
|
||||
rows: [],
|
||||
error: quotaText('quota.service.error.unrecognized'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'success',
|
||||
rows,
|
||||
@@ -862,10 +905,10 @@ const createRedeemRequestId = () => {
|
||||
|
||||
export async function consumeCodexResetCredit(file: AuthFile): Promise<QuotaState> {
|
||||
if (providerForFile(file) !== 'codex') {
|
||||
throw new Error('只有 Codex 凭据支持使用重置额度');
|
||||
throw new Error(quotaText('quota.service.error.codexResetOnly'));
|
||||
}
|
||||
const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex);
|
||||
if (!authIndex) throw new Error('缺少 auth-index,无法使用重置额度');
|
||||
if (!authIndex) throw new Error(quotaText('quota.service.error.missingConsumeAuthIndex'));
|
||||
const header = {
|
||||
...headersByProvider.codex,
|
||||
};
|
||||
|
||||
+102
@@ -189,6 +189,108 @@ input:focus-visible {
|
||||
border-top: 1px solid #e3e1db;
|
||||
}
|
||||
|
||||
.sidebar-language {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-language-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 14px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8d4cc;
|
||||
border-radius: 8px;
|
||||
background: #fffdf8;
|
||||
color: #716b61;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-language-trigger:hover,
|
||||
.sidebar-language-trigger[aria-expanded='true'] {
|
||||
border-color: #c7c2b8;
|
||||
background: #e8e5dc;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.sidebar-language-trigger span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-language-trigger > svg:last-child {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.sidebar-language-trigger > svg:last-child.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar-language-list {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 5px;
|
||||
border: 1px solid #d8d4cc;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -10px 28px rgba(45, 42, 38, 0.16);
|
||||
}
|
||||
|
||||
.sidebar-language-list button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #5f5a53;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-language-list button:hover {
|
||||
background: #f0eee8;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.sidebar-language-list button.selected {
|
||||
background: #e8e5dc;
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.sidebar-language-list button svg {
|
||||
color: #2d2a26;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sidebar-contact {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 14px;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { normalizeLocale, translate } from '../src/i18n';
|
||||
import { en, ja, zhCN, zhTW } from '../src/i18n/resources';
|
||||
import { jaOverrides } from '../src/i18n/ja';
|
||||
|
||||
describe('i18n', () => {
|
||||
it('normalizes English variants and keeps Chinese as the fallback', () => {
|
||||
expect(normalizeLocale('en-US')).toBe('en');
|
||||
expect(normalizeLocale('en')).toBe('en');
|
||||
expect(normalizeLocale('zh-CN')).toBe('zh-CN');
|
||||
expect(normalizeLocale('zh-TW')).toBe('zh-TW');
|
||||
expect(normalizeLocale('zh-Hant-HK')).toBe('zh-TW');
|
||||
expect(normalizeLocale('ja-JP')).toBe('ja');
|
||||
expect(normalizeLocale('unsupported')).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('translates messages and interpolates variables', () => {
|
||||
expect(translate('zh-CN', 'kernel.install.installingVersion', { version: '1.2.3' }))
|
||||
.toBe('正在安装 1.2.3');
|
||||
expect(translate('en', 'kernel.install.installingVersion', { version: '1.2.3' }))
|
||||
.toBe('Installing 1.2.3');
|
||||
expect(translate('zh-TW', 'kernel.install.installingVersion', { version: '1.2.3' }))
|
||||
.toBe('正在安裝 1.2.3');
|
||||
expect(translate('ja', 'kernel.install.installingVersion', { version: '1.2.3' }))
|
||||
.toBe('1.2.3 をインストールしています');
|
||||
});
|
||||
|
||||
it('keeps both locale dictionaries structurally aligned', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zhCN).sort());
|
||||
expect(Object.keys(zhTW).sort()).toEqual(Object.keys(zhCN).sort());
|
||||
expect(Object.keys(ja).sort()).toEqual(Object.keys(zhCN).sort());
|
||||
expect(Object.keys(jaOverrides).sort()).toEqual(Object.keys(zhCN).sort());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user