From 12d6be331c281b302190d57b0361252c8ae7ade5 Mon Sep 17 00:00:00 2001 From: Linkong Date: Sat, 21 Mar 2026 00:03:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BF=AB=E9=80=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=AD=A6=E7=94=9F=E4=B8=A2=E5=A4=B1=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=87=8D=E7=BD=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/data.rs | 201 ++- src-tauri/src/lib.rs | 771 ++++----- src/components/OOBE/OOBE.tsx | 1948 +++++++++++------------ src/components/Settings.tsx | 2704 ++++++++++++++++---------------- src/i18n/locales/en-US.json | 1602 +++++++++---------- src/i18n/locales/zh-CN.json | 1582 +++++++++---------- src/preload/types.ts | 801 +++++----- 7 files changed, 4916 insertions(+), 4693 deletions(-) diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index 266219e..fbe48b5 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -1,62 +1,183 @@ use parking_lot::RwLock; use std::sync::Arc; -use tauri::State; +use tauri::{AppHandle, Emitter, State}; +use crate::db::connection::{create_sqlite_connection, DatabaseType}; +use crate::db::migration::Migration; +use crate::services::settings::{SettingsKey, SettingsValue, SettingsService}; +use crate::services::{AutoScoreService, ThemeService}; use crate::services::permission::PermissionLevel; use crate::state::AppState; use super::response::IpcResponse; - + fn check_admin_permission(state: &Arc>) -> Result<(), String> { - let state_guard = state.read(); - let mut permissions = state_guard.permissions.write(); - let sender_id = 0; - if !permissions.require_permission(sender_id, PermissionLevel::Admin) { - return Err("Permission denied: Admin required".to_string()); - } + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + let sender_id = 0; + if !permissions.require_permission(sender_id, PermissionLevel::Admin) { + return Err("Permission denied: Admin required".to_string()); + } Ok(()) } -#[tauri::command] -pub async fn data_export_json( - state: State<'_, Arc>>, -) -> Result, String> { - check_admin_permission(&state)?; +fn sqlite_db_path(app_handle: &AppHandle) -> Result { + if cfg!(all(debug_assertions, desktop)) { + return Ok("data.sql".to_string()); + } - let state_guard = state.read(); - let settings = state_guard.settings.read(); - let settings_value = settings.get_all(); - let settings_json = serde_json::to_value(settings_value) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - - let data_service = state_guard.data.read(); - let students: Vec = Vec::new(); - let events: Vec = Vec::new(); - - let export_data = data_service.export_json(settings_json, students, events); - - let json_string = serde_json::to_string_pretty(&export_data) - .map_err(|e| format!("Failed to serialize export data: {}", e))?; - - Ok(IpcResponse::success(json_string)) + let app_data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + let data_dir = app_data_dir.join("data"); + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + let db_path = data_dir.join("data.sql"); + db_path + .to_str() + .map(|s| s.to_string()) + .ok_or_else(|| "Invalid sqlite database path".to_string()) } - -#[tauri::command] + +#[tauri::command] +pub async fn data_export_json( + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let state_guard = state.read(); + let settings = state_guard.settings.read(); + let settings_value = settings.get_all(); + let settings_json = serde_json::to_value(settings_value) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + let data_service = state_guard.data.read(); + let students: Vec = Vec::new(); + let events: Vec = Vec::new(); + + let export_data = data_service.export_json(settings_json, students, events); + + let json_string = serde_json::to_string_pretty(&export_data) + .map_err(|e| format!("Failed to serialize export data: {}", e))?; + + Ok(IpcResponse::success(json_string)) +} + +#[tauri::command] pub async fn data_import_json( json_text: String, state: State<'_, Arc>>, ) -> Result, String> { - check_admin_permission(&state)?; - - let state_guard = state.read(); - let mut data_service = state_guard.data.write(); - let result = data_service.import_json(&json_text); - - if result.success { - Ok(IpcResponse::success_empty()) - } else { + check_admin_permission(&state)?; + + let state_guard = state.read(); + let mut data_service = state_guard.data.write(); + let result = data_service.import_json(&json_text); + + if result.success { + Ok(IpcResponse::success_empty()) + } else { Ok(IpcResponse::error( result.message.as_deref().unwrap_or("Import failed"), )) } } + +#[tauri::command] +pub async fn data_reset_all( + app_handle: AppHandle, + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let sqlite_path = sqlite_db_path(&app_handle)?; + let sqlite_conn = create_sqlite_connection(&sqlite_path) + .await + .map_err(|e| format!("Failed to open sqlite database: {}", e))?; + + let (active_conn, active_db_type) = { + let state_guard = state.read(); + let active_conn = state_guard + .db + .read() + .clone() + .ok_or_else(|| "Database not connected".to_string())?; + let db_type = { + let settings = state_guard.settings.read(); + match settings.get_value(SettingsKey::PgConnectionStatus) { + SettingsValue::Json(json) => match json.get("type").and_then(|value| value.as_str()) { + Some("postgresql") => DatabaseType::PostgreSQL, + _ => DatabaseType::SQLite, + }, + _ => DatabaseType::SQLite, + } + }; + (active_conn, db_type) + }; + + if active_db_type == DatabaseType::PostgreSQL { + Migration::reset_database(&active_conn, false) + .await + .map_err(|e| format!("Failed to reset PostgreSQL database: {}", e))?; + } + + Migration::reset_database(&sqlite_conn, true) + .await + .map_err(|e| format!("Failed to reset SQLite database: {}", e))?; + + { + let state_guard = state.read(); + + { + let mut db = state_guard.db.write(); + *db = Some(sqlite_conn.clone()); + } + + { + let mut settings = state_guard.settings.write(); + *settings = SettingsService::new(); + settings.attach_db(Some(sqlite_conn.clone())); + settings + .initialize() + .await + .map_err(|e| format!("Failed to reinitialize settings: {}", e))?; + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": true, + "type": "sqlite" + })), + ) + .await + .map_err(|e| format!("Failed to restore sqlite status: {}", e))?; + } + + { + let mut permissions = state_guard.permissions.write(); + permissions.clear_all_permissions(); + permissions.update_password_status(false, false); + } + + { + let mut theme = state_guard.theme.write(); + *theme = ThemeService::new(); + } + + { + let mut auto_score = state_guard.auto_score.write(); + *auto_score = AutoScoreService::new(); + } + } + + let _ = app_handle.emit( + "settings:changed", + serde_json::json!({ + "key": "is_wizard_completed", + "value": false + }), + ); + + Ok(IpcResponse::success_empty()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3603139..f2bd729 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,388 +1,389 @@ -pub mod commands; -pub mod db; -pub mod models; -pub mod services; -pub mod state; -pub mod utils; - -use crate::db::connection::DatabaseType; -use crate::db::connection::{create_postgres_connection, create_sqlite_connection}; -use crate::db::migration::run_migration; -use crate::services::settings::{SettingsKey, SettingsValue}; -use crate::{commands::*, state::AppState}; -use parking_lot::RwLock; -use std::sync::Arc; -#[cfg(desktop)] -use tauri::{ - image::Image, - menu::{Menu, MenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - WindowEvent, -}; -use tauri::{App, Manager}; -use tokio::time::{timeout, Duration}; - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - let state = AppState::new(app.handle().clone()); - app.manage(Arc::new(RwLock::new(state))); - setup_app(app)?; - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - student_query, - student_create, - student_update, - student_delete, - student_import_from_xlsx, - tags_get_all, - tags_get_by_student, - tags_create, - tags_delete, - tags_update_student_tags, - reason_query, - reason_create, - reason_update, - reason_delete, - reward_setting_query, - reward_setting_create, - reward_setting_update, - reward_setting_delete, - reward_redeem, - reward_redemption_query, - event_query, - event_create, - event_delete, - event_query_by_student, - leaderboard_query, - db_settlement_query, - db_settlement_create, - db_settlement_leaderboard, - settings_get_all, - settings_get, - settings_set, - auth_get_status, - auth_login, - auth_logout, - auth_set_passwords, - auth_generate_recovery, - auth_reset_by_recovery, - auth_clear_all, - theme_list, - theme_current, - theme_set, - theme_save, - theme_delete, - auto_score_get_rules, - auto_score_add_rule, - auto_score_update_rule, - auto_score_delete_rule, - auto_score_toggle_rule, - auto_score_get_status, - auto_score_sort_rules, - board_query_sql, - log_query, - log_clear, - log_set_level, - log_write, +pub mod commands; +pub mod db; +pub mod models; +pub mod services; +pub mod state; +pub mod utils; + +use crate::db::connection::DatabaseType; +use crate::db::connection::{create_postgres_connection, create_sqlite_connection}; +use crate::db::migration::run_migration; +use crate::services::settings::{SettingsKey, SettingsValue}; +use crate::{commands::*, state::AppState}; +use parking_lot::RwLock; +use std::sync::Arc; +#[cfg(desktop)] +use tauri::{ + image::Image, + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + WindowEvent, +}; +use tauri::{App, Manager}; +use tokio::time::{timeout, Duration}; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .setup(|app| { + let state = AppState::new(app.handle().clone()); + app.manage(Arc::new(RwLock::new(state))); + setup_app(app)?; + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + student_query, + student_create, + student_update, + student_delete, + student_import_from_xlsx, + tags_get_all, + tags_get_by_student, + tags_create, + tags_delete, + tags_update_student_tags, + reason_query, + reason_create, + reason_update, + reason_delete, + reward_setting_query, + reward_setting_create, + reward_setting_update, + reward_setting_delete, + reward_redeem, + reward_redemption_query, + event_query, + event_create, + event_delete, + event_query_by_student, + leaderboard_query, + db_settlement_query, + db_settlement_create, + db_settlement_leaderboard, + settings_get_all, + settings_get, + settings_set, + auth_get_status, + auth_login, + auth_logout, + auth_set_passwords, + auth_generate_recovery, + auth_reset_by_recovery, + auth_clear_all, + theme_list, + theme_current, + theme_set, + theme_save, + theme_delete, + auto_score_get_rules, + auto_score_add_rule, + auto_score_update_rule, + auto_score_delete_rule, + auto_score_toggle_rule, + auto_score_get_status, + auto_score_sort_rules, + board_query_sql, + log_query, + log_clear, + log_set_level, + log_write, data_export_json, data_import_json, + data_reset_all, window_minimize, - window_maximize, - window_close, - window_is_maximized, - toggle_devtools, - window_resize, - window_set_resizable, - db_test_connection, - db_switch_connection, - db_get_status, - db_sync, - db_sync_preview, - db_sync_apply, - fs_get_config_structure, - fs_read_json, - fs_write_json, - fs_read_text, - fs_write_text, - fs_delete_file, - fs_list_files, - fs_file_exists, - http_server_start, - http_server_stop, - http_server_status, - mcp_server_start, - mcp_server_stop, - mcp_server_status, - register_url_protocol, - app_quit, - app_restart, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} - -pub fn setup_app(app: &mut App) -> Result<(), Box> { - setup_database(app)?; - - setup_tray(app)?; - - setup_window_events(app)?; - - Ok(()) -} - -fn setup_database(app: &mut App) -> Result<(), Box> { - const DB_CONNECT_TIMEOUT_SECS: u64 = 15; - const DB_MIGRATION_TIMEOUT_SECS: u64 = 20; - - let handle = app.handle().clone(); - let db_path = if cfg!(all(debug_assertions, desktop)) { - std::path::PathBuf::from("data.sql") - } else { - let app_data_dir = handle - .path() - .app_data_dir() - .map_err(|e| format!("Failed to get app data directory: {}", e))?; - let data_dir = app_data_dir.join("data"); - std::fs::create_dir_all(&data_dir) - .map_err(|e| format!("Failed to create data directory: {}", e))?; - data_dir.join("data.sql") - }; - - let db_path_str = db_path.to_str().ok_or("Invalid database path")?.to_string(); - - let db_result = tauri::async_runtime::block_on(async { - let sqlite_conn = create_sqlite_connection(&db_path_str).await?; - run_migration(&sqlite_conn, DatabaseType::SQLite).await?; - - let state = handle.state::(); - let state_guard = state.write(); - let mut active_conn = sqlite_conn.clone(); - - { - let mut settings = state_guard.settings.write(); - settings.attach_db(Some(sqlite_conn.clone())); - settings - .initialize() - .await - .map_err(|e| format!("Failed to initialize settings from sqlite: {}", e))?; - - let pg_connection_string = match settings.get_value(SettingsKey::PgConnectionString) { - SettingsValue::String(s) => s, - _ => String::new(), - }; - - if !pg_connection_string.trim().is_empty() { - match timeout( - Duration::from_secs(DB_CONNECT_TIMEOUT_SECS), - create_postgres_connection(&pg_connection_string), - ) - .await - { - Err(_) => { - let timeout_message = "PostgreSQL auto-connect timeout".to_string(); - eprintln!( - "PostgreSQL auto-connect timed out on startup, fallback to sqlite" - ); - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": false, - "type": "sqlite", - "error": timeout_message - })), - ) - .await - .map_err(|err| format!("Failed to save pg status: {}", err))?; - } - Ok(Err(e)) => { - eprintln!("PostgreSQL auto-connect failed, fallback to sqlite: {}", e); - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": false, - "type": "sqlite", - "error": e.to_string() - })), - ) - .await - .map_err(|err| format!("Failed to save pg status: {}", err))?; - } - Ok(Ok(pg_conn)) => { - let migration_result = timeout( - Duration::from_secs(DB_MIGRATION_TIMEOUT_SECS), - run_migration(&pg_conn, DatabaseType::PostgreSQL), - ) - .await; - - if let Err(_) = migration_result { - let timeout_message = "PostgreSQL migration timeout".to_string(); - eprintln!( - "PostgreSQL migration timed out on startup, fallback to sqlite" - ); - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": false, - "type": "sqlite", - "error": timeout_message - })), - ) - .await - .map_err(|err| format!("Failed to save pg status: {}", err))?; - } else if let Ok(Err(e)) = migration_result { - eprintln!( - "PostgreSQL migration failed on startup, fallback to sqlite: {}", - e - ); - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": false, - "type": "sqlite", - "error": e.to_string() - })), - ) - .await - .map_err(|err| format!("Failed to save pg status: {}", err))?; - } else { - active_conn = pg_conn; - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": true, - "type": "postgresql" - })), - ) - .await - .map_err(|err| format!("Failed to save pg status: {}", err))?; - eprintln!("Auto connected to PostgreSQL from saved connection string"); - } - } - } - } else { - settings - .set_value( - SettingsKey::PgConnectionStatus, - SettingsValue::Json(serde_json::json!({ - "connected": true, - "type": "sqlite" - })), - ) - .await - .map_err(|err| format!("Failed to save sqlite status: {}", err))?; - } - } - - { - let mut db_guard = state_guard.db.write(); - *db_guard = Some(active_conn); - } - - Ok::<_, Box>(()) - }); - - if let Err(e) = db_result { - eprintln!("Failed to connect to database: {}", e); - } else { - eprintln!( - "Database bootstrap completed, sqlite settings db: {}", - db_path_str - ); - } - - Ok(()) -} - -#[cfg(desktop)] -fn setup_tray(app: &mut App) -> Result<(), Box> { - let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; - let hide_item = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - - let menu = Menu::with_items(app, &[&show_item, &hide_item, &quit_item])?; - - let _tray = TrayIconBuilder::new() - .icon(Image::from_bytes(include_bytes!("../icons/icon.png"))?) - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - "hide" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.hide(); - } - } - "quit" => { - app.exit(0); - } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } - } - }) - .build(app)?; - - Ok(()) -} - -#[cfg(not(desktop))] -fn setup_tray(_app: &mut App) -> Result<(), Box> { - Ok(()) -} - -#[cfg(desktop)] -fn setup_window_events(app: &mut App) -> Result<(), Box> { - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { - width: 1180.0, - height: 680.0, - })); - let _ = window.center(); - - #[cfg(target_os = "macos")] - { - let _ = window.set_shadow(true); - } - - let window_clone = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = window_clone.hide(); - } - }); - } - - Ok(()) -} - -#[cfg(not(desktop))] -fn setup_window_events(_app: &mut App) -> Result<(), Box> { - Ok(()) -} + window_maximize, + window_close, + window_is_maximized, + toggle_devtools, + window_resize, + window_set_resizable, + db_test_connection, + db_switch_connection, + db_get_status, + db_sync, + db_sync_preview, + db_sync_apply, + fs_get_config_structure, + fs_read_json, + fs_write_json, + fs_read_text, + fs_write_text, + fs_delete_file, + fs_list_files, + fs_file_exists, + http_server_start, + http_server_stop, + http_server_status, + mcp_server_start, + mcp_server_stop, + mcp_server_status, + register_url_protocol, + app_quit, + app_restart, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +pub fn setup_app(app: &mut App) -> Result<(), Box> { + setup_database(app)?; + + setup_tray(app)?; + + setup_window_events(app)?; + + Ok(()) +} + +fn setup_database(app: &mut App) -> Result<(), Box> { + const DB_CONNECT_TIMEOUT_SECS: u64 = 15; + const DB_MIGRATION_TIMEOUT_SECS: u64 = 20; + + let handle = app.handle().clone(); + let db_path = if cfg!(all(debug_assertions, desktop)) { + std::path::PathBuf::from("data.sql") + } else { + let app_data_dir = handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + let data_dir = app_data_dir.join("data"); + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + data_dir.join("data.sql") + }; + + let db_path_str = db_path.to_str().ok_or("Invalid database path")?.to_string(); + + let db_result = tauri::async_runtime::block_on(async { + let sqlite_conn = create_sqlite_connection(&db_path_str).await?; + run_migration(&sqlite_conn, DatabaseType::SQLite).await?; + + let state = handle.state::(); + let state_guard = state.write(); + let mut active_conn = sqlite_conn.clone(); + + { + let mut settings = state_guard.settings.write(); + settings.attach_db(Some(sqlite_conn.clone())); + settings + .initialize() + .await + .map_err(|e| format!("Failed to initialize settings from sqlite: {}", e))?; + + let pg_connection_string = match settings.get_value(SettingsKey::PgConnectionString) { + SettingsValue::String(s) => s, + _ => String::new(), + }; + + if !pg_connection_string.trim().is_empty() { + match timeout( + Duration::from_secs(DB_CONNECT_TIMEOUT_SECS), + create_postgres_connection(&pg_connection_string), + ) + .await + { + Err(_) => { + let timeout_message = "PostgreSQL auto-connect timeout".to_string(); + eprintln!( + "PostgreSQL auto-connect timed out on startup, fallback to sqlite" + ); + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": false, + "type": "sqlite", + "error": timeout_message + })), + ) + .await + .map_err(|err| format!("Failed to save pg status: {}", err))?; + } + Ok(Err(e)) => { + eprintln!("PostgreSQL auto-connect failed, fallback to sqlite: {}", e); + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": false, + "type": "sqlite", + "error": e.to_string() + })), + ) + .await + .map_err(|err| format!("Failed to save pg status: {}", err))?; + } + Ok(Ok(pg_conn)) => { + let migration_result = timeout( + Duration::from_secs(DB_MIGRATION_TIMEOUT_SECS), + run_migration(&pg_conn, DatabaseType::PostgreSQL), + ) + .await; + + if let Err(_) = migration_result { + let timeout_message = "PostgreSQL migration timeout".to_string(); + eprintln!( + "PostgreSQL migration timed out on startup, fallback to sqlite" + ); + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": false, + "type": "sqlite", + "error": timeout_message + })), + ) + .await + .map_err(|err| format!("Failed to save pg status: {}", err))?; + } else if let Ok(Err(e)) = migration_result { + eprintln!( + "PostgreSQL migration failed on startup, fallback to sqlite: {}", + e + ); + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": false, + "type": "sqlite", + "error": e.to_string() + })), + ) + .await + .map_err(|err| format!("Failed to save pg status: {}", err))?; + } else { + active_conn = pg_conn; + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": true, + "type": "postgresql" + })), + ) + .await + .map_err(|err| format!("Failed to save pg status: {}", err))?; + eprintln!("Auto connected to PostgreSQL from saved connection string"); + } + } + } + } else { + settings + .set_value( + SettingsKey::PgConnectionStatus, + SettingsValue::Json(serde_json::json!({ + "connected": true, + "type": "sqlite" + })), + ) + .await + .map_err(|err| format!("Failed to save sqlite status: {}", err))?; + } + } + + { + let mut db_guard = state_guard.db.write(); + *db_guard = Some(active_conn); + } + + Ok::<_, Box>(()) + }); + + if let Err(e) = db_result { + eprintln!("Failed to connect to database: {}", e); + } else { + eprintln!( + "Database bootstrap completed, sqlite settings db: {}", + db_path_str + ); + } + + Ok(()) +} + +#[cfg(desktop)] +fn setup_tray(app: &mut App) -> Result<(), Box> { + let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; + let hide_item = MenuItem::with_id(app, "hide", "隐藏窗口", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + + let menu = Menu::with_items(app, &[&show_item, &hide_item, &quit_item])?; + + let _tray = TrayIconBuilder::new() + .icon(Image::from_bytes(include_bytes!("../icons/icon.png"))?) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "hide" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + Ok(()) +} + +#[cfg(not(desktop))] +fn setup_tray(_app: &mut App) -> Result<(), Box> { + Ok(()) +} + +#[cfg(desktop)] +fn setup_window_events(app: &mut App) -> Result<(), Box> { + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 1180.0, + height: 680.0, + })); + let _ = window.center(); + + #[cfg(target_os = "macos")] + { + let _ = window.set_shadow(true); + } + + let window_clone = window.clone(); + window.on_window_event(move |event| { + if let WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window_clone.hide(); + } + }); + } + + Ok(()) +} + +#[cfg(not(desktop))] +fn setup_window_events(_app: &mut App) -> Result<(), Box> { + Ok(()) +} diff --git a/src/components/OOBE/OOBE.tsx b/src/components/OOBE/OOBE.tsx index 0a8a70e..d212336 100644 --- a/src/components/OOBE/OOBE.tsx +++ b/src/components/OOBE/OOBE.tsx @@ -1,1013 +1,1035 @@ -import React, { useState, useRef, useCallback, useEffect } from "react" -import { useTranslation } from "react-i18next" -import { Button, Segmented, Input, Tag, message, Typography, InputNumber, Space } from "antd" -import { PlusOutlined, UploadOutlined, FileExcelOutlined } from "@ant-design/icons" -import { OOBEBackground } from "./OOBEBackground" -import { useTheme } from "../../contexts/ThemeContext" -import { changeLanguage, AppLanguage, languageOptions } from "../../i18n" -import type { themeConfig } from "../../preload/types" -import logoSvg from "../../assets/logoHD.svg" - -interface oobeProps { - visible: boolean - onComplete: () => void -} - -type oobeStep = - | "entry" - | "postgresql" - | "language" - | "theme" - | "password" - | "students" - | "reasons" - | "start" - -interface studentItem { - name: string -} - +import React, { useState, useRef, useCallback, useEffect } from "react" +import { useTranslation } from "react-i18next" +import { Button, Segmented, Input, Tag, message, Typography, InputNumber, Space } from "antd" +import { PlusOutlined, UploadOutlined, FileExcelOutlined } from "@ant-design/icons" +import { OOBEBackground } from "./OOBEBackground" +import { useTheme } from "../../contexts/ThemeContext" +import { changeLanguage, AppLanguage, languageOptions } from "../../i18n" +import type { themeConfig } from "../../preload/types" +import logoSvg from "../../assets/logoHD.svg" + +interface oobeProps { + visible: boolean + onComplete: () => void +} + +type oobeStep = + | "entry" + | "postgresql" + | "language" + | "theme" + | "password" + | "students" + | "reasons" + | "start" + +interface studentItem { + name: string +} + interface reasonItem { content: string delta: number } -const deepClone = (v: T): T => JSON.parse(JSON.stringify(v)) as T +const mergeStudents = (prev: studentItem[], names: string[]): studentItem[] => { + const existing = new Set(prev.map((item) => item.name)) + const next = [...prev] -const withTimeout = async (promise: Promise, ms: number, timeoutMessage: string): Promise => { - let timer: ReturnType | undefined - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(timeoutMessage)), ms) - }), - ]) - } finally { - if (timer) clearTimeout(timer) - } + names.forEach((rawName) => { + const name = rawName.trim() + if (!name || existing.has(name)) return + next.push({ name }) + existing.add(name) + }) + + return next } -const presetPrimaryColors = [ - "#1677FF", - "#2F54EB", - "#722ED1", - "#EB2F96", - "#F5222D", - "#FA8C16", - "#FADB14", - "#52C41A", - "#13C2C2", -] - -const presetGradients: { - id: string - labelKey: string - light: string - dark: string -}[] = [ - { - id: "blue", - labelKey: "blue", - light: "linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)", - dark: "linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)", - }, - { - id: "pink", - labelKey: "pink", - light: "linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)", - dark: "linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)", - }, - { - id: "cyan", - labelKey: "cyan", - light: "linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)", - dark: "linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)", - }, - { - id: "purple", - labelKey: "purple", - light: "linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)", - dark: "linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)", - }, -] - -export const OOBE: React.FC = ({ visible, onComplete }) => { - const { t } = useTranslation() - const { currentTheme, setTheme, themes, applyTheme } = useTheme() - const [messageApi, contextHolder] = message.useMessage() - - const [currentStep, setCurrentStep] = useState("entry") - const [loading, setLoading] = useState(false) - const [pgAutoLoading, setPgAutoLoading] = useState(false) - - const [selectedLanguage, setSelectedLanguage] = useState("zh-CN") - const [workingTheme, setWorkingTheme] = useState(null) - const [primaryInput, setPrimaryInput] = useState("") - const [students, setStudents] = useState([]) - const [newStudentName, setNewStudentName] = useState("") - const [reasons, setReasons] = useState([]) - const [newReasonContent, setNewReasonContent] = useState("") - const [newReasonDelta, setNewReasonDelta] = useState(1) - const [adminPassword, setAdminPassword] = useState("") - const [pointsPassword, setPointsPassword] = useState("") - const [pgConnectionString, setPgConnectionString] = useState("") - - const fileInputRef = useRef(null) - - const steps: oobeStep[] = ["entry", "language", "theme", "password", "students", "reasons", "start"] - const stepIndex = steps.indexOf(currentStep) + 1 - const totalSteps = steps.length - - const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF" - const isDark = workingTheme?.mode === "dark" - - const showOobeMessage = ( - type: "success" | "error" | "warning" | "info", - content: string - ) => { - messageApi.open({ - type, - content, - style: { - marginTop: 8, - zIndex: 12000, - }, - }) +const mergeReasons = (prev: reasonItem[], nextReason: reasonItem): reasonItem[] => { + if (prev.some((item) => item.content === nextReason.content)) { + return prev } - - useEffect(() => { - if (!currentTheme) return - const base = deepClone(currentTheme) - const editable = - base.id === "custom-default" || base.id.startsWith("custom-") - ? base - : { ...base, id: "custom-default", name: t("theme.myTheme") } - setWorkingTheme(editable) - setPrimaryInput(editable.config?.tdesign?.brandColor || "") - }, [currentTheme]) - - useEffect(() => { - if (!workingTheme) return - applyTheme(workingTheme) - }, [workingTheme, applyTheme]) - - const saveThemeToDb = async (theme: themeConfig) => { - if (!(window as any).api) return false - try { - const exists = themes.some((t) => t.id === theme.id) - if (!exists) { - const res = await (window as any).api.saveTheme(theme) - if (!res?.success) return false - } - if (currentTheme?.id !== theme.id) { - await setTheme(theme.id) - } - const res = await (window as any).api.saveTheme(theme) - return res?.success - } catch { - return false - } - } - - const setPrimary = async (hex: string) => { - setPrimaryInput(hex) - setWorkingTheme((prev: themeConfig | null) => { - if (!prev) return prev - const next = deepClone(prev) - next.config = next.config || ({} as any) - next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex } - saveThemeToDb(next) - return next - }) - } - - const setGradientPreset = async (value: string) => { - setWorkingTheme((prev: themeConfig | null) => { - if (!prev) return prev - const next = deepClone(prev) - next.config = next.config || ({} as any) - next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": value } - saveThemeToDb(next) - return next - }) - } - - const setMode = async (mode: "light" | "dark") => { - const targetThemeId = mode === "light" ? "light-default" : "dark-default" - await setTheme(targetThemeId) - } - - const handleLanguageChange = async (lang: AppLanguage) => { - setSelectedLanguage(lang) - await changeLanguage(lang) - } - + return [...prev, nextReason] +} + +const deepClone = (v: T): T => JSON.parse(JSON.stringify(v)) as T + +const withTimeout = async (promise: Promise, ms: number, timeoutMessage: string): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), ms) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +const presetPrimaryColors = [ + "#1677FF", + "#2F54EB", + "#722ED1", + "#EB2F96", + "#F5222D", + "#FA8C16", + "#FADB14", + "#52C41A", + "#13C2C2", +] + +const presetGradients: { + id: string + labelKey: string + light: string + dark: string +}[] = [ + { + id: "blue", + labelKey: "blue", + light: "linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)", + dark: "linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)", + }, + { + id: "pink", + labelKey: "pink", + light: "linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)", + dark: "linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)", + }, + { + id: "cyan", + labelKey: "cyan", + light: "linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)", + dark: "linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)", + }, + { + id: "purple", + labelKey: "purple", + light: "linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)", + dark: "linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)", + }, +] + +export const OOBE: React.FC = ({ visible, onComplete }) => { + const { t } = useTranslation() + const { currentTheme, setTheme, themes, applyTheme } = useTheme() + const [messageApi, contextHolder] = message.useMessage() + + const [currentStep, setCurrentStep] = useState("entry") + const [loading, setLoading] = useState(false) + const [pgAutoLoading, setPgAutoLoading] = useState(false) + + const [selectedLanguage, setSelectedLanguage] = useState("zh-CN") + const [workingTheme, setWorkingTheme] = useState(null) + const [primaryInput, setPrimaryInput] = useState("") + const [students, setStudents] = useState([]) + const [newStudentName, setNewStudentName] = useState("") + const [reasons, setReasons] = useState([]) + const [newReasonContent, setNewReasonContent] = useState("") + const [newReasonDelta, setNewReasonDelta] = useState(1) + const [adminPassword, setAdminPassword] = useState("") + const [pointsPassword, setPointsPassword] = useState("") + const [pgConnectionString, setPgConnectionString] = useState("") + + const fileInputRef = useRef(null) + + const steps: oobeStep[] = ["entry", "language", "theme", "password", "students", "reasons", "start"] + const stepIndex = steps.indexOf(currentStep) + 1 + const totalSteps = steps.length + + const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF" + const isDark = workingTheme?.mode === "dark" + + const showOobeMessage = ( + type: "success" | "error" | "warning" | "info", + content: string + ) => { + messageApi.open({ + type, + content, + style: { + marginTop: 8, + zIndex: 12000, + }, + }) + } + + useEffect(() => { + if (!currentTheme) return + const base = deepClone(currentTheme) + const editable = + base.id === "custom-default" || base.id.startsWith("custom-") + ? base + : { ...base, id: "custom-default", name: t("theme.myTheme") } + setWorkingTheme(editable) + setPrimaryInput(editable.config?.tdesign?.brandColor || "") + }, [currentTheme]) + + useEffect(() => { + if (!workingTheme) return + applyTheme(workingTheme) + }, [workingTheme, applyTheme]) + + const saveThemeToDb = async (theme: themeConfig) => { + if (!(window as any).api) return false + try { + const exists = themes.some((t) => t.id === theme.id) + if (!exists) { + const res = await (window as any).api.saveTheme(theme) + if (!res?.success) return false + } + if (currentTheme?.id !== theme.id) { + await setTheme(theme.id) + } + const res = await (window as any).api.saveTheme(theme) + return res?.success + } catch { + return false + } + } + + const setPrimary = async (hex: string) => { + setPrimaryInput(hex) + setWorkingTheme((prev: themeConfig | null) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex } + saveThemeToDb(next) + return next + }) + } + + const setGradientPreset = async (value: string) => { + setWorkingTheme((prev: themeConfig | null) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": value } + saveThemeToDb(next) + return next + }) + } + + const setMode = async (mode: "light" | "dark") => { + const targetThemeId = mode === "light" ? "light-default" : "dark-default" + await setTheme(targetThemeId) + } + + const handleLanguageChange = async (lang: AppLanguage) => { + setSelectedLanguage(lang) + await changeLanguage(lang) + } + const addStudent = () => { const input = newStudentName.trim() if (!input) return - - const lines = input - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - if (lines.length === 0) return - + + const lines = input + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (lines.length === 0) return + const existing = new Set(students.map((s) => s.name)) const added = new Set() - const newStudents: studentItem[] = [] + const newStudentNames: string[] = [] lines.forEach((name) => { if (existing.has(name) || added.has(name)) return - newStudents.push({ name }) + newStudentNames.push(name) added.add(name) }) - if (newStudents.length === 0) { + if (newStudentNames.length === 0) { showOobeMessage("warning", t("oobe.steps.students.studentExists")) return } - setStudents([...students, ...newStudents]) + setStudents((prev) => mergeStudents(prev, newStudentNames)) setNewStudentName("") } const removeStudent = (name: string) => { - setStudents(students.filter((s) => s.name !== name)) + setStudents((prev) => prev.filter((s) => s.name !== name)) } const addReason = () => { const content = newReasonContent.trim() if (!content) return - if (reasons.some((r) => r.content === content)) { - messageApi.warning(t("oobe.steps.reasons.reasonExists")) - return - } - setReasons([...reasons, { content, delta: newReasonDelta }]) + if (reasons.some((r) => r.content === content)) { + messageApi.warning(t("oobe.steps.reasons.reasonExists")) + return + } + setReasons((prev) => mergeReasons(prev, { content, delta: newReasonDelta })) setNewReasonContent("") setNewReasonDelta(1) } const removeReason = (content: string) => { - setReasons(reasons.filter((r) => r.content !== content)) + setReasons((prev) => prev.filter((r) => r.content !== content)) } - - const handleFileImport = useCallback( - async (file: File) => { - const ext = file.name.split(".").pop()?.toLowerCase() - if (ext === "json") { - try { - const text = await file.text() - const data = JSON.parse(text) - let studentList: string[] = [] - if (Array.isArray(data)) { - studentList = data.map((item: any) => - typeof item === "string" ? item : item.name || item.student_name - ) - } else if (data.students && Array.isArray(data.students)) { - studentList = data.students.map((item: any) => - typeof item === "string" ? item : item.name || item.student_name - ) - } - const newStudents = studentList + + const handleFileImport = useCallback( + async (file: File) => { + const ext = file.name.split(".").pop()?.toLowerCase() + if (ext === "json") { + try { + const text = await file.text() + const data = JSON.parse(text) + let studentList: string[] = [] + if (Array.isArray(data)) { + studentList = data.map((item: any) => + typeof item === "string" ? item : item.name || item.student_name + ) + } else if (data.students && Array.isArray(data.students)) { + studentList = data.students.map((item: any) => + typeof item === "string" ? item : item.name || item.student_name + ) + } + const newStudentNames = studentList .filter((name) => name && typeof name === "string") - .filter((name) => !students.some((s) => s.name === name)) - .map((name) => ({ name: name.trim() })) - if (newStudents.length > 0) { - setStudents([...students, ...newStudents]) + .map((name) => name.trim()) + .filter(Boolean) + const mergedStudents = mergeStudents(students, newStudentNames) + const importedCount = mergedStudents.length - students.length + if (importedCount > 0) { + setStudents((prev) => mergeStudents(prev, newStudentNames)) showOobeMessage( "success", - t("oobe.steps.students.importSuccess", { count: newStudents.length }) + t("oobe.steps.students.importSuccess", { count: importedCount }) ) } else { showOobeMessage("info", t("oobe.steps.students.noNewStudents")) - } - } catch { - showOobeMessage("error", t("oobe.steps.students.parseFailed")) - } - } else if (ext === "xlsx") { - try { - const { read, utils } = await import("xlsx") - const arrayBuffer = await file.arrayBuffer() - const workbook = read(arrayBuffer, { type: "array" }) - const firstSheet = workbook.Sheets[workbook.SheetNames[0]] - const jsonData = utils.sheet_to_json(firstSheet, { header: 1 }) as string[][] - const names: string[] = [] - jsonData.forEach((row, idx) => { - if (idx === 0) return - const name = row[0] - if (name && typeof name === "string") { - names.push(name.trim()) - } - }) - const newStudents = names - .filter((name) => !students.some((s) => s.name === name)) - .map((name) => ({ name })) - if (newStudents.length > 0) { - setStudents([...students, ...newStudents]) + } + } catch { + showOobeMessage("error", t("oobe.steps.students.parseFailed")) + } + } else if (ext === "xlsx") { + try { + const { read, utils } = await import("xlsx") + const arrayBuffer = await file.arrayBuffer() + const workbook = read(arrayBuffer, { type: "array" }) + const firstSheet = workbook.Sheets[workbook.SheetNames[0]] + const jsonData = utils.sheet_to_json(firstSheet, { header: 1 }) as string[][] + const names: string[] = [] + jsonData.forEach((row, idx) => { + if (idx === 0) return + const name = row[0] + if (name && typeof name === "string") { + names.push(name.trim()) + } + }) + const mergedStudents = mergeStudents(students, names) + const importedCount = mergedStudents.length - students.length + if (importedCount > 0) { + setStudents((prev) => mergeStudents(prev, names)) showOobeMessage( "success", - t("oobe.steps.students.importSuccess", { count: newStudents.length }) + t("oobe.steps.students.importSuccess", { count: importedCount }) ) } else { showOobeMessage("info", t("oobe.steps.students.noNewStudents")) - } - } catch { - showOobeMessage("error", t("oobe.steps.students.parseFailed")) - } - } else { - showOobeMessage("error", t("oobe.steps.students.unsupportedFormat")) - } - }, - [students, messageApi] - ) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - const file = e.dataTransfer.files[0] - if (file) handleFileImport(file) - }, - [handleFileImport] - ) - - const handleNext = () => { - const idx = steps.indexOf(currentStep) - if (idx < steps.length - 1) { - setCurrentStep(steps[idx + 1]) - } - } - - const handlePrev = () => { - const idx = steps.indexOf(currentStep) - if (idx > 0) { - setCurrentStep(steps[idx - 1]) - } - } - - const handleSkip = () => { - handleNext() - } - - const markWizardCompleted = async () => { - if (!(window as any).api) throw new Error("api not ready") - const res = await (window as any).api.setSetting("is_wizard_completed", true) - if (!res?.success) throw new Error("failed") - } - - const handleSkipToApp = async () => { - setLoading(true) - try { - await markWizardCompleted() - showOobeMessage("success", t("common.success")) - onComplete() - } catch (e: any) { - showOobeMessage("error", e?.message || t("common.error")) - } finally { - setLoading(false) - } - } - - const handleConnectPgAndSync = async () => { - const connectionString = pgConnectionString.trim() - if (!connectionString) { - showOobeMessage("warning", t("settings.database.enterConnectionString")) - return - } - setPgAutoLoading(true) - try { - if (!(window as any).api) throw new Error("api not ready") - const switchRes = await withTimeout( - (window as any).api.dbSwitchConnection(connectionString), - 25_000, - "连接 PostgreSQL 超时,请检查网络或连接字符串" - ) - if (!switchRes?.success || switchRes?.data?.type !== "postgresql") { - throw new Error(switchRes?.message || t("settings.database.switchFailed")) - } - - const previewRes = await withTimeout( - (window as any).api.dbSyncPreview(), - 25_000, - "同步预检查超时,请稍后重试" - ) - if (!previewRes?.success || !previewRes?.data?.can_sync) { - throw new Error(previewRes?.message || previewRes?.data?.message || t("settings.database.uploadFailed")) - } - - if (previewRes?.data?.need_sync) { - const syncApplyRes = await withTimeout( - (window as any).api.dbSyncApply("keep_local"), - 45_000, - "执行同步超时,请稍后重试" - ) - if (!syncApplyRes?.success || !syncApplyRes?.data?.success) { - throw new Error( - syncApplyRes?.data?.message || syncApplyRes?.message || t("settings.database.uploadFailed") - ) - } - } - - await markWizardCompleted() - showOobeMessage("success", t("settings.database.uploadSuccess")) - onComplete() - } catch (e: any) { - showOobeMessage("error", e?.message || t("settings.database.uploadFailed")) - } finally { - setPgAutoLoading(false) - } - } - - const handleFinish = async () => { - setLoading(true) - try { - if (!(window as any).api) throw new Error("api not ready") - const ensureSuccess = (res: any, fallbackMessage: string) => { - if (!res?.success) { - throw new Error(res?.message || fallbackMessage) - } - } - - const syncRes = await (window as any).api.dbSync() - ensureSuccess(syncRes, t("common.error")) - if (!syncRes?.data?.success) { - throw new Error(syncRes?.data?.message || t("common.error")) - } - - if (workingTheme) { - const exists = themes.some((t) => t.id === workingTheme.id) - if (!exists) { - const saveRes = await (window as any).api.saveTheme(workingTheme) - ensureSuccess(saveRes, t("common.error")) - } - if (currentTheme?.id !== workingTheme.id) { - await setTheme(workingTheme.id) - } - } - - for (const student of students) { - const createStudentRes = await (window as any).api.createStudent({ name: student.name }) - ensureSuccess(createStudentRes, t("common.error")) - } - - for (const reason of reasons) { - const createReasonRes = await (window as any).api.createReason({ - content: reason.content, - category: t("reasons.others"), - delta: reason.delta, - }) - ensureSuccess(createReasonRes, t("common.error")) - } - - if (adminPassword || pointsPassword) { - const authRes = await (window as any).api.authSetPasswords({ - adminPassword: adminPassword || null, - pointsPassword: pointsPassword || null, - }) - ensureSuccess(authRes, t("common.error")) - } - - await markWizardCompleted() - - showOobeMessage("success", t("common.success")) - onComplete() - } catch (e: any) { - showOobeMessage("error", e?.message || t("common.error")) - } finally { - setLoading(false) - } - } - - if (!visible) return null - - const currentBg = workingTheme?.config?.custom?.["--ss-bg-color"] || "" - - const renderStepContent = () => { - switch (currentStep) { - case "entry": - return ( -
- {t("oobe.steps.entry.description")} - - - -
- ) - - case "postgresql": - return ( -
- - {t("oobe.steps.postgresql.description")} - - setPgConnectionString(e.target.value)} - placeholder={t("oobe.steps.postgresql.connectionPlaceholder")} - /> - - {t("settings.database.connectionExample")} - -
- - -
-
- ) - - case "language": - return ( -
- - {t("oobe.steps.language.description")} - - handleLanguageChange(v as AppLanguage)} - options={languageOptions.map((opt) => ({ - value: opt.value, - label: opt.label, - }))} - block - /> -
- ) - - case "theme": - return ( -
- {t("oobe.steps.theme.description")} -
- {t("oobe.steps.theme.mode")} - setMode(v as any)} - options={[ - { label: t("oobe.steps.theme.lightMode"), value: "light" }, - { label: t("oobe.steps.theme.darkMode"), value: "dark" }, - ]} - /> -
-
- {t("oobe.steps.theme.primaryColor")} -
- setPrimaryInput(e.target.value)} - onBlur={() => { - const hex = primaryInput.trim() - if (/^#?[0-9a-fA-F]{6}$/.test(hex.replace("#", ""))) { - setPrimary(hex.startsWith("#") ? hex : `#${hex}`) - } - }} - style={{ width: 100 }} - /> - {presetPrimaryColors.slice(0, 6).map((c) => ( -
-
-
- {t("oobe.steps.theme.backgroundGradient")} -
- {presetGradients.map((g) => { - const gradientValue = g[workingTheme?.mode || "light"] - const active = currentBg === gradientValue - return ( -
-
-
- ) - - case "password": - return ( -
- - {t("oobe.steps.password.description")} - - -
- {t("oobe.steps.password.adminPassword")} - - {t("oobe.steps.password.adminPasswordHint")} - - setAdminPassword(e.target.value)} - placeholder={t("oobe.steps.password.passwordPlaceholder")} - maxLength={6} - style={{ marginTop: 8 }} - /> -
-
- {t("oobe.steps.password.pointsPassword")} - - {t("oobe.steps.password.pointsPasswordHint")} - - setPointsPassword(e.target.value)} - placeholder={t("oobe.steps.password.passwordPlaceholder")} - maxLength={6} - style={{ marginTop: 8 }} - /> -
- - {t("oobe.steps.password.hint")} - -
-
- ) - - case "students": - return ( -
- - {t("oobe.steps.students.description")} - -
- - { - const file = e.target.files?.[0] - if (file) handleFileImport(file) - if (fileInputRef.current) fileInputRef.current.value = "" - }} - /> -
-
e.preventDefault()} - style={{ - border: isDark ? "1px dashed rgba(255,255,255,0.3)" : "1px dashed rgba(0,0,0,0.2)", - borderRadius: 8, - padding: 16, - textAlign: "center", - color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)", - }} - > - -
{t("oobe.steps.students.dragDrop")}
-
- {t("oobe.steps.students.supportedFormats")} -
-
-
- setNewStudentName(e.target.value)} - placeholder={t("oobe.steps.students.studentName")} - autoSize={{ minRows: 2, maxRows: 5 }} - /> - -
- - {t("oobe.steps.students.manualHint")} - -
- {students.length === 0 ? ( - - {t("oobe.steps.students.noStudents")} - - ) : ( - students.map((s) => ( - removeStudent(s.name)} - style={{ margin: 2 }} - > - {s.name} - - )) - )} -
- {students.length > 0 && ( - - {t("oobe.steps.students.studentCount", { count: students.length })} - - )} -
- ) - - case "reasons": - return ( -
- - {t("oobe.steps.reasons.description")} - -
- setNewReasonContent(e.target.value)} - placeholder={t("oobe.steps.reasons.reasonName")} - style={{ flex: 1 }} - /> - setNewReasonDelta(v || 0)} - min={-999} - max={999} - style={{ width: 80 }} - /> - -
-
- {reasons.length === 0 ? ( - - {t("oobe.steps.reasons.noReasons")} - - ) : ( - reasons.map((r) => ( - removeReason(r.content)} - color={r.delta > 0 ? "success" : "error"} - style={{ margin: 2 }} - > - {r.content} ({r.delta > 0 ? "+" : ""} - {r.delta}) - - )) - )} -
- {reasons.length > 0 && ( - - {t("oobe.steps.reasons.reasonCount", { count: reasons.length })} - - )} -
- ) - - case "start": - return ( -
- - {t("oobe.steps.start.description")} - -
-
- - - {t("oobe.steps.start.features.score")} - -
-
- - - {t("oobe.steps.start.features.history")} - -
-
- - - {t("oobe.steps.start.features.settlement")} - -
-
-
- ) - } - } - - return ( -
- - -
- {contextHolder} - -
- - {t("oobe.title")} - - - {t("oobe.subtitle")} - -
- -
- - {t(`oobe.steps.${currentStep}.title`)} - -
- -
{renderStepContent()}
- - {currentStep !== "entry" && currentStep !== "postgresql" && ( -
-
- {currentStep !== "language" && } - {currentStep !== "start" && } -
- -
-
-
-
- {t("oobe.step", { current: stepIndex, total: totalSteps })} -
- -
- {currentStep !== "start" ? ( - - ) : ( - - )} -
-
- )} -
-
- ) -} + } + } catch { + showOobeMessage("error", t("oobe.steps.students.parseFailed")) + } + } else { + showOobeMessage("error", t("oobe.steps.students.unsupportedFormat")) + } + }, + [students, messageApi] + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + if (file) handleFileImport(file) + }, + [handleFileImport] + ) + + const handleNext = () => { + const idx = steps.indexOf(currentStep) + if (idx < steps.length - 1) { + setCurrentStep(steps[idx + 1]) + } + } + + const handlePrev = () => { + const idx = steps.indexOf(currentStep) + if (idx > 0) { + setCurrentStep(steps[idx - 1]) + } + } + + const handleSkip = () => { + handleNext() + } + + const markWizardCompleted = async () => { + if (!(window as any).api) throw new Error("api not ready") + const res = await (window as any).api.setSetting("is_wizard_completed", true) + if (!res?.success) throw new Error("failed") + } + + const handleSkipToApp = async () => { + setLoading(true) + try { + await markWizardCompleted() + showOobeMessage("success", t("common.success")) + onComplete() + } catch (e: any) { + showOobeMessage("error", e?.message || t("common.error")) + } finally { + setLoading(false) + } + } + + const handleConnectPgAndSync = async () => { + const connectionString = pgConnectionString.trim() + if (!connectionString) { + showOobeMessage("warning", t("settings.database.enterConnectionString")) + return + } + setPgAutoLoading(true) + try { + if (!(window as any).api) throw new Error("api not ready") + const switchRes = await withTimeout( + (window as any).api.dbSwitchConnection(connectionString), + 25_000, + "连接 PostgreSQL 超时,请检查网络或连接字符串" + ) + if (!switchRes?.success || switchRes?.data?.type !== "postgresql") { + throw new Error(switchRes?.message || t("settings.database.switchFailed")) + } + + const previewRes = await withTimeout( + (window as any).api.dbSyncPreview(), + 25_000, + "同步预检查超时,请稍后重试" + ) + if (!previewRes?.success || !previewRes?.data?.can_sync) { + throw new Error(previewRes?.message || previewRes?.data?.message || t("settings.database.uploadFailed")) + } + + if (previewRes?.data?.need_sync) { + const syncApplyRes = await withTimeout( + (window as any).api.dbSyncApply("keep_local"), + 45_000, + "执行同步超时,请稍后重试" + ) + if (!syncApplyRes?.success || !syncApplyRes?.data?.success) { + throw new Error( + syncApplyRes?.data?.message || syncApplyRes?.message || t("settings.database.uploadFailed") + ) + } + } + + await markWizardCompleted() + showOobeMessage("success", t("settings.database.uploadSuccess")) + onComplete() + } catch (e: any) { + showOobeMessage("error", e?.message || t("settings.database.uploadFailed")) + } finally { + setPgAutoLoading(false) + } + } + + const handleFinish = async () => { + setLoading(true) + try { + if (!(window as any).api) throw new Error("api not ready") + const ensureSuccess = (res: any, fallbackMessage: string) => { + if (!res?.success) { + throw new Error(res?.message || fallbackMessage) + } + } + + const syncRes = await (window as any).api.dbSync() + ensureSuccess(syncRes, t("common.error")) + if (!syncRes?.data?.success) { + throw new Error(syncRes?.data?.message || t("common.error")) + } + + if (workingTheme) { + const exists = themes.some((t) => t.id === workingTheme.id) + if (!exists) { + const saveRes = await (window as any).api.saveTheme(workingTheme) + ensureSuccess(saveRes, t("common.error")) + } + if (currentTheme?.id !== workingTheme.id) { + await setTheme(workingTheme.id) + } + } + + for (const student of students) { + const createStudentRes = await (window as any).api.createStudent({ name: student.name }) + ensureSuccess(createStudentRes, t("common.error")) + } + + for (const reason of reasons) { + const createReasonRes = await (window as any).api.createReason({ + content: reason.content, + category: t("reasons.others"), + delta: reason.delta, + }) + ensureSuccess(createReasonRes, t("common.error")) + } + + if (adminPassword || pointsPassword) { + const authRes = await (window as any).api.authSetPasswords({ + adminPassword: adminPassword || null, + pointsPassword: pointsPassword || null, + }) + ensureSuccess(authRes, t("common.error")) + } + + await markWizardCompleted() + + showOobeMessage("success", t("common.success")) + onComplete() + } catch (e: any) { + showOobeMessage("error", e?.message || t("common.error")) + } finally { + setLoading(false) + } + } + + if (!visible) return null + + const currentBg = workingTheme?.config?.custom?.["--ss-bg-color"] || "" + + const renderStepContent = () => { + switch (currentStep) { + case "entry": + return ( +
+ {t("oobe.steps.entry.description")} + + + +
+ ) + + case "postgresql": + return ( +
+ + {t("oobe.steps.postgresql.description")} + + setPgConnectionString(e.target.value)} + placeholder={t("oobe.steps.postgresql.connectionPlaceholder")} + /> + + {t("settings.database.connectionExample")} + +
+ + +
+
+ ) + + case "language": + return ( +
+ + {t("oobe.steps.language.description")} + + handleLanguageChange(v as AppLanguage)} + options={languageOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + }))} + block + /> +
+ ) + + case "theme": + return ( +
+ {t("oobe.steps.theme.description")} +
+ {t("oobe.steps.theme.mode")} + setMode(v as any)} + options={[ + { label: t("oobe.steps.theme.lightMode"), value: "light" }, + { label: t("oobe.steps.theme.darkMode"), value: "dark" }, + ]} + /> +
+
+ {t("oobe.steps.theme.primaryColor")} +
+ setPrimaryInput(e.target.value)} + onBlur={() => { + const hex = primaryInput.trim() + if (/^#?[0-9a-fA-F]{6}$/.test(hex.replace("#", ""))) { + setPrimary(hex.startsWith("#") ? hex : `#${hex}`) + } + }} + style={{ width: 100 }} + /> + {presetPrimaryColors.slice(0, 6).map((c) => ( +
+
+
+ {t("oobe.steps.theme.backgroundGradient")} +
+ {presetGradients.map((g) => { + const gradientValue = g[workingTheme?.mode || "light"] + const active = currentBg === gradientValue + return ( +
+
+
+ ) + + case "password": + return ( +
+ + {t("oobe.steps.password.description")} + + +
+ {t("oobe.steps.password.adminPassword")} + + {t("oobe.steps.password.adminPasswordHint")} + + setAdminPassword(e.target.value)} + placeholder={t("oobe.steps.password.passwordPlaceholder")} + maxLength={6} + style={{ marginTop: 8 }} + /> +
+
+ {t("oobe.steps.password.pointsPassword")} + + {t("oobe.steps.password.pointsPasswordHint")} + + setPointsPassword(e.target.value)} + placeholder={t("oobe.steps.password.passwordPlaceholder")} + maxLength={6} + style={{ marginTop: 8 }} + /> +
+ + {t("oobe.steps.password.hint")} + +
+
+ ) + + case "students": + return ( +
+ + {t("oobe.steps.students.description")} + +
+ + { + const file = e.target.files?.[0] + if (file) handleFileImport(file) + if (fileInputRef.current) fileInputRef.current.value = "" + }} + /> +
+
e.preventDefault()} + style={{ + border: isDark ? "1px dashed rgba(255,255,255,0.3)" : "1px dashed rgba(0,0,0,0.2)", + borderRadius: 8, + padding: 16, + textAlign: "center", + color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)", + }} + > + +
{t("oobe.steps.students.dragDrop")}
+
+ {t("oobe.steps.students.supportedFormats")} +
+
+
+ setNewStudentName(e.target.value)} + placeholder={t("oobe.steps.students.studentName")} + autoSize={{ minRows: 2, maxRows: 5 }} + /> + +
+ + {t("oobe.steps.students.manualHint")} + +
+ {students.length === 0 ? ( + + {t("oobe.steps.students.noStudents")} + + ) : ( + students.map((s) => ( + removeStudent(s.name)} + style={{ margin: 2 }} + > + {s.name} + + )) + )} +
+ {students.length > 0 && ( + + {t("oobe.steps.students.studentCount", { count: students.length })} + + )} +
+ ) + + case "reasons": + return ( +
+ + {t("oobe.steps.reasons.description")} + +
+ setNewReasonContent(e.target.value)} + placeholder={t("oobe.steps.reasons.reasonName")} + style={{ flex: 1 }} + /> + setNewReasonDelta(v || 0)} + min={-999} + max={999} + style={{ width: 80 }} + /> + +
+
+ {reasons.length === 0 ? ( + + {t("oobe.steps.reasons.noReasons")} + + ) : ( + reasons.map((r) => ( + removeReason(r.content)} + color={r.delta > 0 ? "success" : "error"} + style={{ margin: 2 }} + > + {r.content} ({r.delta > 0 ? "+" : ""} + {r.delta}) + + )) + )} +
+ {reasons.length > 0 && ( + + {t("oobe.steps.reasons.reasonCount", { count: reasons.length })} + + )} +
+ ) + + case "start": + return ( +
+ + {t("oobe.steps.start.description")} + +
+
+ + + {t("oobe.steps.start.features.score")} + +
+
+ + + {t("oobe.steps.start.features.history")} + +
+
+ + + {t("oobe.steps.start.features.settlement")} + +
+
+
+ ) + } + } + + return ( +
+ + +
+ {contextHolder} + +
+ + {t("oobe.title")} + + + {t("oobe.subtitle")} + +
+ +
+ + {t(`oobe.steps.${currentStep}.title`)} + +
+ +
{renderStepContent()}
+ + {currentStep !== "entry" && currentStep !== "postgresql" && ( +
+
+ {currentStep !== "language" && } + {currentStep !== "start" && } +
+ +
+
+
+
+ {t("oobe.step", { current: stepIndex, total: totalSteps })} +
+ +
+ {currentStep !== "start" ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ) +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 080d363..9467315 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,937 +1,986 @@ -import React, { useEffect, useMemo, useRef, useState } from "react" -import { - Tabs, - Card, - Form, - Select, - Input, - Button, - Space, - Divider, - Tag, - Modal, - Switch, - message, -} from "antd" -import { ThemeQuickSettings } from "./ThemeQuickSettings" -import { useTranslation } from "react-i18next" -import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" - -type permissionLevel = "admin" | "points" | "view" -type appSettings = { - is_wizard_completed: boolean - log_level: "debug" | "info" | "warn" | "error" - window_zoom?: string - search_keyboard_layout?: "t9" | "qwerty26" - disable_search_keyboard?: boolean - auto_score_enabled?: boolean -} - -const withTimeout = async (promise: Promise, ms: number, timeoutMessage: string): Promise => { - let timer: ReturnType | undefined - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(timeoutMessage)), ms) - }), - ]) - } finally { - if (timer) clearTimeout(timer) - } -} - -export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { - const { t } = useTranslation() - const [activeTab, setActiveTab] = useState("appearance") - const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()) - const [settings, setSettings] = useState({ - is_wizard_completed: false, - log_level: "info", - window_zoom: "1.0", - search_keyboard_layout: "qwerty26", - disable_search_keyboard: false, - }) - - const [securityStatus, setSecurityStatus] = useState<{ - permission: permissionLevel - hasAdminPassword: boolean - hasPointsPassword: boolean - hasRecoveryString: boolean - } | null>(null) - - const [adminPassword, setAdminPassword] = useState("") - const [pointsPassword, setPointsPassword] = useState("") - const [recoveryToReset, setRecoveryToReset] = useState("") - - const [recoveryDialogVisible, setRecoveryDialogVisible] = useState(false) - const [recoveryDialogHeader, setRecoveryDialogHeader] = useState("") - const [recoveryDialogString, setRecoveryDialogString] = useState("") - const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("") - - const [logsDialogVisible, setLogsDialogVisible] = useState(false) - const [logsText, setLogsText] = useState("") - const [logsLoading, setLogsLoading] = useState(false) - +import React, { useEffect, useMemo, useRef, useState } from "react" +import { + Tabs, + Card, + Form, + Select, + Input, + Button, + Space, + Divider, + Tag, + Modal, + Switch, + message, +} from "antd" +import { ThemeQuickSettings } from "./ThemeQuickSettings" +import { useTranslation } from "react-i18next" +import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" + +type permissionLevel = "admin" | "points" | "view" +type appSettings = { + is_wizard_completed: boolean + log_level: "debug" | "info" | "warn" | "error" + window_zoom?: string + search_keyboard_layout?: "t9" | "qwerty26" + disable_search_keyboard?: boolean + auto_score_enabled?: boolean +} + +const withTimeout = async (promise: Promise, ms: number, timeoutMessage: string): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), ms) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState("appearance") + const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()) + const [settings, setSettings] = useState({ + is_wizard_completed: false, + log_level: "info", + window_zoom: "1.0", + search_keyboard_layout: "qwerty26", + disable_search_keyboard: false, + }) + + const [securityStatus, setSecurityStatus] = useState<{ + permission: permissionLevel + hasAdminPassword: boolean + hasPointsPassword: boolean + hasRecoveryString: boolean + } | null>(null) + + const [adminPassword, setAdminPassword] = useState("") + const [pointsPassword, setPointsPassword] = useState("") + const [recoveryToReset, setRecoveryToReset] = useState("") + + const [recoveryDialogVisible, setRecoveryDialogVisible] = useState(false) + const [recoveryDialogHeader, setRecoveryDialogHeader] = useState("") + const [recoveryDialogString, setRecoveryDialogString] = useState("") + const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("") + + const [logsDialogVisible, setLogsDialogVisible] = useState(false) + const [logsText, setLogsText] = useState("") + const [logsLoading, setLogsLoading] = useState(false) + const [clearDialogVisible, setClearDialogVisible] = useState(false) const [clearLoading, setClearLoading] = useState(false) - - const importInputRef = useRef(null) - - const [settleLoading, setSettleLoading] = useState(false) - const [settleDialogVisible, setSettleDialogVisible] = useState(false) - - const [urlRegisterLoading, setUrlRegisterLoading] = useState(false) - const [appQuitLoading, setAppQuitLoading] = useState(false) - const [appRestartLoading, setAppRestartLoading] = useState(false) - const [mcpLoading, setMcpLoading] = useState(false) - const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({ - host: "127.0.0.1", - port: 3901, - }) - const [mcpStatus, setMcpStatus] = useState<{ - is_running: boolean - config: { host: string; port: number } - url?: string | null - }>({ - is_running: false, - config: { host: "127.0.0.1", port: 3901 }, - url: null, - }) - const canAdmin = permission === "admin" - const [messageApi, contextHolder] = message.useMessage() - - const [pgConnectionString, setPgConnectionString] = useState("") - const [pgConnectionStatus, setPgConnectionStatus] = useState<{ - connected: boolean - type: "sqlite" | "postgresql" - error?: string - }>({ connected: true, type: "sqlite" }) - const [pgTestLoading, setPgTestLoading] = useState(false) - const [pgSwitchLoading, setPgSwitchLoading] = useState(false) - const [pgUploadLoading, setPgUploadLoading] = useState(false) - - const permissionTag = useMemo(() => { - return ( - - {permission === "admin" - ? t("permissions.admin") - : permission === "points" - ? t("permissions.points") - : t("permissions.view")} - - ) - }, [permission, t]) - - const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { - window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) - } - - const loadMcpStatus = async () => { - if (!(window as any).api?.mcpServerStatus) return - try { - const res = await (window as any).api.mcpServerStatus() - if (res.success && res.data) { - setMcpStatus(res.data) - if (res.data.config?.host && res.data.config?.port) { - setMcpConfig({ host: res.data.config.host, port: res.data.config.port }) - } - } - } catch { - // ignore status polling errors in settings page - } - } - - const loadAll = async () => { - if (!(window as any).api) return - const res = await (window as any).api.getAllSettings() - if (res.success && res.data) { - setSettings(res.data) - setPgConnectionString(res.data.pg_connection_string || "") - setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) - } - const authRes = await (window as any).api.authGetStatus() - if (authRes.success && authRes.data) setSecurityStatus(authRes.data) - await loadMcpStatus() - } - - useEffect(() => { - loadAll() - const api = (window as any).api - if (!api) return - - let disposed = false - let unlisten: (() => void) | null = null - - api - .onSettingChanged((change: any) => { - setSettings((prev) => { - if (change?.key === "log_level") return { ...prev, log_level: change.value } - if (change?.key === "is_wizard_completed") - return { ...prev, is_wizard_completed: change.value } - if (change?.key === "window_zoom") return { ...prev, window_zoom: change.value } - if (change?.key === "search_keyboard_layout") - return { ...prev, search_keyboard_layout: change.value } - if (change?.key === "disable_search_keyboard") - return { ...prev, disable_search_keyboard: Boolean(change.value) } - if (change?.key === "auto_score_enabled") - return { ...prev, auto_score_enabled: change.value } - return prev - }) - }) - .then((fn: () => void) => { - if (disposed) { - fn() - return - } - unlisten = fn - }) - .catch(() => void 0) - - return () => { - disposed = true - if (unlisten) unlisten() - } - }, []) - - const showLogs = async () => { - if (!(window as any).api) return - setLogsLoading(true) - const res = await (window as any).api.queryLogs(200) - setLogsLoading(false) - if (!res.success) { - messageApi.error(res.message || t("settings.data.readLogsFailed")) - return - } - setLogsText((res.data || []).join("\n")) - setLogsDialogVisible(true) - } - - const exportLogs = async () => { - if (!(window as any).api) return - const res = await (window as any).api.queryLogs(5000) - if (!res.success) { - messageApi.error(res.message || t("settings.data.readLogsFailed")) - return - } - const dateTime = new Date().toISOString().replace(/[:.]/g, "-") - downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join("\n")}\n`) - messageApi.success(t("settings.data.logsExported")) - } - - const downloadTextFile = (filename: string, text: string) => { - const blob = new Blob(["\ufeff" + text], { type: "text/plain;charset=utf-8" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) - } - - const showRecoveryDialog = (header: string, recoveryString: string) => { - const date = new Date().toISOString().slice(0, 10) - const filename = `secscore_recovery_${date}.txt` - setRecoveryDialogHeader(header) - setRecoveryDialogString(recoveryString) - setRecoveryDialogFilename(filename) - setRecoveryDialogVisible(true) - } - - const exportJson = async () => { - if (!(window as any).api) return - const res = await (window as any).api.exportDataJson() - if (!res.success || !res.data) { - messageApi.error(res.message || t("settings.data.exportFailed")) - return - } - const blob = new Blob([res.data], { type: "application/json;charset=utf-8" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json` - a.click() - URL.revokeObjectURL(url) - messageApi.success(t("settings.data.exportSuccess")) - } - - const importJson = async (file: File) => { - if (!(window as any).api) return - const text = await file.text() - const res = await (window as any).api.importDataJson(text) - if (res.success) { - messageApi.success(t("settings.data.importSuccess")) - setTimeout(() => window.location.reload(), 300) - } else { - messageApi.error(res.message || t("settings.data.importFailed")) - } - } - - const savePasswords = async () => { - if (!(window as any).api) return - const res = await (window as any).api.authSetPasswords({ - adminPassword: adminPassword ? adminPassword : undefined, - pointsPassword: pointsPassword ? pointsPassword : undefined, - }) - if (res.success) { - setAdminPassword("") - setPointsPassword("") - await loadAll() - if (res.data?.recoveryString) { - showRecoveryDialog( - t("settings.security.recoveryString") + ` (${t("recovery.hint")})`, - res.data.recoveryString - ) - } else { - messageApi.success(t("settings.security.saved")) - } - } else { - messageApi.error(res.message || t("settings.general.saveFailed")) - } - } - - const generateRecovery = async () => { - if (!(window as any).api) return - const res = await (window as any).api.authGenerateRecovery() - if (!res.success || !res.data?.recoveryString) { - messageApi.error(res.message || t("settings.security.generateFailed")) - return - } - await loadAll() - showRecoveryDialog( - t("settings.security.newRecoveryString", "New recovery string (please save it)"), - res.data.recoveryString - ) - } - - const resetByRecovery = async () => { - if (!(window as any).api) return - const res = await (window as any).api.authResetByRecovery(recoveryToReset) - if (!res.success || !res.data?.recoveryString) { - messageApi.error(res.message || t("settings.security.resetFailed")) - return - } - setRecoveryToReset("") - await loadAll() - showRecoveryDialog( - t("settings.security.passwordClearedNewRecovery", "Password cleared, new recovery string"), - res.data.recoveryString - ) - } - + const [resetDialogVisible, setResetDialogVisible] = useState(false) + const [resetLoading, setResetLoading] = useState(false) + + const importInputRef = useRef(null) + + const [settleLoading, setSettleLoading] = useState(false) + const [settleDialogVisible, setSettleDialogVisible] = useState(false) + + const [urlRegisterLoading, setUrlRegisterLoading] = useState(false) + const [appQuitLoading, setAppQuitLoading] = useState(false) + const [appRestartLoading, setAppRestartLoading] = useState(false) + const [mcpLoading, setMcpLoading] = useState(false) + const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({ + host: "127.0.0.1", + port: 3901, + }) + const [mcpStatus, setMcpStatus] = useState<{ + is_running: boolean + config: { host: string; port: number } + url?: string | null + }>({ + is_running: false, + config: { host: "127.0.0.1", port: 3901 }, + url: null, + }) + const canAdmin = permission === "admin" + const [messageApi, contextHolder] = message.useMessage() + + const [pgConnectionString, setPgConnectionString] = useState("") + const [pgConnectionStatus, setPgConnectionStatus] = useState<{ + connected: boolean + type: "sqlite" | "postgresql" + error?: string + }>({ connected: true, type: "sqlite" }) + const [pgTestLoading, setPgTestLoading] = useState(false) + const [pgSwitchLoading, setPgSwitchLoading] = useState(false) + const [pgUploadLoading, setPgUploadLoading] = useState(false) + + const permissionTag = useMemo(() => { + return ( + + {permission === "admin" + ? t("permissions.admin") + : permission === "points" + ? t("permissions.points") + : t("permissions.view")} + + ) + }, [permission, t]) + + const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { + window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) + } + + const loadMcpStatus = async () => { + if (!(window as any).api?.mcpServerStatus) return + try { + const res = await (window as any).api.mcpServerStatus() + if (res.success && res.data) { + setMcpStatus(res.data) + if (res.data.config?.host && res.data.config?.port) { + setMcpConfig({ host: res.data.config.host, port: res.data.config.port }) + } + } + } catch { + // ignore status polling errors in settings page + } + } + + const loadAll = async () => { + if (!(window as any).api) return + const res = await (window as any).api.getAllSettings() + if (res.success && res.data) { + setSettings(res.data) + setPgConnectionString(res.data.pg_connection_string || "") + setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) + } + const authRes = await (window as any).api.authGetStatus() + if (authRes.success && authRes.data) setSecurityStatus(authRes.data) + await loadMcpStatus() + } + + useEffect(() => { + loadAll() + const api = (window as any).api + if (!api) return + + let disposed = false + let unlisten: (() => void) | null = null + + api + .onSettingChanged((change: any) => { + setSettings((prev) => { + if (change?.key === "log_level") return { ...prev, log_level: change.value } + if (change?.key === "is_wizard_completed") + return { ...prev, is_wizard_completed: change.value } + if (change?.key === "window_zoom") return { ...prev, window_zoom: change.value } + if (change?.key === "search_keyboard_layout") + return { ...prev, search_keyboard_layout: change.value } + if (change?.key === "disable_search_keyboard") + return { ...prev, disable_search_keyboard: Boolean(change.value) } + if (change?.key === "auto_score_enabled") + return { ...prev, auto_score_enabled: change.value } + return prev + }) + }) + .then((fn: () => void) => { + if (disposed) { + fn() + return + } + unlisten = fn + }) + .catch(() => void 0) + + return () => { + disposed = true + if (unlisten) unlisten() + } + }, []) + + const showLogs = async () => { + if (!(window as any).api) return + setLogsLoading(true) + const res = await (window as any).api.queryLogs(200) + setLogsLoading(false) + if (!res.success) { + messageApi.error(res.message || t("settings.data.readLogsFailed")) + return + } + setLogsText((res.data || []).join("\n")) + setLogsDialogVisible(true) + } + + const exportLogs = async () => { + if (!(window as any).api) return + const res = await (window as any).api.queryLogs(5000) + if (!res.success) { + messageApi.error(res.message || t("settings.data.readLogsFailed")) + return + } + const dateTime = new Date().toISOString().replace(/[:.]/g, "-") + downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join("\n")}\n`) + messageApi.success(t("settings.data.logsExported")) + } + + const downloadTextFile = (filename: string, text: string) => { + const blob = new Blob(["\ufeff" + text], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + const showRecoveryDialog = (header: string, recoveryString: string) => { + const date = new Date().toISOString().slice(0, 10) + const filename = `secscore_recovery_${date}.txt` + setRecoveryDialogHeader(header) + setRecoveryDialogString(recoveryString) + setRecoveryDialogFilename(filename) + setRecoveryDialogVisible(true) + } + + const exportJson = async () => { + if (!(window as any).api) return + const res = await (window as any).api.exportDataJson() + if (!res.success || !res.data) { + messageApi.error(res.message || t("settings.data.exportFailed")) + return + } + const blob = new Blob([res.data], { type: "application/json;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) + messageApi.success(t("settings.data.exportSuccess")) + } + + const importJson = async (file: File) => { + if (!(window as any).api) return + const text = await file.text() + const res = await (window as any).api.importDataJson(text) + if (res.success) { + messageApi.success(t("settings.data.importSuccess")) + setTimeout(() => window.location.reload(), 300) + } else { + messageApi.error(res.message || t("settings.data.importFailed")) + } + } + + const savePasswords = async () => { + if (!(window as any).api) return + const res = await (window as any).api.authSetPasswords({ + adminPassword: adminPassword ? adminPassword : undefined, + pointsPassword: pointsPassword ? pointsPassword : undefined, + }) + if (res.success) { + setAdminPassword("") + setPointsPassword("") + await loadAll() + if (res.data?.recoveryString) { + showRecoveryDialog( + t("settings.security.recoveryString") + ` (${t("recovery.hint")})`, + res.data.recoveryString + ) + } else { + messageApi.success(t("settings.security.saved")) + } + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + } + + const generateRecovery = async () => { + if (!(window as any).api) return + const res = await (window as any).api.authGenerateRecovery() + if (!res.success || !res.data?.recoveryString) { + messageApi.error(res.message || t("settings.security.generateFailed")) + return + } + await loadAll() + showRecoveryDialog( + t("settings.security.newRecoveryString", "New recovery string (please save it)"), + res.data.recoveryString + ) + } + + const resetByRecovery = async () => { + if (!(window as any).api) return + const res = await (window as any).api.authResetByRecovery(recoveryToReset) + if (!res.success || !res.data?.recoveryString) { + messageApi.error(res.message || t("settings.security.resetFailed")) + return + } + setRecoveryToReset("") + await loadAll() + showRecoveryDialog( + t("settings.security.passwordClearedNewRecovery", "Password cleared, new recovery string"), + res.data.recoveryString + ) + } + const clearAllPasswords = () => { setClearDialogVisible(true) } - + const handleConfirmClearAll = async () => { - if (!(window as any).api) return - setClearLoading(true) - const res = await (window as any).api.authClearAll() - setClearLoading(false) - if (res.success) { - messageApi.success(t("settings.security.cleared")) - await loadAll() - setClearDialogVisible(false) - } else { - messageApi.error(res.message || t("settings.security.clearFailed")) - } + if (!(window as any).api) return + setClearLoading(true) + const res = await (window as any).api.authClearAll() + setClearLoading(false) + if (res.success) { + messageApi.success(t("settings.security.cleared")) + await loadAll() + setClearDialogVisible(false) + } else { + messageApi.error(res.message || t("settings.security.clearFailed")) + } } - const confirmSettlement = () => { - setSettleDialogVisible(true) + const openResetDialog = () => { + setResetDialogVisible(true) } - const testPgConnection = async () => { - if (!(window as any).api) return - if (!pgConnectionString) { - messageApi.warning(t("settings.database.enterConnectionString")) - return - } - setPgTestLoading(true) + const handleConfirmResetAll = async () => { + if (!(window as any).api?.dataResetAll) return + setResetLoading(true) try { - const res = await withTimeout( - (window as any).api.dbTestConnection(pgConnectionString), - 20_000, - "测试连接超时,请检查网络或连接字符串" - ) - if (res.success && res.data?.success) { - messageApi.success(t("settings.database.connectionTestSuccess")) - } else { - messageApi.error( - res.data?.error || res.message || t("settings.database.connectionTestFailed") - ) - } - } catch (e: any) { - messageApi.error(e?.message || t("settings.database.connectionTestFailed")) - } finally { - setPgTestLoading(false) - } - } - - const switchToPg = async () => { - if (!(window as any).api) return - if (!pgConnectionString.trim()) { - messageApi.warning(t("settings.database.enterConnectionString")) - return - } - setPgSwitchLoading(true) - try { - const res = await withTimeout( - (window as any).api.dbSwitchConnection(pgConnectionString.trim()), - 25_000, - "切换 PostgreSQL 超时,请检查网络或连接字符串" - ) + const res = await (window as any).api.dataResetAll() if (res.success) { - messageApi.success( - t("settings.database.switchedTo", { - type: res.data?.type === "postgresql" ? "PostgreSQL" : "SQLite", - }) - ) - await loadAll() + messageApi.success(t("settings.data.resetSuccess")) + window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) + window.setTimeout(() => window.location.reload(), 300) } else { - messageApi.error(res.message || t("settings.database.switchFailed")) + messageApi.error(res.message || t("settings.data.resetFailed")) } } catch (e: any) { - messageApi.error(e?.message || t("settings.database.switchFailed")) + messageApi.error(e?.message || t("settings.data.resetFailed")) } finally { - setPgSwitchLoading(false) + setResetLoading(false) + setResetDialogVisible(false) } } - - const switchToSQLite = async () => { - if (!(window as any).api) return - setPgSwitchLoading(true) - try { - const res = await withTimeout( - (window as any).api.dbSwitchConnection(""), - 15_000, - "切换 SQLite 超时,请稍后重试" - ) - if (res.success) { - messageApi.success(t("settings.database.switchedToSQLite")) - setPgConnectionString("") - await loadAll() - } else { - messageApi.error(res.message || t("settings.database.switchFailed")) - } - } catch (e: any) { - messageApi.error(e?.message || t("settings.database.switchFailed")) - } finally { - setPgSwitchLoading(false) - } - } - - const uploadLocalToRemote = async () => { - if (!(window as any).api) return - try { - const statusRes = await (window as any).api.dbGetStatus() - if (!statusRes.success || !statusRes.data?.connected || statusRes.data?.type !== "postgresql") { - messageApi.warning(t("settings.database.uploadNeedRemote")) - return - } - - const previewRes = await withTimeout( - (window as any).api.dbSyncPreview(), - 25_000, - "同步预检查超时,请稍后重试" - ) - if (!previewRes.success || !previewRes.data?.can_sync) { - messageApi.error(previewRes.message || previewRes.data?.message || t("settings.database.uploadFailed")) - return - } - if (!previewRes.data.need_sync) { - messageApi.info(t("settings.database.noNeedUpload")) - return - } - - Modal.confirm({ - title: t("settings.database.uploadConfirmTitle"), - content: t("settings.database.uploadConfirmContent", { - localOnly: previewRes.data.local_only || 0, - remoteOnly: previewRes.data.remote_only || 0, - conflicts: previewRes.data.conflicts?.length || 0, - }), - okText: t("settings.database.uploadButton"), - cancelText: t("common.cancel"), - onOk: async () => { - setPgUploadLoading(true) - try { - const applyRes = await withTimeout( - (window as any).api.dbSyncApply("keep_local"), - 45_000, - "执行同步超时,请稍后重试" - ) - if (applyRes.success && applyRes.data?.success) { - messageApi.success( - applyRes.data?.message || t("settings.database.uploadSuccess") - ) - emitDataUpdated("all") - await loadAll() - } else { - messageApi.error( - applyRes.data?.message || applyRes.message || t("settings.database.uploadFailed") - ) - } - } catch (e: any) { - messageApi.error(e?.message || t("settings.database.uploadFailed")) - } finally { - setPgUploadLoading(false) - } - }, - }) - } catch (e: any) { - messageApi.error(e?.message || t("settings.database.uploadFailed")) - } - } - - const startMcpServer = async () => { - if (!(window as any).api?.mcpServerStart) return - const host = mcpConfig.host.trim() - const port = Number(mcpConfig.port) - if (!host) { - messageApi.warning(t("settings.mcp.hostRequired")) - return - } - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - messageApi.warning(t("settings.mcp.portInvalid")) - return - } - - setMcpLoading(true) - try { - const res = await withTimeout( - (window as any).api.mcpServerStart({ host, port }), - 10_000, - t("settings.mcp.startTimeout") - ) - if (res.success) { - messageApi.success(t("settings.mcp.startSuccess")) - } else { - messageApi.error(res.message || t("settings.mcp.startFailed")) - } - } catch (e: any) { - messageApi.error(e?.message || t("settings.mcp.startFailed")) - } finally { - await loadMcpStatus() - setMcpLoading(false) - } - } - - const stopMcpServer = async () => { - if (!(window as any).api?.mcpServerStop) return - setMcpLoading(true) - try { - const res = await withTimeout( - (window as any).api.mcpServerStop(), - 10_000, - t("settings.mcp.stopTimeout") - ) - if (res.success) { - messageApi.success(t("settings.mcp.stopSuccess")) - } else { - messageApi.error(res.message || t("settings.mcp.stopFailed")) - } - } catch (e: any) { - messageApi.error(e?.message || t("settings.mcp.stopFailed")) - } finally { - await loadMcpStatus() - setMcpLoading(false) - } - } - - const currentYear = new Date().getFullYear() - - const confirmQuitApp = () => { - Modal.confirm({ - title: t("settings.app.confirmQuitTitle"), - content: t("settings.app.confirmQuitContent"), - okText: t("settings.app.quit"), - okButtonProps: { danger: true }, - cancelText: t("common.cancel"), - onOk: async () => { - if (!(window as any).api) return - setAppQuitLoading(true) - try { - await (window as any).api.appQuit() - } catch (e: any) { - setAppQuitLoading(false) - messageApi.error(e?.message || t("settings.app.quitFailed")) - } - }, - }) - } - - const confirmRestartApp = () => { - Modal.confirm({ - title: t("settings.app.confirmRestartTitle"), - content: t("settings.app.confirmRestartContent"), - okText: t("settings.app.restart"), - cancelText: t("common.cancel"), - onOk: async () => { - if (!(window as any).api) return - setAppRestartLoading(true) - try { - await (window as any).api.appRestart() - } catch (e: any) { - setAppRestartLoading(false) - messageApi.error(e?.message || t("settings.app.restartFailed")) - } - }, - }) - } - - const tabItems = [ - { - key: "appearance", - label: t("settings.tabs.appearance"), - children: ( - -
- - { - if (!(window as any).api) return - const next = String(v) as "t9" | "qwerty26" - const res = await (window as any).api.setSetting("search_keyboard_layout", next) - if (res.success) { - setSettings((prev) => ({ ...prev, search_keyboard_layout: next })) - messageApi.success(t("settings.general.saved")) - } else { - messageApi.error(res.message || t("settings.general.saveFailed")) - } - }} - style={{ width: "320px" }} - disabled={!canAdmin} - options={[ - { value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") }, - { value: "t9", label: t("settings.searchKeyboard.options.t9") }, - ]} - /> -
- {t("settings.searchKeyboard.hint")} -
-
- - - { - if (!(window as any).api) return - const res = await (window as any).api.setSetting( - "disable_search_keyboard", - checked - ) - if (res.success) { - setSettings((prev) => ({ ...prev, disable_search_keyboard: checked })) - messageApi.success(t("settings.general.saved")) - } else { - messageApi.error(res.message || t("settings.general.saveFailed")) - } - }} - disabled={!canAdmin} - /> -
- {t("settings.searchKeyboard.disableHint")} -
-
- - - setAdminPassword(e.target.value)} - placeholder={t("settings.security.adminPasswordPlaceholder")} - maxLength={6} - disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} - /> - - - - setPointsPassword(e.target.value)} - placeholder={t("settings.security.pointsPasswordPlaceholder")} - maxLength={6} - disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} - /> - - - - - - - - - -
-
- - -
- {t("settings.security.recoveryReset")} -
- - setRecoveryToReset(e.target.value)} - placeholder={t("settings.security.recoveryPlaceholder")} - style={{ width: "420px" }} - /> - - -
- {t("settings.security.resetHint")} -
-
- - ), - }, - { - key: "database", - label: t("settings.database.title"), - children: ( - <> - -
- {t("settings.database.currentStatus")} -
- - - {pgConnectionStatus.type === "postgresql" - ? t("settings.database.postgresqlRemote") - : t("settings.database.sqliteLocal")} - - - {pgConnectionStatus.connected - ? t("settings.database.connected") - : t("settings.database.disconnected")} - - {pgConnectionStatus.error && ( - - {pgConnectionStatus.error} - - )} - -
- - -
- {t("settings.database.postgresqlConnection")} -
-
- {t("settings.database.connectionHint")} -
- - setPgConnectionString(e.target.value)} - placeholder="postgresql://user:password@host:port/database?sslmode=require" - style={{ flex: 1 }} - disabled={!canAdmin} - /> - - -
- {t("settings.database.connectionExample")} -
- - - - - -
- {t("settings.database.uploadHint")} -
-
- - -
- {t("settings.database.syncDescription")} -
-
-

{t("settings.database.syncPoint1")}

-

{t("settings.database.syncPoint2")}

-

{t("settings.database.syncPoint3")}

-

{t("settings.database.syncPoint4")}

-
-
- - ), - }, - { - key: "data", - label: t("settings.data.title"), - children: ( - <> + + const confirmSettlement = () => { + setSettleDialogVisible(true) + } + + const testPgConnection = async () => { + if (!(window as any).api) return + if (!pgConnectionString) { + messageApi.warning(t("settings.database.enterConnectionString")) + return + } + setPgTestLoading(true) + try { + const res = await withTimeout( + (window as any).api.dbTestConnection(pgConnectionString), + 20_000, + "测试连接超时,请检查网络或连接字符串" + ) + if (res.success && res.data?.success) { + messageApi.success(t("settings.database.connectionTestSuccess")) + } else { + messageApi.error( + res.data?.error || res.message || t("settings.database.connectionTestFailed") + ) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.database.connectionTestFailed")) + } finally { + setPgTestLoading(false) + } + } + + const switchToPg = async () => { + if (!(window as any).api) return + if (!pgConnectionString.trim()) { + messageApi.warning(t("settings.database.enterConnectionString")) + return + } + setPgSwitchLoading(true) + try { + const res = await withTimeout( + (window as any).api.dbSwitchConnection(pgConnectionString.trim()), + 25_000, + "切换 PostgreSQL 超时,请检查网络或连接字符串" + ) + if (res.success) { + messageApi.success( + t("settings.database.switchedTo", { + type: res.data?.type === "postgresql" ? "PostgreSQL" : "SQLite", + }) + ) + await loadAll() + } else { + messageApi.error(res.message || t("settings.database.switchFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.database.switchFailed")) + } finally { + setPgSwitchLoading(false) + } + } + + const switchToSQLite = async () => { + if (!(window as any).api) return + setPgSwitchLoading(true) + try { + const res = await withTimeout( + (window as any).api.dbSwitchConnection(""), + 15_000, + "切换 SQLite 超时,请稍后重试" + ) + if (res.success) { + messageApi.success(t("settings.database.switchedToSQLite")) + setPgConnectionString("") + await loadAll() + } else { + messageApi.error(res.message || t("settings.database.switchFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.database.switchFailed")) + } finally { + setPgSwitchLoading(false) + } + } + + const uploadLocalToRemote = async () => { + if (!(window as any).api) return + try { + const statusRes = await (window as any).api.dbGetStatus() + if (!statusRes.success || !statusRes.data?.connected || statusRes.data?.type !== "postgresql") { + messageApi.warning(t("settings.database.uploadNeedRemote")) + return + } + + const previewRes = await withTimeout( + (window as any).api.dbSyncPreview(), + 25_000, + "同步预检查超时,请稍后重试" + ) + if (!previewRes.success || !previewRes.data?.can_sync) { + messageApi.error(previewRes.message || previewRes.data?.message || t("settings.database.uploadFailed")) + return + } + if (!previewRes.data.need_sync) { + messageApi.info(t("settings.database.noNeedUpload")) + return + } + + Modal.confirm({ + title: t("settings.database.uploadConfirmTitle"), + content: t("settings.database.uploadConfirmContent", { + localOnly: previewRes.data.local_only || 0, + remoteOnly: previewRes.data.remote_only || 0, + conflicts: previewRes.data.conflicts?.length || 0, + }), + okText: t("settings.database.uploadButton"), + cancelText: t("common.cancel"), + onOk: async () => { + setPgUploadLoading(true) + try { + const applyRes = await withTimeout( + (window as any).api.dbSyncApply("keep_local"), + 45_000, + "执行同步超时,请稍后重试" + ) + if (applyRes.success && applyRes.data?.success) { + messageApi.success( + applyRes.data?.message || t("settings.database.uploadSuccess") + ) + emitDataUpdated("all") + await loadAll() + } else { + messageApi.error( + applyRes.data?.message || applyRes.message || t("settings.database.uploadFailed") + ) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.database.uploadFailed")) + } finally { + setPgUploadLoading(false) + } + }, + }) + } catch (e: any) { + messageApi.error(e?.message || t("settings.database.uploadFailed")) + } + } + + const startMcpServer = async () => { + if (!(window as any).api?.mcpServerStart) return + const host = mcpConfig.host.trim() + const port = Number(mcpConfig.port) + if (!host) { + messageApi.warning(t("settings.mcp.hostRequired")) + return + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + messageApi.warning(t("settings.mcp.portInvalid")) + return + } + + setMcpLoading(true) + try { + const res = await withTimeout( + (window as any).api.mcpServerStart({ host, port }), + 10_000, + t("settings.mcp.startTimeout") + ) + if (res.success) { + messageApi.success(t("settings.mcp.startSuccess")) + } else { + messageApi.error(res.message || t("settings.mcp.startFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.mcp.startFailed")) + } finally { + await loadMcpStatus() + setMcpLoading(false) + } + } + + const stopMcpServer = async () => { + if (!(window as any).api?.mcpServerStop) return + setMcpLoading(true) + try { + const res = await withTimeout( + (window as any).api.mcpServerStop(), + 10_000, + t("settings.mcp.stopTimeout") + ) + if (res.success) { + messageApi.success(t("settings.mcp.stopSuccess")) + } else { + messageApi.error(res.message || t("settings.mcp.stopFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.mcp.stopFailed")) + } finally { + await loadMcpStatus() + setMcpLoading(false) + } + } + + const currentYear = new Date().getFullYear() + + const confirmQuitApp = () => { + Modal.confirm({ + title: t("settings.app.confirmQuitTitle"), + content: t("settings.app.confirmQuitContent"), + okText: t("settings.app.quit"), + okButtonProps: { danger: true }, + cancelText: t("common.cancel"), + onOk: async () => { + if (!(window as any).api) return + setAppQuitLoading(true) + try { + await (window as any).api.appQuit() + } catch (e: any) { + setAppQuitLoading(false) + messageApi.error(e?.message || t("settings.app.quitFailed")) + } + }, + }) + } + + const confirmRestartApp = () => { + Modal.confirm({ + title: t("settings.app.confirmRestartTitle"), + content: t("settings.app.confirmRestartContent"), + okText: t("settings.app.restart"), + cancelText: t("common.cancel"), + onOk: async () => { + if (!(window as any).api) return + setAppRestartLoading(true) + try { + await (window as any).api.appRestart() + } catch (e: any) { + setAppRestartLoading(false) + messageApi.error(e?.message || t("settings.app.restartFailed")) + } + }, + }) + } + + const tabItems = [ + { + key: "appearance", + label: t("settings.tabs.appearance"), + children: ( + +
+ + { + if (!(window as any).api) return + const next = String(v) as "t9" | "qwerty26" + const res = await (window as any).api.setSetting("search_keyboard_layout", next) + if (res.success) { + setSettings((prev) => ({ ...prev, search_keyboard_layout: next })) + messageApi.success(t("settings.general.saved")) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + }} + style={{ width: "320px" }} + disabled={!canAdmin} + options={[ + { value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") }, + { value: "t9", label: t("settings.searchKeyboard.options.t9") }, + ]} + /> +
+ {t("settings.searchKeyboard.hint")} +
+
+ + + { + if (!(window as any).api) return + const res = await (window as any).api.setSetting( + "disable_search_keyboard", + checked + ) + if (res.success) { + setSettings((prev) => ({ ...prev, disable_search_keyboard: checked })) + messageApi.success(t("settings.general.saved")) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + }} + disabled={!canAdmin} + /> +
+ {t("settings.searchKeyboard.disableHint")} +
+
+ + + setAdminPassword(e.target.value)} + placeholder={t("settings.security.adminPasswordPlaceholder")} + maxLength={6} + disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} + /> + + + + setPointsPassword(e.target.value)} + placeholder={t("settings.security.pointsPasswordPlaceholder")} + maxLength={6} + disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} + /> + + + + + + + + + +
+
+ + +
+ {t("settings.security.recoveryReset")} +
+ + setRecoveryToReset(e.target.value)} + placeholder={t("settings.security.recoveryPlaceholder")} + style={{ width: "420px" }} + /> + + +
+ {t("settings.security.resetHint")} +
+
+ + ), + }, + { + key: "database", + label: t("settings.database.title"), + children: ( + <> + +
+ {t("settings.database.currentStatus")} +
+ + + {pgConnectionStatus.type === "postgresql" + ? t("settings.database.postgresqlRemote") + : t("settings.database.sqliteLocal")} + + + {pgConnectionStatus.connected + ? t("settings.database.connected") + : t("settings.database.disconnected")} + + {pgConnectionStatus.error && ( + + {pgConnectionStatus.error} + + )} + +
+ + +
+ {t("settings.database.postgresqlConnection")} +
+
+ {t("settings.database.connectionHint")} +
+ + setPgConnectionString(e.target.value)} + placeholder="postgresql://user:password@host:port/database?sslmode=require" + style={{ flex: 1 }} + disabled={!canAdmin} + /> + + +
+ {t("settings.database.connectionExample")} +
+ + + + + +
+ {t("settings.database.uploadHint")} +
+
+ + +
+ {t("settings.database.syncDescription")} +
+
+

{t("settings.database.syncPoint1")}

+

{t("settings.database.syncPoint2")}

+

{t("settings.database.syncPoint3")}

+

{t("settings.database.syncPoint4")}

+
+
+ + ), + }, + { + key: "data", + label: t("settings.data.title"), + children: ( + <> + + +
+ {t("settings.data.settlementHint")} +
+
+
+ + = ({ permission }} > -
- {t("settings.data.settlementHint")} + {t("settings.data.resetHint")}
@@ -956,411 +1000,429 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission -
- {t("settings.data.importExport")} -
- - - - { - const file = e.target.files?.[0] - if (file) importJson(file) - if (importInputRef.current) importInputRef.current.value = "" - }} - /> - -
- {t("settings.data.importHint")} -
-
- - -
- - setMcpConfig((prev) => ({ ...prev, host: e.target.value }))} - disabled={!canAdmin || mcpStatus.is_running} - style={{ width: "280px" }} - placeholder="127.0.0.1" - /> - - - { - const next = Number(e.target.value.replace(/[^\d]/g, "")) - if (Number.isFinite(next) && next > 0) { - setMcpConfig((prev) => ({ ...prev, port: next })) - } else if (!e.target.value) { - setMcpConfig((prev) => ({ ...prev, port: 0 })) - } - }} - disabled={!canAdmin || mcpStatus.is_running} - style={{ width: "180px" }} - placeholder="3901" - /> - - - - - - - - -
-
- {t("settings.mcp.hint")} -
- -
- {t("settings.app.title")} -
-
- {t("settings.app.description")} -
-
- - - - -
-
- ), - }, - ] - - return ( -
- {contextHolder} -
-

{t("settings.title")}

- {permissionTag} -
- - - - setRecoveryDialogVisible(false)} - footer={ - - } - width="70%" - > -
-
- {recoveryDialogString} -
- - - -
- {t("recovery.exportHint")} -
-
-
- - setLogsDialogVisible(false)} - footer={} - width="80%" - > -
- {logsText || t("settings.data.noLogs")} -
-
- - !settleLoading && setSettleDialogVisible(false)} - onOk={async () => { - if (!(window as any).api) return - setSettleLoading(true) - const res = await (window as any).api.createSettlement() - setSettleLoading(false) - if (res.success && res.data) { - messageApi.success(t("settings.data.settlementSuccess")) - emitDataUpdated("all") - setSettleDialogVisible(false) - } else { - messageApi.error(res.message || t("settings.data.settlementFailed")) - } - }} - confirmLoading={settleLoading} - okText={t("settings.data.settle")} - > -
-
{t("settings.data.settlementConfirm1")}
-
- {t("settings.data.settlementConfirm2")} -
-
-
- + color: "var(--ss-text-main)", + marginBottom: "16px", + }} + > +
+ {t("settings.data.importExport")} +
+ + + + { + const file = e.target.files?.[0] + if (file) importJson(file) + if (importInputRef.current) importInputRef.current.value = "" + }} + /> + +
+ {t("settings.data.importHint")} +
+ + + +
+ + setMcpConfig((prev) => ({ ...prev, host: e.target.value }))} + disabled={!canAdmin || mcpStatus.is_running} + style={{ width: "280px" }} + placeholder="127.0.0.1" + /> + + + { + const next = Number(e.target.value.replace(/[^\d]/g, "")) + if (Number.isFinite(next) && next > 0) { + setMcpConfig((prev) => ({ ...prev, port: next })) + } else if (!e.target.value) { + setMcpConfig((prev) => ({ ...prev, port: 0 })) + } + }} + disabled={!canAdmin || mcpStatus.is_running} + style={{ width: "180px" }} + placeholder="3901" + /> + + + + + + + + +
+
+ {t("settings.mcp.hint")} +
+ +
+ {t("settings.app.title")} +
+
+ {t("settings.app.description")} +
+
+ + + + +
+
+ ), + }, + ] + + return ( +
+ {contextHolder} +
+

{t("settings.title")}

+ {permissionTag} +
+ + + + setRecoveryDialogVisible(false)} + footer={ + + } + width="70%" + > +
+
+ {recoveryDialogString} +
+ + + +
+ {t("recovery.exportHint")} +
+
+
+ + setLogsDialogVisible(false)} + footer={} + width="80%" + > +
+ {logsText || t("settings.data.noLogs")} +
+
+ + !settleLoading && setSettleDialogVisible(false)} + onOk={async () => { + if (!(window as any).api) return + setSettleLoading(true) + const res = await (window as any).api.createSettlement() + setSettleLoading(false) + if (res.success && res.data) { + messageApi.success(t("settings.data.settlementSuccess")) + emitDataUpdated("all") + setSettleDialogVisible(false) + } else { + messageApi.error(res.message || t("settings.data.settlementFailed")) + } + }} + confirmLoading={settleLoading} + okText={t("settings.data.settle")} + > +
+
{t("settings.data.settlementConfirm1")}
+
+ {t("settings.data.settlementConfirm2")} +
+
+
+ !clearLoading && setClearDialogVisible(false)} - onOk={handleConfirmClearAll} - confirmLoading={clearLoading} - okText="确认清空" - okButtonProps={{ danger: true }} + onCancel={() => !clearLoading && setClearDialogVisible(false)} + onOk={handleConfirmClearAll} + confirmLoading={clearLoading} + okText="确认清空" + okButtonProps={{ danger: true }} > 清空后将关闭保护(无密码时默认视为管理权限)。 + + !resetLoading && setResetDialogVisible(false)} + onOk={handleConfirmResetAll} + confirmLoading={resetLoading} + okText={t("settings.data.resetAllAndReenter")} + okButtonProps={{ danger: true }} + cancelText={t("common.cancel")} + > +
+
{t("settings.data.resetConfirm1")}
+
+ {t("settings.data.resetConfirm2")} +
+
+
) } diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index a395713..7e34126 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -1,819 +1,827 @@ -{ - "common": { - "next": "Next", - "prev": "Previous", - "finish": "Finish", - "cancel": "Cancel", - "save": "Save", - "loading": "Loading...", - "confirm": "Confirm", - "success": "Success", - "error": "Error", - "delete": "Delete", - "edit": "Edit", - "add": "Add", - "search": "Search", - "clear": "Clear", - "close": "Close", - "yes": "Yes", - "no": "No", - "submit": "Submit", - "reset": "Reset", - "import": "Import", - "export": "Export", - "create": "Create", - "update": "Update", - "refresh": "Refresh", - "view": "View", - "name": "Name", - "status": "Status", - "operation": "Operation", - "description": "Description", - "total": "Total {{count}} items", - "noData": "No data", - "pleaseSelect": "Please select", - "pleaseEnter": "Please enter", - "readOnly": "Read-only mode", - "none": "None" +{ + "common": { + "next": "Next", + "prev": "Previous", + "finish": "Finish", + "cancel": "Cancel", + "save": "Save", + "loading": "Loading...", + "confirm": "Confirm", + "success": "Success", + "error": "Error", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "search": "Search", + "clear": "Clear", + "close": "Close", + "yes": "Yes", + "no": "No", + "submit": "Submit", + "reset": "Reset", + "import": "Import", + "export": "Export", + "create": "Create", + "update": "Update", + "refresh": "Refresh", + "view": "View", + "name": "Name", + "status": "Status", + "operation": "Operation", + "description": "Description", + "total": "Total {{count}} items", + "noData": "No data", + "pleaseSelect": "Please select", + "pleaseEnter": "Please enter", + "readOnly": "Read-only mode", + "none": "None" }, "oobe": { - "title": "Welcome to SecScore", - "subtitle": "Education Points Management Expert", - "step": "Step {{current}}/{{total}}", - "skip": "Skip", - "steps": { + "title": "SecScore Quick Setup", + "subtitle": "Finish the essentials in a few steps and start using the app", + "step": "Step {{current}}/{{total}}", + "skip": "Skip", + "steps": { "entry": { "title": "Get Started", "description": "Choose how you want to proceed", - "enterOobe": "Enter OOBE", + "enterOobe": "Enter Quick Setup", "connectPostgresAutoSync": "Connect PostgreSQL with Auto Sync", - "skipDirect": "Skip OOBE and Enter" + "skipDirect": "Skip Quick Setup and Enter" }, - "postgresql": { - "title": "Connect PostgreSQL", - "description": "Enter a PostgreSQL connection string. The app will connect and auto-sync before entering.", - "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", - "autoSyncAndEnter": "Connect, Auto Sync, and Enter" - }, - "language": { - "title": "Select Language", - "description": "Choose your preferred interface language" - }, - "theme": { - "title": "Set Theme", - "description": "Choose your preferred interface appearance", - "mode": "Mode", - "lightMode": "Light", - "darkMode": "Dark", - "primaryColor": "Primary Color", - "backgroundGradient": "Background Gradient" - }, - "password": { - "title": "Set Password", - "description": "Set login password to protect your data (optional)", - "adminPassword": "Admin Password", - "adminPasswordHint": "Full access to all features, 6 digits", - "pointsPassword": "Points Password", - "pointsPasswordHint": "Points operations only, 6 digits", - "passwordPlaceholder": "Enter 6 digits", - "hint": "Leave empty to skip, you can configure in settings later" - }, - "students": { - "title": "Import Student List", - "description": "Add student list to start points management", - "import": "Import List", - "manual": "Add Manually", - "importHint": "Supports Excel (.xlsx) or JSON files", - "manualHint": "You can paste multiple lines, one student name per line", - "dragDrop": "Drag and drop files here, or click to select", - "supportedFormats": "Supports .xlsx, .json formats", - "studentName": "Student Name", - "addStudent": "Add Student", - "noStudents": "No students yet, please add or import", - "studentCount": "{{count}} students added", - "studentExists": "Student already exists", - "importSuccess": "Successfully imported {{count}} students", - "noNewStudents": "No new students to import", - "parseFailed": "File parsing failed", - "unsupportedFormat": "Unsupported file format" - }, - "reasons": { - "title": "Set Reasons", - "description": "Add common point change reasons", - "addReason": "Add Reason", - "reasonName": "Reason Name", - "points": "Points", - "positive": "Add Points", - "negative": "Deduct Points", - "noReasons": "No reasons yet, please add common reasons", - "reasonCount": "{{count}} reasons added", - "reasonExists": "Reason already exists" - }, - "start": { - "title": "Ready to Go", - "description": "Everything is ready. Let's start using SecScore!", - "features": { - "score": "Easily manage student points", - "history": "Complete point change history", - "settlement": "Support settlement and archiving" - }, - "startButton": "Get Started" - } - } - }, - "settings": { - "title": "Settings", - "tabs": { - "appearance": "Appearance", - "security": "Security", - "database": "Database Connection", - "dataManagement": "Data Management", - "urlProtocol": "URL Protocol", - "about": "About" - }, - "language": "Language", - "languageHint": "Select interface display language", - "theme": "Theme", - "password": "Set Password", - "themeMode": "Mode", - "lightMode": "Light", - "darkMode": "Dark", - "primaryColor": "Primary Color", - "backgroundGradient": "Background Gradient", - "searchKeyboard": { - "title": "Search Keyboard", - "hint": "Choose the pop-up keyboard layout under the search box", - "disableToggle": "Disable Search Keyboard", - "disableHint": "When enabled, the keyboard will no longer pop up below the search box", - "options": { - "qwerty26": "26-key (QWERTY)", - "t9": "9-key (T9)" - } - }, - "interfaceZoom": "Interface Zoom", - "zoomOptions": { - "small70": "Small (70%)", - "default100": "Default (100%)", - "large150": "Large (150%)" - }, - "general": { - "saved": "Saved", - "saveFailed": "Save failed" - }, - "zoomHint": "Adjust the overall size of the application interface", - "zoomUpdated": "Interface zoom updated", - "updateFailed": "Update failed", - "security": { - "title": "Password Protection System", - "adminPassword": "Admin Password", - "pointsPassword": "Points Password", - "recoveryString": "Recovery String", - "set": "Set", - "notSet": "Not Set", - "generated": "Generated", - "notGenerated": "Not Generated", - "enterPassword": "Enter 6-digit number (leave empty to skip)", - "adminPasswordPlaceholder": "Enter 6-digit admin password (leave empty to skip)", - "pointsPasswordPlaceholder": "Enter 6-digit points password (leave empty to skip)", - "recoveryPlaceholder": "Enter recovery string", - "savePassword": "Save Password", - "savePasswords": "Save Passwords", - "generateRecovery": "Generate Recovery String", - "clearAllPasswords": "Clear All Passwords", - "recoveryReset": "Recovery String Reset", - "enterRecovery": "Enter recovery string", - "resetPassword": "Reset Password", - "resetHint": "Reset will clear admin/points passwords and generate a new recovery string.", - "confirmClear": "Confirm clearing all passwords?", - "clearHint": "After clearing, protection will be disabled (no password defaults to admin permission).", - "saved": "Password updated", - "saveFailed": "Update failed", - "generateFailed": "Generate failed", - "resetFailed": "Reset failed", - "cleared": "Cleared", - "clearFailed": "Clear failed", - "passwordClearedNewRecovery": "Password cleared, new recovery string", - "newRecoveryString": "New recovery string (please save it)" - }, - "database": { - "title": "Database Connection", - "currentStatus": "Current Database Status", - "sqliteLocal": "SQLite Local Database", - "postgresqlRemote": "PostgreSQL Remote Database", - "connected": "Connected", - "disconnected": "Disconnected", - "postgresqlConnection": "PostgreSQL Remote Connection", - "connectionHint": "Enter PostgreSQL connection string to connect to remote database for multi-device sync.", - "testConnection": "Test Connection", - "switchToPostgreSQL": "Switch to PostgreSQL", - "switchToSQLite": "Switch to Local SQLite", - "connectionTestSuccess": "Connection test successful", - "connectionTestFailed": "Connection test failed", - "switchedTo": "Switched to {{type}} database", - "switchedToSQLite": "Switched to SQLite local database", - "switchFailed": "Switch failed", - "syncDescription": "Multi-device Sync Info", - "syncPoint1": "Using PostgreSQL remote database enables multi-device data synchronization.", - "syncPoint2": "Built-in operation queue mechanism ensures data consistency during simultaneous operations.", - "syncPoint3": "Application restart required after switching database.", - "syncPoint4": "Recommended cloud database services: Neon, Supabase, AWS RDS, etc.", - "enterConnectionString": "Please enter PostgreSQL connection string", - "connectionExample": "Example: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", - "uploadButton": "Upload Local Data to Remote", - "uploadHint": "After connecting remote database, upload and merge local SQLite history into remote.", - "uploadNeedRemote": "Please connect to PostgreSQL remote database first", - "noNeedUpload": "Local and remote data are already consistent", - "uploadConfirmTitle": "Upload local data to remote?", - "uploadConfirmContent": "Sync will prefer local data. Detected local-only {{localOnly}}, remote-only {{remoteOnly}}, conflicts {{conflicts}}.", - "uploadSuccess": "Local data uploaded to remote", - "uploadFailed": "Upload failed" - }, + "postgresql": { + "title": "Connect PostgreSQL", + "description": "Enter a PostgreSQL connection string. The app will connect and auto-sync before entering.", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "Connect, Auto Sync, and Enter" + }, + "language": { + "title": "Select Language", + "description": "Choose your preferred interface language" + }, + "theme": { + "title": "Set Theme", + "description": "Choose your preferred interface appearance", + "mode": "Mode", + "lightMode": "Light", + "darkMode": "Dark", + "primaryColor": "Primary Color", + "backgroundGradient": "Background Gradient" + }, + "password": { + "title": "Set Password", + "description": "Set login password to protect your data (optional)", + "adminPassword": "Admin Password", + "adminPasswordHint": "Full access to all features, 6 digits", + "pointsPassword": "Points Password", + "pointsPasswordHint": "Points operations only, 6 digits", + "passwordPlaceholder": "Enter 6 digits", + "hint": "Leave empty to skip, you can configure in settings later" + }, + "students": { + "title": "Import Student List", + "description": "Add student list to start points management", + "import": "Import List", + "manual": "Add Manually", + "importHint": "Supports Excel (.xlsx) or JSON files", + "manualHint": "You can paste multiple lines, one student name per line", + "dragDrop": "Drag and drop files here, or click to select", + "supportedFormats": "Supports .xlsx, .json formats", + "studentName": "Student Name", + "addStudent": "Add Student", + "noStudents": "No students yet, please add or import", + "studentCount": "{{count}} students added", + "studentExists": "Student already exists", + "importSuccess": "Successfully imported {{count}} students", + "noNewStudents": "No new students to import", + "parseFailed": "File parsing failed", + "unsupportedFormat": "Unsupported file format" + }, + "reasons": { + "title": "Set Reasons", + "description": "Add common point change reasons", + "addReason": "Add Reason", + "reasonName": "Reason Name", + "points": "Points", + "positive": "Add Points", + "negative": "Deduct Points", + "noReasons": "No reasons yet, please add common reasons", + "reasonCount": "{{count}} reasons added", + "reasonExists": "Reason already exists" + }, + "start": { + "title": "Ready to Go", + "description": "Everything is ready. Let's start using SecScore!", + "features": { + "score": "Easily manage student points", + "history": "Complete point change history", + "settlement": "Support settlement and archiving" + }, + "startButton": "Get Started" + } + } + }, + "settings": { + "title": "Settings", + "tabs": { + "appearance": "Appearance", + "security": "Security", + "database": "Database Connection", + "dataManagement": "Data Management", + "urlProtocol": "URL Protocol", + "about": "About" + }, + "language": "Language", + "languageHint": "Select interface display language", + "theme": "Theme", + "password": "Set Password", + "themeMode": "Mode", + "lightMode": "Light", + "darkMode": "Dark", + "primaryColor": "Primary Color", + "backgroundGradient": "Background Gradient", + "searchKeyboard": { + "title": "Search Keyboard", + "hint": "Choose the pop-up keyboard layout under the search box", + "disableToggle": "Disable Search Keyboard", + "disableHint": "When enabled, the keyboard will no longer pop up below the search box", + "options": { + "qwerty26": "26-key (QWERTY)", + "t9": "9-key (T9)" + } + }, + "interfaceZoom": "Interface Zoom", + "zoomOptions": { + "small70": "Small (70%)", + "default100": "Default (100%)", + "large150": "Large (150%)" + }, + "general": { + "saved": "Saved", + "saveFailed": "Save failed" + }, + "zoomHint": "Adjust the overall size of the application interface", + "zoomUpdated": "Interface zoom updated", + "updateFailed": "Update failed", + "security": { + "title": "Password Protection System", + "adminPassword": "Admin Password", + "pointsPassword": "Points Password", + "recoveryString": "Recovery String", + "set": "Set", + "notSet": "Not Set", + "generated": "Generated", + "notGenerated": "Not Generated", + "enterPassword": "Enter 6-digit number (leave empty to skip)", + "adminPasswordPlaceholder": "Enter 6-digit admin password (leave empty to skip)", + "pointsPasswordPlaceholder": "Enter 6-digit points password (leave empty to skip)", + "recoveryPlaceholder": "Enter recovery string", + "savePassword": "Save Password", + "savePasswords": "Save Passwords", + "generateRecovery": "Generate Recovery String", + "clearAllPasswords": "Clear All Passwords", + "recoveryReset": "Recovery String Reset", + "enterRecovery": "Enter recovery string", + "resetPassword": "Reset Password", + "resetHint": "Reset will clear admin/points passwords and generate a new recovery string.", + "confirmClear": "Confirm clearing all passwords?", + "clearHint": "After clearing, protection will be disabled (no password defaults to admin permission).", + "saved": "Password updated", + "saveFailed": "Update failed", + "generateFailed": "Generate failed", + "resetFailed": "Reset failed", + "cleared": "Cleared", + "clearFailed": "Clear failed", + "passwordClearedNewRecovery": "Password cleared, new recovery string", + "newRecoveryString": "New recovery string (please save it)" + }, + "database": { + "title": "Database Connection", + "currentStatus": "Current Database Status", + "sqliteLocal": "SQLite Local Database", + "postgresqlRemote": "PostgreSQL Remote Database", + "connected": "Connected", + "disconnected": "Disconnected", + "postgresqlConnection": "PostgreSQL Remote Connection", + "connectionHint": "Enter PostgreSQL connection string to connect to remote database for multi-device sync.", + "testConnection": "Test Connection", + "switchToPostgreSQL": "Switch to PostgreSQL", + "switchToSQLite": "Switch to Local SQLite", + "connectionTestSuccess": "Connection test successful", + "connectionTestFailed": "Connection test failed", + "switchedTo": "Switched to {{type}} database", + "switchedToSQLite": "Switched to SQLite local database", + "switchFailed": "Switch failed", + "syncDescription": "Multi-device Sync Info", + "syncPoint1": "Using PostgreSQL remote database enables multi-device data synchronization.", + "syncPoint2": "Built-in operation queue mechanism ensures data consistency during simultaneous operations.", + "syncPoint3": "Application restart required after switching database.", + "syncPoint4": "Recommended cloud database services: Neon, Supabase, AWS RDS, etc.", + "enterConnectionString": "Please enter PostgreSQL connection string", + "connectionExample": "Example: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "Upload Local Data to Remote", + "uploadHint": "After connecting remote database, upload and merge local SQLite history into remote.", + "uploadNeedRemote": "Please connect to PostgreSQL remote database first", + "noNeedUpload": "Local and remote data are already consistent", + "uploadConfirmTitle": "Upload local data to remote?", + "uploadConfirmContent": "Sync will prefer local data. Detected local-only {{localOnly}}, remote-only {{remoteOnly}}, conflicts {{conflicts}}.", + "uploadSuccess": "Local data uploaded to remote", + "uploadFailed": "Upload failed" + }, "data": { "title": "Data Management", "settlement": "Settlement", "settlementAndRestart": "Settle and Restart", "settlementHint": "Mark current records as a phase and reset all student points to zero.", + "quickSetupReset": "Quick Setup Reset", + "resetAllAndReenter": "Delete all data and re-enter Quick Setup", + "resetHint": "Delete students, reasons, records, rewards, tags, and all settings, then return to Quick Setup.", "importExport": "Import / Export", - "exportJson": "Export JSON", - "importJson": "Import JSON", - "importHint": "Import will overwrite existing students/reasons/records/settings (security settings excluded).", - "logs": "Logs", - "logLevel": "Log Level", - "logOperation": "Log Operation", - "viewLogs": "View Logs", - "exportLogs": "Export Logs", - "clearLogs": "Clear Logs", - "logsCleared": "Logs cleared", - "systemLogs": "System Logs (Last 200)", - "noLogs": "No logs", - "logLevelUpdated": "Log level updated", - "readLogsFailed": "Failed to read logs", - "logsExported": "Logs exported", - "exportFailed": "Export failed", - "exportSuccess": "Export successful", + "exportJson": "Export JSON", + "importJson": "Import JSON", + "importHint": "Import will overwrite existing students/reasons/records/settings (security settings excluded).", + "logs": "Logs", + "logLevel": "Log Level", + "logOperation": "Log Operation", + "viewLogs": "View Logs", + "exportLogs": "Export Logs", + "clearLogs": "Clear Logs", + "logsCleared": "Logs cleared", + "systemLogs": "System Logs (Last 200)", + "noLogs": "No logs", + "logLevelUpdated": "Log level updated", + "readLogsFailed": "Failed to read logs", + "logsExported": "Logs exported", + "exportFailed": "Export failed", + "exportSuccess": "Export successful", "importSuccess": "Import successful, refreshing", "importFailed": "Import failed", + "resetSuccess": "Reset complete, reopening Quick Setup", + "resetFailed": "Reset failed", "clearFailed": "Clear failed", "confirmSettlement": "Confirm settlement and restart?", "settlementConfirm1": "This will archive current unsettled records as a phase and reset all student points to zero.", "settlementConfirm2": "Student list remains unchanged; settlement history available in 'Settlements' page.", + "confirmResetAll": "Delete all data and re-enter Quick Setup?", + "resetConfirm1": "This will delete students, reasons, score records, rewards, tags, and all settings. This cannot be undone.", + "resetConfirm2": "After reset, the app will reload and open Quick Setup directly.", "settlementSuccess": "Settlement successful, points restarted", "settlementFailed": "Settlement failed", "settle": "Settle", - "logLevels": { - "debug": "DEBUG", - "info": "INFO", - "warn": "WARN", - "error": "ERROR" - } - }, - "url": { - "title": "URL Protocol (secscore://)", - "protocol": "URL Protocol", - "description": "Can invoke SecScore via URL to perform actions, for example:", - "register": "Register URL Protocol", - "registered": "URL protocol registered", - "registerFailed": "Registration failed", - "installerRequired": "Requires installed SecScore, may not work in development mode." - }, - "mcp": { - "title": "MCP Service", - "description": "Manage the built-in MCP HTTP service for external MCP clients.", - "running": "Running", - "stopped": "Stopped", - "noUrl": "No URL", - "host": "Host", - "port": "Port", - "start": "Start", - "stop": "Stop", - "refresh": "Refresh Status", - "hint": "Recommended to bind only to 127.0.0.1; after start, endpoint is http://host:port/mcp", - "hostRequired": "Please enter MCP host", - "portInvalid": "Please enter a valid port (1-65535)", - "startSuccess": "MCP server started", - "startFailed": "Failed to start MCP server", - "stopSuccess": "MCP server stopped", - "stopFailed": "Failed to stop MCP server", - "startTimeout": "MCP server start timed out, please try again", - "stopTimeout": "MCP server stop timed out, please try again" - }, - "app": { - "title": "App Controls", - "description": "Quickly restart or quit the app.", - "restart": "Restart App", - "quit": "Quit App", - "confirmRestartTitle": "Restart the app now?", - "confirmRestartContent": "The app will close and start again immediately.", - "confirmQuitTitle": "Quit the app now?", - "confirmQuitContent": "The app will exit immediately.", - "restartFailed": "Restart failed", - "quitFailed": "Quit failed" - }, - "about": { - "title": "About", - "appName": "Education Points Management", - "version": "Version", - "copyright": "Copyright", - "ipcStatus": "IPC Status", - "ipcConnected": "Connected", - "ipcDisconnected": "Disconnected (Preload failed)", - "environment": "Environment", - "toggleDevTools": "Toggle Developer Tools", - "license": "SecScore is licensed under GPL3.0", - "copyRightText": "CopyRight © 2025-{{year}} SECTL" - } - }, - "sidebar": { - "home": "Home", - "students": "Students", - "score": "Score", - "boards": "Boards", - "leaderboard": "Leaderboard", - "settlements": "Settlements", - "reasons": "Reasons", - "autoScore": "Auto Score", - "rewardExchange": "Reward Exchange", - "rewardSettings": "Reward Settings", - "settings": "Settings", - "remoteDb": "Remote Database", - "refreshStatus": "Refresh Status", - "syncNow": "Sync Now", - "syncSuccess": "Sync successful", - "syncFailed": "Sync failed", - "getDbStatusFailed": "Failed to get database status", - "notRemoteMode": "Not in remote database mode, please restart application", - "dbNotConnected": "Database not connected" - }, - "auth": { - "unlock": "Unlock Permission", - "unlockHint": "Enter 6-digit password: Admin password = full access, Points password = points operations only.", - "unlockButton": "Unlock", - "unlocked": "Permission unlocked", - "logout": "Switched to read-only", - "passwordPlaceholder": "e.g. 123456", - "passwordError": "Incorrect password", - "enterPassword": "Enter Password", - "lock": "Lock" - }, - "languages": { - "zh-CN": "简体中文", - "en-US": "English" - }, - "theme": { - "colorAndBackground": "Color & Background", - "gradientLabels": { - "blue": "Fresh Blue", - "pink": "Soft Pink", - "cyan": "Cyan", - "purple": "Purple" - }, - "gradientDirections": { - "vertical": "Vertical", - "horizontal": "Horizontal", - "diagonal": "Diagonal" - }, - "generate": "Generate", - "saved": "Saved", - "saveFailed": "Save failed", - "mode": "Mode", - "myTheme": "My Theme" - }, - "permissions": { - "admin": "Admin", - "points": "Points", - "view": "Read-only" - }, - "home": { - "title": "Student Points Home", - "subtitle": "{{count}} students total, click card to operate", - "searchPlaceholder": "Search name/pinyin...", - "sortBy": { - "alphabet": "Name Sort", - "surname": "Surname Group", - "score": "Points Rank" - }, - "layoutBy": { - "grouped": "Grouped List", - "squareGrid": "Square Grid" - }, - "noStudents": "No student data, please add in Student Management", - "noMatch": "No matching students found", - "clearSearch": "Clear search", - "points": "pts", - "currentScore": "Current Points", - "quickOptions": "Quick Options", - "adjustPoints": "Adjust Points", - "customPoints": "Custom Points", - "customPointsHint": "Enter any value in the input box", - "reason": "Reason", - "reasonPlaceholder": "Enter reason for points change (optional)", - "preview": "Change Preview", - "noReason": "(No reason)", - "category": { - "others": "Others" - }, - "scoreAdded": "Added {{points}} points for {{name}}", - "scoreDeducted": "Deducted {{points}} points for {{name}}", - "submitFailed": "Submit failed", - "pleaseSelectPoints": "Please select or enter points", - "reasonDefault": "{{action}} {{points}} points", - "studentCount": "{{count}} students", - "operationTitle": "Points Operation: {{name}}", - "submitOperation": "Submit Operation", - "operationTitleBatch": "Points Operation: {{count}} Selected", - "undoLastAction": "Undo Last", - "undoLastHint": "Undo latest: {{name}} {{delta}}", - "undoUnavailable": "No points operation available to undo", - "undoLastSuccess": "Last points operation has been undone", - "multiSelect": "Multi-select", - "batchMode": "Batch Mode", - "selectAll": "Select All", - "clearSelected": "Clear Selected", - "batchOperate": "Batch Points", - "selected": "Selected", - "selectedCount": "{{count}} selected", - "batchSuccess": "Submitted points for {{count}} students", - "batchPartial": "Successfully submitted for {{success}}/{{total}} students", - "selectStudentFirst": "Please select student(s) first", - "addPoints": "Add Points", - "deductPoints": "Deduct Points", - "pointsChange": "Points Change", - "reasonLabel": "Reason: " - }, - "students": { - "title": "Student Management", - "importList": "Import List", - "addStudent": "Add Student", - "name": "Name", - "avatar": "Avatar", - "currentScore": "Current Points", - "tags": "Tags", - "noTags": "No tags", - "editTags": "Edit Tags", - "editAvatar": "Set Avatar", - "deleteConfirm": "Confirm delete this student?", - "addTitle": "Add Student", - "addConfirm": "Add", - "namePlaceholder": "Enter student name", - "nameRequired": "Please enter name", - "nameExists": "Student name already exists", - "addSuccess": "Added successfully", - "addFailed": "Add failed", - "deleteSuccess": "Deleted successfully", - "deleteFailed": "Delete failed", - "tagSaveSuccess": "Tags saved successfully", - "tagSaveFailed": "Failed to save tags", - "importTitle": "Import List", - "importTextHint": "Paste multiple lines, one student name per line", - "importTextPlaceholder": "Example:\nAlice\nBob\nCharlie", - "importByText": "Import from Text", - "importByXlsx": "Import via xlsx", - "xlsxPreview": "XLSX Preview & Import", - "file": "File", - "selectNameCol": "Click header to select name column", - "previewRows": "Preview first 50 rows", - "importConfirm": "Import", - "importComplete": "Import complete: {{inserted}} added, {{skipped}} skipped", - "workerNotReady": "Worker not initialized", - "parseXlsxFailed": "Failed to parse xlsx", - "selectNameColFirst": "Please select \"Name Column\" first", - "noNamesFound": "No importable names found in selected column", - "importFailed": "Import failed", - "editTagTitle": "Edit Tags - {{name}}", - "editAvatarTitle": "Set Avatar - {{name}}", - "avatarUpload": "Upload Image", - "avatarClear": "Clear Avatar", - "noAvatar": "No Avatar", - "avatarTip": "Supports common image formats, up to 2MB", - "avatarSaveSuccess": "Avatar saved", - "avatarSaveFailed": "Failed to save avatar", - "avatarInvalidFile": "Please choose an image file", - "avatarTooLarge": "Image must be smaller than 2MB", - "avatarReadFailed": "Failed to read image" - }, - "score": { - "title": "Points Management", - "student": "Student", - "change": "Change", - "reason": "Reason", - "time": "Time", - "undoConfirm": "Confirm undo this record? Student points will be rolled back.", - "undo": "Undo", - "undoSuccess": "Operation undone", - "undoFailed": "Undo failed", - "recentRecords": "Recent Records", - "pleaseSelectStudent": "Please select or search student", - "selectAllStudents": "Whole Class", - "clearSelectedStudents": "Clear", - "selectedCount": "Selected {{selected}} / {{total}}", - "points": "Points", - "addPoints": "Add", - "deductPoints": "Deduct", - "quickReason": "Quick Reason", - "selectReason": "Select preset reason", - "reasonContent": "Reason Content", - "reasonPlaceholder": "Manual input or select quick reason", - "submit": "Confirm Submit", - "pleaseEnterInfo": "Please fill in complete information", - "pleaseEnterPoints": "Please enter points or select preset reason", - "batchSuccess": "Submitted points for {{count}} students", - "batchPartial": "Successfully submitted for {{success}}/{{total}} students" - }, - "reasons": { - "title": "Reason Management", - "addReason": "Add Preset Reason", - "category": "Category", - "content": "Reason Content", - "presetPoints": "Preset Points", - "deleteConfirm": "Confirm delete this reason?", - "addTitle": "Add Reason", - "addConfirm": "Add", - "categoryPlaceholder": "e.g. Study, Discipline", - "contentPlaceholder": "Enter reason", - "pointsPlaceholder": "e.g. 2 or -2", - "contentRequired": "Please enter reason content", - "pointsRequired": "Please enter preset points", - "reasonExists": "Same reason already exists in this category", - "addSuccess": "Added successfully", - "addFailed": "Add failed", - "deleteSuccess": "Deleted successfully", - "deleteFailed": "Delete failed", - "others": "Others" - }, - "rewardExchange": { - "title": "Reward Exchange", - "enterMode": "Enter Exchange Mode", - "exitMode": "Exit Exchange Mode", - "modeHint": "Reward exchange mode is active. Click a student card to choose an available reward.", - "normalHint": "Current cards show original score. Enter mode to display redeemable reward points.", - "remainingRewardPoints": "Remaining Reward Points", - "currentScore": "Current Score", - "chooseRewardTitle": "Choose Reward for {{name}}", - "currentRewardPoints": "Current reward points: {{points}}", - "costLabel": "Cost points: {{points}}", - "redeemNow": "Redeem", - "noAffordableRewards": "No rewards can be redeemed now. Earn more points first.", - "redeemSuccess": "{{student}} redeemed \"{{reward}}\" and spent {{points}} points", - "redeemFailed": "Redeem failed", - "recordsTitle": "Redemption Records", - "recordStudent": "Student", - "recordReward": "Reward", - "recordCost": "Cost", - "recordTime": "Redeemed At" - }, - "rewardSettings": { - "title": "Reward Settings", - "addReward": "Add Reward", - "addTitle": "Add Reward", - "editTitle": "Edit Reward", - "rewardName": "Reward Name", - "costPoints": "Cost Points", - "namePlaceholder": "e.g. Homework-Free Card", - "costPlaceholder": "Enter cost points", - "nameRequired": "Please enter reward name", - "costRequired": "Please enter positive integer points", - "createSuccess": "Reward created", - "createFailed": "Create reward failed", - "updateSuccess": "Reward updated", - "updateFailed": "Update reward failed", - "deleteSuccess": "Reward deleted", - "deleteFailed": "Delete reward failed", - "deleteConfirm": "Delete reward \"{{name}}\"?" - }, - "board": { - "title": "Boards", - "editable": "Editable", - "readonly": "Read-only", - "addBoard": "Add Board", - "removeBoard": "Delete Board", - "removeBoardConfirm": "Delete current board?", - "boardConfig": "Board Config", - "boardNamePlaceholder": "Enter board name", - "renameBoard": "Rename Board", - "newBoard": "New Board", - "untitledBoard": "Untitled Board", - "addList": "Add Student List", - "removeList": "Delete List", - "removeListConfirm": "Delete current student list?", - "editList": "Edit", - "sqlEditorTitle": "Edit Student List", - "splitHorizontal": "Split Left/Right", - "splitVertical": "Split Top/Bottom", - "dragHandle": "Drag to resize layout", - "listNamePlaceholder": "Enter list name", - "newList": "New List", - "applyPreset": "Apply Preset", - "run": "Run SQL", - "runAll": "Run All", - "viewModes": { - "list": "List View", - "card": "Card View", - "grid": "Grid View" - }, - "metrics": { - "totalScore": "Score", - "rewardPoints": "Reward", - "weekChange": "7d", - "weekDeducted": "7d Deduct", - "todayAnswered": "Today" - }, - "sqlPlaceholder": "Enter SQL (single SELECT / WITH query only)", - "templateHint": "Template variables: {{today_start}}, {{since_7d}}, {{since_30d}}, {{now}}", - "templateDescription": "Use template variables directly in SQL. They will be replaced with ISO timestamps at runtime for both SQLite and PostgreSQL.", - "saving": "Saving...", - "saveFailed": "Failed to save boards", - "runFailed": "Failed to run query", - "keepAtLeastOneBoard": "Keep at least one board", - "keepAtLeastOneList": "Keep at least one student list", - "presets": { - "weekLowDeduct": { - "name": "Low Deduction (7d)", - "description": "Rank students with weekly deduction < 3" - }, - "todayActive": { - "name": "Today's Activity", - "description": "Sort by today's answer count and score change" - }, - "rewardRanking": { - "name": "Reward Ranking", - "description": "Sort by reward points and total score" - } - } - }, - "leaderboard": { - "title": "Points Leaderboard", - "rank": "Rank", - "name": "Name", - "totalScore": "Total Points", - "todayChange": "Today's Change", - "weekChange": "This Week's Change", - "monthChange": "This Month's Change", - "viewHistory": "View", - "exportXlsx": "Export XLSX", - "exportSuccess": "Export successful", - "queryFailed": "Query failed", - "historyTitle": "{{name}} - Operation History", - "close": "Close", - "today": "Today", - "week": "This Week", - "month": "This Month", - "operationRecord": "Operation Record", - "change": "Change" - }, - "settlements": { - "title": "Settlement History", - "phase": "Phase #{{id}}", - "viewLeaderboard": "View Leaderboard", - "recordCount": "Records: {{count}}", - "noRecords": "No settlement records", - "noSettlements": "No settlement records", - "back": "Back", - "leaderboardTitle": "Settlement Leaderboard", - "phaseScore": "Phase Points", - "queryFailed": "Query failed", - "rank": "Rank", - "name": "Name" - }, - "autoScore": { - "title": "Auto Score Management", - "name": "Automation Name", - "namePlaceholder": "e.g. Daily Check-in Points", - "nameRequired": "Please enter automation name", - "triggers": "Triggers", - "actions": "Actions", - "applicableStudents": "Applicable Students", - "allStudents": "All Students", - "lastExecuted": "Last Executed", - "notExecuted": "Not executed", - "invalidTime": "Invalid time", - "addTrigger": "Add Rule", - "addAction": "Add Action", - "whenTriggered": "When the following rules trigger", - "triggeredActions": "Actions triggered when rules are met", - "addAutomation": "Add Automation", - "updateAutomation": "Update Automation", - "cancelEdit": "Cancel Edit", - "resetForm": "Reset Form", - "deleteConfirm": "Confirm delete this automation?", - "adminRequired": "Admin permission required to view auto score automation", - "adminCreateRequired": "Admin permission required to create or update auto score automation", - "adminDeleteRequired": "Admin permission required to delete auto score automation", - "adminToggleRequired": "Admin permission required to enable/disable auto score automation", - "fetchFailed": "Failed to get automation", - "triggerRequired": "Please add at least one trigger", - "actionRequired": "Please add at least one action", - "createSuccess": "Automation created successfully", - "updateSuccess": "Automation updated successfully", - "createFailed": "Failed to create automation", - "updateFailed": "Failed to update automation", - "deleteSuccess": "Automation deleted successfully", - "deleteFailed": "Failed to delete automation", - "enabled": "Automation enabled", - "disabled": "Automation disabled", - "enableFailed": "Failed to enable automation", - "disableFailed": "Failed to disable automation", - "noTriggerAvailable": "No trigger types available, please check configuration", - "noActionAvailable": "No action types available, please check configuration", - "sort": "Sort", - "status": "Status", - "triggerCount": "{{count}} triggers", - "actionCount": "{{count}} actions", - "studentCount": "{{count}} students", - "studentPlaceholder": "Please select or search students (leave empty for all students)", - "triggerCondition": "Trigger Condition", - "executeAction": "Execute Action", - "addOperation": "Add Operation", - "scoreLabel": "Score", - "tagNameLabel": "Tag Name", - "operationNoteLabel": "Operation Note (Optional)", - "intervalMinutesPlaceholder": "e.g. 1440", - "tagNamesPlaceholder": "e.g. excellent_student,class_monitor", - "triggerIntervalTime": "Interval Time", - "triggerStudentTag": "Student Tag", - "actionAddScore": "Add Score", - "actionAddTag": "Add Tag", - "minutesPlaceholder": "Enter minutes", - "minutes": "minutes", - "tagsPlaceholder": "Enter tag name", - "scorePlaceholder": "Enter score", - "tagNamePlaceholder": "Enter tag name", - "reasonPlaceholder": "Enter reason", - "relationAnd": "AND", - "relationOr": "OR" - }, - "triggers": { - "studentTag": { - "label": "When student matches tag", - "description": "Trigger automation when student matches specific tag", - "placeholder": "Please select tag", - "required": "Please enter tag name" - }, - "randomTime": { - "label": "Random time trigger", - "description": "Trigger automation randomly within specified time range", - "from": "From", - "to": "To", - "hour": "h", - "minHourPlaceholder": "Min hour", - "maxHourPlaceholder": "Max hour", - "required": "Please enter valid time range (minutes)" - }, - "intervalTime": { - "label": "Interval time trigger", - "description": "Trigger automation when interval time is reached", - "monthLabel": "Month", - "weekLabel": "Week", - "timeLabel": "Time", - "days": "Days", - "minutes": "Minutes", - "placeholderMinutes": "Enter interval (minutes)", - "placeholderDays": "Enter interval (days)", - "required": "Please enter valid time interval (minutes)" - } - }, - "actions": { - "sendNotification": { - "label": "Send Notification", - "description": "Send notification to student", - "placeholder": "Enter notification content" - }, - "addTag": { - "label": "Add Tag", - "description": "Add tag to student", - "placeholder": "Enter tag" - }, - "addScore": { - "label": "Add Score", - "description": "Add score to student", - "pointsPlaceholder": "Enter points", - "reasonPlaceholder": "Enter reason" - } - }, - "wizard": { - "welcomeTitle": "Welcome to SecScore Points Management", - "welcomeDesc": "Thanks for choosing SecScore. Before starting, please spend a minute to complete basic configuration.", - "configComplete": "Configuration complete!", - "configFailed": "Configuration save failed", - "startJourney": "Start Points Journey" - }, - "tags": { - "editTitle": "Edit Tags", - "inputPlaceholder": "Enter tag name, press Enter to add", - "selectedTags": "Selected Tags (click to remove)", - "noTagsSelected": "No tags selected", - "availableTags": "Available Tags (click to select)", - "noAvailableTags": "No available tags", - "fetchFailed": "Failed to fetch tags", - "nameTooLong": "Tag name cannot exceed 30 characters", - "addFailed": "Failed to add tag", - "deleteSuccess": "Tag deleted successfully", - "deleteFailed": "Failed to delete tag" - }, - "recovery": { - "title": "SecScore Recovery String", - "saved": "I have saved", - "export": "Export Text File", - "exportHint": "It is recommended to export and store offline. If lost, it cannot be recovered.", - "hint": "Recommended to save offline after export, cannot be recovered if lost." - } -} + "logLevels": { + "debug": "DEBUG", + "info": "INFO", + "warn": "WARN", + "error": "ERROR" + } + }, + "url": { + "title": "URL Protocol (secscore://)", + "protocol": "URL Protocol", + "description": "Can invoke SecScore via URL to perform actions, for example:", + "register": "Register URL Protocol", + "registered": "URL protocol registered", + "registerFailed": "Registration failed", + "installerRequired": "Requires installed SecScore, may not work in development mode." + }, + "mcp": { + "title": "MCP Service", + "description": "Manage the built-in MCP HTTP service for external MCP clients.", + "running": "Running", + "stopped": "Stopped", + "noUrl": "No URL", + "host": "Host", + "port": "Port", + "start": "Start", + "stop": "Stop", + "refresh": "Refresh Status", + "hint": "Recommended to bind only to 127.0.0.1; after start, endpoint is http://host:port/mcp", + "hostRequired": "Please enter MCP host", + "portInvalid": "Please enter a valid port (1-65535)", + "startSuccess": "MCP server started", + "startFailed": "Failed to start MCP server", + "stopSuccess": "MCP server stopped", + "stopFailed": "Failed to stop MCP server", + "startTimeout": "MCP server start timed out, please try again", + "stopTimeout": "MCP server stop timed out, please try again" + }, + "app": { + "title": "App Controls", + "description": "Quickly restart or quit the app.", + "restart": "Restart App", + "quit": "Quit App", + "confirmRestartTitle": "Restart the app now?", + "confirmRestartContent": "The app will close and start again immediately.", + "confirmQuitTitle": "Quit the app now?", + "confirmQuitContent": "The app will exit immediately.", + "restartFailed": "Restart failed", + "quitFailed": "Quit failed" + }, + "about": { + "title": "About", + "appName": "Education Points Management", + "version": "Version", + "copyright": "Copyright", + "ipcStatus": "IPC Status", + "ipcConnected": "Connected", + "ipcDisconnected": "Disconnected (Preload failed)", + "environment": "Environment", + "toggleDevTools": "Toggle Developer Tools", + "license": "SecScore is licensed under GPL3.0", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "Home", + "students": "Students", + "score": "Score", + "boards": "Boards", + "leaderboard": "Leaderboard", + "settlements": "Settlements", + "reasons": "Reasons", + "autoScore": "Auto Score", + "rewardExchange": "Reward Exchange", + "rewardSettings": "Reward Settings", + "settings": "Settings", + "remoteDb": "Remote Database", + "refreshStatus": "Refresh Status", + "syncNow": "Sync Now", + "syncSuccess": "Sync successful", + "syncFailed": "Sync failed", + "getDbStatusFailed": "Failed to get database status", + "notRemoteMode": "Not in remote database mode, please restart application", + "dbNotConnected": "Database not connected" + }, + "auth": { + "unlock": "Unlock Permission", + "unlockHint": "Enter 6-digit password: Admin password = full access, Points password = points operations only.", + "unlockButton": "Unlock", + "unlocked": "Permission unlocked", + "logout": "Switched to read-only", + "passwordPlaceholder": "e.g. 123456", + "passwordError": "Incorrect password", + "enterPassword": "Enter Password", + "lock": "Lock" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English" + }, + "theme": { + "colorAndBackground": "Color & Background", + "gradientLabels": { + "blue": "Fresh Blue", + "pink": "Soft Pink", + "cyan": "Cyan", + "purple": "Purple" + }, + "gradientDirections": { + "vertical": "Vertical", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "generate": "Generate", + "saved": "Saved", + "saveFailed": "Save failed", + "mode": "Mode", + "myTheme": "My Theme" + }, + "permissions": { + "admin": "Admin", + "points": "Points", + "view": "Read-only" + }, + "home": { + "title": "Student Points Home", + "subtitle": "{{count}} students total, click card to operate", + "searchPlaceholder": "Search name/pinyin...", + "sortBy": { + "alphabet": "Name Sort", + "surname": "Surname Group", + "score": "Points Rank" + }, + "layoutBy": { + "grouped": "Grouped List", + "squareGrid": "Square Grid" + }, + "noStudents": "No student data, please add in Student Management", + "noMatch": "No matching students found", + "clearSearch": "Clear search", + "points": "pts", + "currentScore": "Current Points", + "quickOptions": "Quick Options", + "adjustPoints": "Adjust Points", + "customPoints": "Custom Points", + "customPointsHint": "Enter any value in the input box", + "reason": "Reason", + "reasonPlaceholder": "Enter reason for points change (optional)", + "preview": "Change Preview", + "noReason": "(No reason)", + "category": { + "others": "Others" + }, + "scoreAdded": "Added {{points}} points for {{name}}", + "scoreDeducted": "Deducted {{points}} points for {{name}}", + "submitFailed": "Submit failed", + "pleaseSelectPoints": "Please select or enter points", + "reasonDefault": "{{action}} {{points}} points", + "studentCount": "{{count}} students", + "operationTitle": "Points Operation: {{name}}", + "submitOperation": "Submit Operation", + "operationTitleBatch": "Points Operation: {{count}} Selected", + "undoLastAction": "Undo Last", + "undoLastHint": "Undo latest: {{name}} {{delta}}", + "undoUnavailable": "No points operation available to undo", + "undoLastSuccess": "Last points operation has been undone", + "multiSelect": "Multi-select", + "batchMode": "Batch Mode", + "selectAll": "Select All", + "clearSelected": "Clear Selected", + "batchOperate": "Batch Points", + "selected": "Selected", + "selectedCount": "{{count}} selected", + "batchSuccess": "Submitted points for {{count}} students", + "batchPartial": "Successfully submitted for {{success}}/{{total}} students", + "selectStudentFirst": "Please select student(s) first", + "addPoints": "Add Points", + "deductPoints": "Deduct Points", + "pointsChange": "Points Change", + "reasonLabel": "Reason: " + }, + "students": { + "title": "Student Management", + "importList": "Import List", + "addStudent": "Add Student", + "name": "Name", + "avatar": "Avatar", + "currentScore": "Current Points", + "tags": "Tags", + "noTags": "No tags", + "editTags": "Edit Tags", + "editAvatar": "Set Avatar", + "deleteConfirm": "Confirm delete this student?", + "addTitle": "Add Student", + "addConfirm": "Add", + "namePlaceholder": "Enter student name", + "nameRequired": "Please enter name", + "nameExists": "Student name already exists", + "addSuccess": "Added successfully", + "addFailed": "Add failed", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Delete failed", + "tagSaveSuccess": "Tags saved successfully", + "tagSaveFailed": "Failed to save tags", + "importTitle": "Import List", + "importTextHint": "Paste multiple lines, one student name per line", + "importTextPlaceholder": "Example:\nAlice\nBob\nCharlie", + "importByText": "Import from Text", + "importByXlsx": "Import via xlsx", + "xlsxPreview": "XLSX Preview & Import", + "file": "File", + "selectNameCol": "Click header to select name column", + "previewRows": "Preview first 50 rows", + "importConfirm": "Import", + "importComplete": "Import complete: {{inserted}} added, {{skipped}} skipped", + "workerNotReady": "Worker not initialized", + "parseXlsxFailed": "Failed to parse xlsx", + "selectNameColFirst": "Please select \"Name Column\" first", + "noNamesFound": "No importable names found in selected column", + "importFailed": "Import failed", + "editTagTitle": "Edit Tags - {{name}}", + "editAvatarTitle": "Set Avatar - {{name}}", + "avatarUpload": "Upload Image", + "avatarClear": "Clear Avatar", + "noAvatar": "No Avatar", + "avatarTip": "Supports common image formats, up to 2MB", + "avatarSaveSuccess": "Avatar saved", + "avatarSaveFailed": "Failed to save avatar", + "avatarInvalidFile": "Please choose an image file", + "avatarTooLarge": "Image must be smaller than 2MB", + "avatarReadFailed": "Failed to read image" + }, + "score": { + "title": "Points Management", + "student": "Student", + "change": "Change", + "reason": "Reason", + "time": "Time", + "undoConfirm": "Confirm undo this record? Student points will be rolled back.", + "undo": "Undo", + "undoSuccess": "Operation undone", + "undoFailed": "Undo failed", + "recentRecords": "Recent Records", + "pleaseSelectStudent": "Please select or search student", + "selectAllStudents": "Whole Class", + "clearSelectedStudents": "Clear", + "selectedCount": "Selected {{selected}} / {{total}}", + "points": "Points", + "addPoints": "Add", + "deductPoints": "Deduct", + "quickReason": "Quick Reason", + "selectReason": "Select preset reason", + "reasonContent": "Reason Content", + "reasonPlaceholder": "Manual input or select quick reason", + "submit": "Confirm Submit", + "pleaseEnterInfo": "Please fill in complete information", + "pleaseEnterPoints": "Please enter points or select preset reason", + "batchSuccess": "Submitted points for {{count}} students", + "batchPartial": "Successfully submitted for {{success}}/{{total}} students" + }, + "reasons": { + "title": "Reason Management", + "addReason": "Add Preset Reason", + "category": "Category", + "content": "Reason Content", + "presetPoints": "Preset Points", + "deleteConfirm": "Confirm delete this reason?", + "addTitle": "Add Reason", + "addConfirm": "Add", + "categoryPlaceholder": "e.g. Study, Discipline", + "contentPlaceholder": "Enter reason", + "pointsPlaceholder": "e.g. 2 or -2", + "contentRequired": "Please enter reason content", + "pointsRequired": "Please enter preset points", + "reasonExists": "Same reason already exists in this category", + "addSuccess": "Added successfully", + "addFailed": "Add failed", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Delete failed", + "others": "Others" + }, + "rewardExchange": { + "title": "Reward Exchange", + "enterMode": "Enter Exchange Mode", + "exitMode": "Exit Exchange Mode", + "modeHint": "Reward exchange mode is active. Click a student card to choose an available reward.", + "normalHint": "Current cards show original score. Enter mode to display redeemable reward points.", + "remainingRewardPoints": "Remaining Reward Points", + "currentScore": "Current Score", + "chooseRewardTitle": "Choose Reward for {{name}}", + "currentRewardPoints": "Current reward points: {{points}}", + "costLabel": "Cost points: {{points}}", + "redeemNow": "Redeem", + "noAffordableRewards": "No rewards can be redeemed now. Earn more points first.", + "redeemSuccess": "{{student}} redeemed \"{{reward}}\" and spent {{points}} points", + "redeemFailed": "Redeem failed", + "recordsTitle": "Redemption Records", + "recordStudent": "Student", + "recordReward": "Reward", + "recordCost": "Cost", + "recordTime": "Redeemed At" + }, + "rewardSettings": { + "title": "Reward Settings", + "addReward": "Add Reward", + "addTitle": "Add Reward", + "editTitle": "Edit Reward", + "rewardName": "Reward Name", + "costPoints": "Cost Points", + "namePlaceholder": "e.g. Homework-Free Card", + "costPlaceholder": "Enter cost points", + "nameRequired": "Please enter reward name", + "costRequired": "Please enter positive integer points", + "createSuccess": "Reward created", + "createFailed": "Create reward failed", + "updateSuccess": "Reward updated", + "updateFailed": "Update reward failed", + "deleteSuccess": "Reward deleted", + "deleteFailed": "Delete reward failed", + "deleteConfirm": "Delete reward \"{{name}}\"?" + }, + "board": { + "title": "Boards", + "editable": "Editable", + "readonly": "Read-only", + "addBoard": "Add Board", + "removeBoard": "Delete Board", + "removeBoardConfirm": "Delete current board?", + "boardConfig": "Board Config", + "boardNamePlaceholder": "Enter board name", + "renameBoard": "Rename Board", + "newBoard": "New Board", + "untitledBoard": "Untitled Board", + "addList": "Add Student List", + "removeList": "Delete List", + "removeListConfirm": "Delete current student list?", + "editList": "Edit", + "sqlEditorTitle": "Edit Student List", + "splitHorizontal": "Split Left/Right", + "splitVertical": "Split Top/Bottom", + "dragHandle": "Drag to resize layout", + "listNamePlaceholder": "Enter list name", + "newList": "New List", + "applyPreset": "Apply Preset", + "run": "Run SQL", + "runAll": "Run All", + "viewModes": { + "list": "List View", + "card": "Card View", + "grid": "Grid View" + }, + "metrics": { + "totalScore": "Score", + "rewardPoints": "Reward", + "weekChange": "7d", + "weekDeducted": "7d Deduct", + "todayAnswered": "Today" + }, + "sqlPlaceholder": "Enter SQL (single SELECT / WITH query only)", + "templateHint": "Template variables: {{today_start}}, {{since_7d}}, {{since_30d}}, {{now}}", + "templateDescription": "Use template variables directly in SQL. They will be replaced with ISO timestamps at runtime for both SQLite and PostgreSQL.", + "saving": "Saving...", + "saveFailed": "Failed to save boards", + "runFailed": "Failed to run query", + "keepAtLeastOneBoard": "Keep at least one board", + "keepAtLeastOneList": "Keep at least one student list", + "presets": { + "weekLowDeduct": { + "name": "Low Deduction (7d)", + "description": "Rank students with weekly deduction < 3" + }, + "todayActive": { + "name": "Today's Activity", + "description": "Sort by today's answer count and score change" + }, + "rewardRanking": { + "name": "Reward Ranking", + "description": "Sort by reward points and total score" + } + } + }, + "leaderboard": { + "title": "Points Leaderboard", + "rank": "Rank", + "name": "Name", + "totalScore": "Total Points", + "todayChange": "Today's Change", + "weekChange": "This Week's Change", + "monthChange": "This Month's Change", + "viewHistory": "View", + "exportXlsx": "Export XLSX", + "exportSuccess": "Export successful", + "queryFailed": "Query failed", + "historyTitle": "{{name}} - Operation History", + "close": "Close", + "today": "Today", + "week": "This Week", + "month": "This Month", + "operationRecord": "Operation Record", + "change": "Change" + }, + "settlements": { + "title": "Settlement History", + "phase": "Phase #{{id}}", + "viewLeaderboard": "View Leaderboard", + "recordCount": "Records: {{count}}", + "noRecords": "No settlement records", + "noSettlements": "No settlement records", + "back": "Back", + "leaderboardTitle": "Settlement Leaderboard", + "phaseScore": "Phase Points", + "queryFailed": "Query failed", + "rank": "Rank", + "name": "Name" + }, + "autoScore": { + "title": "Auto Score Management", + "name": "Automation Name", + "namePlaceholder": "e.g. Daily Check-in Points", + "nameRequired": "Please enter automation name", + "triggers": "Triggers", + "actions": "Actions", + "applicableStudents": "Applicable Students", + "allStudents": "All Students", + "lastExecuted": "Last Executed", + "notExecuted": "Not executed", + "invalidTime": "Invalid time", + "addTrigger": "Add Rule", + "addAction": "Add Action", + "whenTriggered": "When the following rules trigger", + "triggeredActions": "Actions triggered when rules are met", + "addAutomation": "Add Automation", + "updateAutomation": "Update Automation", + "cancelEdit": "Cancel Edit", + "resetForm": "Reset Form", + "deleteConfirm": "Confirm delete this automation?", + "adminRequired": "Admin permission required to view auto score automation", + "adminCreateRequired": "Admin permission required to create or update auto score automation", + "adminDeleteRequired": "Admin permission required to delete auto score automation", + "adminToggleRequired": "Admin permission required to enable/disable auto score automation", + "fetchFailed": "Failed to get automation", + "triggerRequired": "Please add at least one trigger", + "actionRequired": "Please add at least one action", + "createSuccess": "Automation created successfully", + "updateSuccess": "Automation updated successfully", + "createFailed": "Failed to create automation", + "updateFailed": "Failed to update automation", + "deleteSuccess": "Automation deleted successfully", + "deleteFailed": "Failed to delete automation", + "enabled": "Automation enabled", + "disabled": "Automation disabled", + "enableFailed": "Failed to enable automation", + "disableFailed": "Failed to disable automation", + "noTriggerAvailable": "No trigger types available, please check configuration", + "noActionAvailable": "No action types available, please check configuration", + "sort": "Sort", + "status": "Status", + "triggerCount": "{{count}} triggers", + "actionCount": "{{count}} actions", + "studentCount": "{{count}} students", + "studentPlaceholder": "Please select or search students (leave empty for all students)", + "triggerCondition": "Trigger Condition", + "executeAction": "Execute Action", + "addOperation": "Add Operation", + "scoreLabel": "Score", + "tagNameLabel": "Tag Name", + "operationNoteLabel": "Operation Note (Optional)", + "intervalMinutesPlaceholder": "e.g. 1440", + "tagNamesPlaceholder": "e.g. excellent_student,class_monitor", + "triggerIntervalTime": "Interval Time", + "triggerStudentTag": "Student Tag", + "actionAddScore": "Add Score", + "actionAddTag": "Add Tag", + "minutesPlaceholder": "Enter minutes", + "minutes": "minutes", + "tagsPlaceholder": "Enter tag name", + "scorePlaceholder": "Enter score", + "tagNamePlaceholder": "Enter tag name", + "reasonPlaceholder": "Enter reason", + "relationAnd": "AND", + "relationOr": "OR" + }, + "triggers": { + "studentTag": { + "label": "When student matches tag", + "description": "Trigger automation when student matches specific tag", + "placeholder": "Please select tag", + "required": "Please enter tag name" + }, + "randomTime": { + "label": "Random time trigger", + "description": "Trigger automation randomly within specified time range", + "from": "From", + "to": "To", + "hour": "h", + "minHourPlaceholder": "Min hour", + "maxHourPlaceholder": "Max hour", + "required": "Please enter valid time range (minutes)" + }, + "intervalTime": { + "label": "Interval time trigger", + "description": "Trigger automation when interval time is reached", + "monthLabel": "Month", + "weekLabel": "Week", + "timeLabel": "Time", + "days": "Days", + "minutes": "Minutes", + "placeholderMinutes": "Enter interval (minutes)", + "placeholderDays": "Enter interval (days)", + "required": "Please enter valid time interval (minutes)" + } + }, + "actions": { + "sendNotification": { + "label": "Send Notification", + "description": "Send notification to student", + "placeholder": "Enter notification content" + }, + "addTag": { + "label": "Add Tag", + "description": "Add tag to student", + "placeholder": "Enter tag" + }, + "addScore": { + "label": "Add Score", + "description": "Add score to student", + "pointsPlaceholder": "Enter points", + "reasonPlaceholder": "Enter reason" + } + }, + "wizard": { + "welcomeTitle": "Welcome to SecScore Points Management", + "welcomeDesc": "Thanks for choosing SecScore. Before starting, please spend a minute to complete basic configuration.", + "configComplete": "Configuration complete!", + "configFailed": "Configuration save failed", + "startJourney": "Start Points Journey" + }, + "tags": { + "editTitle": "Edit Tags", + "inputPlaceholder": "Enter tag name, press Enter to add", + "selectedTags": "Selected Tags (click to remove)", + "noTagsSelected": "No tags selected", + "availableTags": "Available Tags (click to select)", + "noAvailableTags": "No available tags", + "fetchFailed": "Failed to fetch tags", + "nameTooLong": "Tag name cannot exceed 30 characters", + "addFailed": "Failed to add tag", + "deleteSuccess": "Tag deleted successfully", + "deleteFailed": "Failed to delete tag" + }, + "recovery": { + "title": "SecScore Recovery String", + "saved": "I have saved", + "export": "Export Text File", + "exportHint": "It is recommended to export and store offline. If lost, it cannot be recovered.", + "hint": "Recommended to save offline after export, cannot be recovered if lost." + } +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 71d39a2..7faf278 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,809 +1,817 @@ -{ - "common": { - "next": "下一步", - "prev": "上一步", - "finish": "完成", - "cancel": "取消", - "save": "保存", - "loading": "加载中...", - "confirm": "确认", - "success": "成功", - "error": "错误", - "delete": "删除", - "edit": "编辑", - "add": "添加", - "search": "搜索", - "clear": "清空", - "close": "关闭", - "yes": "是", - "no": "否", - "submit": "提交", - "reset": "重置", - "import": "导入", - "export": "导出", - "create": "创建", - "update": "更新", - "refresh": "刷新", - "view": "查看", - "name": "姓名", - "status": "状态", - "operation": "操作", - "description": "描述", - "total": "共 {{count}} 条", - "noData": "暂无数据", - "pleaseSelect": "请选择", - "pleaseEnter": "请输入", - "readOnly": "当前为只读权限", - "none": "无" +{ + "common": { + "next": "下一步", + "prev": "上一步", + "finish": "完成", + "cancel": "取消", + "save": "保存", + "loading": "加载中...", + "confirm": "确认", + "success": "成功", + "error": "错误", + "delete": "删除", + "edit": "编辑", + "add": "添加", + "search": "搜索", + "clear": "清空", + "close": "关闭", + "yes": "是", + "no": "否", + "submit": "提交", + "reset": "重置", + "import": "导入", + "export": "导出", + "create": "创建", + "update": "更新", + "refresh": "刷新", + "view": "查看", + "name": "姓名", + "status": "状态", + "operation": "操作", + "description": "描述", + "total": "共 {{count}} 条", + "noData": "暂无数据", + "pleaseSelect": "请选择", + "pleaseEnter": "请输入", + "readOnly": "当前为只读权限", + "none": "无" }, "oobe": { - "title": "欢迎使用 SecScore", - "subtitle": "教育积分管理专家", - "step": "第 {{current}}/{{total}} 步", - "skip": "跳过", - "steps": { + "title": "SecScore 快速设置", + "subtitle": "几步完成基础配置,马上开始使用", + "step": "第 {{current}}/{{total}} 步", + "skip": "跳过", + "steps": { "entry": { "title": "开始配置", "description": "请选择接下来要进行的操作", - "enterOobe": "进入OOBE", + "enterOobe": "进入快速设置", "connectPostgresAutoSync": "连接PostgreSQL自动同步", - "skipDirect": "跳过OOBE,直接进入" + "skipDirect": "跳过快速设置,直接进入" }, - "postgresql": { - "title": "连接 PostgreSQL", - "description": "输入 PostgreSQL 连接字符串,系统将自动连接并同步后进入应用", - "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", - "autoSyncAndEnter": "连接并自动同步后进入" - }, - "language": { - "title": "选择语言", - "description": "请选择您偏好的界面语言" - }, - "theme": { - "title": "设置主题", - "description": "选择您喜欢的界面外观", - "mode": "模式", - "lightMode": "浅色", - "darkMode": "深色", - "primaryColor": "主色", - "backgroundGradient": "背景渐变" - }, - "password": { - "title": "设置密码", - "description": "设置登录密码以保护您的数据(可选)", - "adminPassword": "管理密码", - "adminPasswordHint": "可管理所有功能,6位数字", - "pointsPassword": "积分密码", - "pointsPasswordHint": "仅可操作积分,6位数字", - "passwordPlaceholder": "请输入6位数字", - "hint": "留空则不设置密码,之后可在设置中配置" - }, - "students": { - "title": "导入名单", - "description": "添加学生名单以便开始积分管理", - "import": "导入名单", - "manual": "手动添加", - "importHint": "支持 Excel (.xlsx) 或 JSON 文件", - "manualHint": "支持粘贴多行文本,一行一个姓名", - "dragDrop": "拖拽文件到此处,或点击选择", - "supportedFormats": "支持 .xlsx, .json 格式", - "studentName": "学生姓名", - "addStudent": "添加学生", - "noStudents": "暂无学生,请添加或导入", - "studentCount": "已添加 {{count}} 名学生", - "studentExists": "学生已存在", - "importSuccess": "成功导入 {{count}} 名学生", - "noNewStudents": "没有新学生可导入", - "parseFailed": "文件解析失败", - "unsupportedFormat": "不支持的文件格式" - }, - "reasons": { - "title": "设置理由", - "description": "添加常用的积分变动理由", - "addReason": "添加理由", - "reasonName": "理由名称", - "points": "积分", - "positive": "加分", - "negative": "扣分", - "noReasons": "暂无理由,请添加常用理由", - "reasonCount": "已添加 {{count}} 个理由", - "reasonExists": "理由已存在" - }, - "start": { - "title": "准备就绪", - "description": "一切准备就绪,开始使用 SecScore 吧!", - "features": { - "score": "轻松管理学生积分", - "history": "完整的积分变动历史", - "settlement": "支持结算与归档" - }, - "startButton": "开始使用" - } - } - }, - "settings": { - "title": "系统设置", - "tabs": { - "appearance": "外观", - "security": "安全", - "database": "数据库连接", - "dataManagement": "数据管理", - "urlProtocol": "URL 链接", - "about": "关于" - }, - "language": "语言", - "languageHint": "选择界面显示语言", - "theme": "主题", - "password": "设置密码", - "themeMode": "模式", - "lightMode": "浅色", - "darkMode": "深色", - "primaryColor": "主色", - "backgroundGradient": "背景渐变", - "searchKeyboard": { - "title": "搜索小键盘", - "hint": "选择搜索框下方弹出的小键盘布局", - "disableToggle": "禁用搜索小键盘", - "disableHint": "开启后,搜索框下方不再弹出小键盘", - "options": { - "qwerty26": "26 键(QWERTY)", - "t9": "9 键(T9)" - } - }, - "interfaceZoom": "界面缩放", - "zoomOptions": { - "small70": "小 (70%)", - "default100": "默认 (100%)", - "large150": "大 (150%)" - }, - "general": { - "saved": "已保存", - "saveFailed": "保存失败" - }, - "zoomHint": "调节应用界面的整体大小", - "zoomUpdated": "界面缩放已更新", - "updateFailed": "更新失败", - "security": { - "title": "密码保护系统", - "adminPassword": "管理密码", - "pointsPassword": "积分密码", - "recoveryString": "找回字符串", - "set": "已设置", - "notSet": "未设置", - "generated": "已生成", - "notGenerated": "未生成", - "enterPassword": "输入6位数字(留空则不修改)", - "adminPasswordPlaceholder": "输入6位数字管理密码(留空则不修改)", - "pointsPasswordPlaceholder": "输入6位数字积分密码(留空则不修改)", - "recoveryPlaceholder": "输入找回字符串", - "savePassword": "保存密码", - "savePasswords": "保存密码", - "generateRecovery": "生成找回字符串", - "clearAllPasswords": "清空所有密码", - "recoveryReset": "找回字符串重置", - "enterRecovery": "输入找回字符串", - "resetPassword": "重置密码", - "resetHint": "重置会清空管理/积分密码,并生成新的找回字符串。", - "confirmClear": "确认清空所有密码?", - "clearHint": "清空后将关闭保护(无密码时默认视为管理权限)。", - "saved": "密码已更新", - "saveFailed": "更新失败", - "generateFailed": "生成失败", - "resetFailed": "重置失败", - "cleared": "已清空", - "clearFailed": "清空失败", - "passwordClearedNewRecovery": "密码已清空,新的找回字符串", - "newRecoveryString": "新的找回字符串(请保存)" - }, - "database": { - "title": "数据库连接", - "currentStatus": "当前数据库状态", - "sqliteLocal": "SQLite 本地数据库", - "postgresqlRemote": "PostgreSQL 远程数据库", - "connected": "已连接", - "disconnected": "未连接", - "postgresqlConnection": "PostgreSQL 远程连接", - "connectionHint": "输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。", - "testConnection": "测试连接", - "switchToPostgreSQL": "切换到 PostgreSQL", - "switchToSQLite": "切换到本地 SQLite", - "connectionTestSuccess": "连接测试成功", - "connectionTestFailed": "连接测试失败", - "switchedTo": "已切换到 {{type}} 数据库", - "switchedToSQLite": "已切换到 SQLite 本地数据库", - "switchFailed": "切换失败", - "syncDescription": "多端同步说明", - "syncPoint1": "使用 PostgreSQL 远程数据库可以实现多端数据同步。", - "syncPoint2": "系统内置操作队列机制,确保多端同时操作时数据一致性。", - "syncPoint3": "切换数据库后需要重启应用以生效。", - "syncPoint4": "建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。", - "enterConnectionString": "请输入 PostgreSQL 连接字符串", - "connectionExample": "示例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", - "uploadButton": "上传本地数据到远程", - "uploadHint": "连接远程数据库后,可将本地 SQLite 历史数据上传并合并到远程。", - "uploadNeedRemote": "请先切换并连接到 PostgreSQL 远程数据库", - "noNeedUpload": "本地与远程数据已一致,无需上传", - "uploadConfirmTitle": "确认上传本地数据到远程?", - "uploadConfirmContent": "将以本地数据为优先进行同步。检测到本地新增 {{localOnly}} 条、远程新增 {{remoteOnly}} 条、冲突 {{conflicts}} 条。", - "uploadSuccess": "本地数据已上传到远程", - "uploadFailed": "上传失败" - }, + "postgresql": { + "title": "连接 PostgreSQL", + "description": "输入 PostgreSQL 连接字符串,系统将自动连接并同步后进入应用", + "connectionPlaceholder": "postgresql://user:password@host:port/database?sslmode=require", + "autoSyncAndEnter": "连接并自动同步后进入" + }, + "language": { + "title": "选择语言", + "description": "请选择您偏好的界面语言" + }, + "theme": { + "title": "设置主题", + "description": "选择您喜欢的界面外观", + "mode": "模式", + "lightMode": "浅色", + "darkMode": "深色", + "primaryColor": "主色", + "backgroundGradient": "背景渐变" + }, + "password": { + "title": "设置密码", + "description": "设置登录密码以保护您的数据(可选)", + "adminPassword": "管理密码", + "adminPasswordHint": "可管理所有功能,6位数字", + "pointsPassword": "积分密码", + "pointsPasswordHint": "仅可操作积分,6位数字", + "passwordPlaceholder": "请输入6位数字", + "hint": "留空则不设置密码,之后可在设置中配置" + }, + "students": { + "title": "导入名单", + "description": "添加学生名单以便开始积分管理", + "import": "导入名单", + "manual": "手动添加", + "importHint": "支持 Excel (.xlsx) 或 JSON 文件", + "manualHint": "支持粘贴多行文本,一行一个姓名", + "dragDrop": "拖拽文件到此处,或点击选择", + "supportedFormats": "支持 .xlsx, .json 格式", + "studentName": "学生姓名", + "addStudent": "添加学生", + "noStudents": "暂无学生,请添加或导入", + "studentCount": "已添加 {{count}} 名学生", + "studentExists": "学生已存在", + "importSuccess": "成功导入 {{count}} 名学生", + "noNewStudents": "没有新学生可导入", + "parseFailed": "文件解析失败", + "unsupportedFormat": "不支持的文件格式" + }, + "reasons": { + "title": "设置理由", + "description": "添加常用的积分变动理由", + "addReason": "添加理由", + "reasonName": "理由名称", + "points": "积分", + "positive": "加分", + "negative": "扣分", + "noReasons": "暂无理由,请添加常用理由", + "reasonCount": "已添加 {{count}} 个理由", + "reasonExists": "理由已存在" + }, + "start": { + "title": "准备就绪", + "description": "一切准备就绪,开始使用 SecScore 吧!", + "features": { + "score": "轻松管理学生积分", + "history": "完整的积分变动历史", + "settlement": "支持结算与归档" + }, + "startButton": "开始使用" + } + } + }, + "settings": { + "title": "系统设置", + "tabs": { + "appearance": "外观", + "security": "安全", + "database": "数据库连接", + "dataManagement": "数据管理", + "urlProtocol": "URL 链接", + "about": "关于" + }, + "language": "语言", + "languageHint": "选择界面显示语言", + "theme": "主题", + "password": "设置密码", + "themeMode": "模式", + "lightMode": "浅色", + "darkMode": "深色", + "primaryColor": "主色", + "backgroundGradient": "背景渐变", + "searchKeyboard": { + "title": "搜索小键盘", + "hint": "选择搜索框下方弹出的小键盘布局", + "disableToggle": "禁用搜索小键盘", + "disableHint": "开启后,搜索框下方不再弹出小键盘", + "options": { + "qwerty26": "26 键(QWERTY)", + "t9": "9 键(T9)" + } + }, + "interfaceZoom": "界面缩放", + "zoomOptions": { + "small70": "小 (70%)", + "default100": "默认 (100%)", + "large150": "大 (150%)" + }, + "general": { + "saved": "已保存", + "saveFailed": "保存失败" + }, + "zoomHint": "调节应用界面的整体大小", + "zoomUpdated": "界面缩放已更新", + "updateFailed": "更新失败", + "security": { + "title": "密码保护系统", + "adminPassword": "管理密码", + "pointsPassword": "积分密码", + "recoveryString": "找回字符串", + "set": "已设置", + "notSet": "未设置", + "generated": "已生成", + "notGenerated": "未生成", + "enterPassword": "输入6位数字(留空则不修改)", + "adminPasswordPlaceholder": "输入6位数字管理密码(留空则不修改)", + "pointsPasswordPlaceholder": "输入6位数字积分密码(留空则不修改)", + "recoveryPlaceholder": "输入找回字符串", + "savePassword": "保存密码", + "savePasswords": "保存密码", + "generateRecovery": "生成找回字符串", + "clearAllPasswords": "清空所有密码", + "recoveryReset": "找回字符串重置", + "enterRecovery": "输入找回字符串", + "resetPassword": "重置密码", + "resetHint": "重置会清空管理/积分密码,并生成新的找回字符串。", + "confirmClear": "确认清空所有密码?", + "clearHint": "清空后将关闭保护(无密码时默认视为管理权限)。", + "saved": "密码已更新", + "saveFailed": "更新失败", + "generateFailed": "生成失败", + "resetFailed": "重置失败", + "cleared": "已清空", + "clearFailed": "清空失败", + "passwordClearedNewRecovery": "密码已清空,新的找回字符串", + "newRecoveryString": "新的找回字符串(请保存)" + }, + "database": { + "title": "数据库连接", + "currentStatus": "当前数据库状态", + "sqliteLocal": "SQLite 本地数据库", + "postgresqlRemote": "PostgreSQL 远程数据库", + "connected": "已连接", + "disconnected": "未连接", + "postgresqlConnection": "PostgreSQL 远程连接", + "connectionHint": "输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。", + "testConnection": "测试连接", + "switchToPostgreSQL": "切换到 PostgreSQL", + "switchToSQLite": "切换到本地 SQLite", + "connectionTestSuccess": "连接测试成功", + "connectionTestFailed": "连接测试失败", + "switchedTo": "已切换到 {{type}} 数据库", + "switchedToSQLite": "已切换到 SQLite 本地数据库", + "switchFailed": "切换失败", + "syncDescription": "多端同步说明", + "syncPoint1": "使用 PostgreSQL 远程数据库可以实现多端数据同步。", + "syncPoint2": "系统内置操作队列机制,确保多端同时操作时数据一致性。", + "syncPoint3": "切换数据库后需要重启应用以生效。", + "syncPoint4": "建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。", + "enterConnectionString": "请输入 PostgreSQL 连接字符串", + "connectionExample": "示例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require", + "uploadButton": "上传本地数据到远程", + "uploadHint": "连接远程数据库后,可将本地 SQLite 历史数据上传并合并到远程。", + "uploadNeedRemote": "请先切换并连接到 PostgreSQL 远程数据库", + "noNeedUpload": "本地与远程数据已一致,无需上传", + "uploadConfirmTitle": "确认上传本地数据到远程?", + "uploadConfirmContent": "将以本地数据为优先进行同步。检测到本地新增 {{localOnly}} 条、远程新增 {{remoteOnly}} 条、冲突 {{conflicts}} 条。", + "uploadSuccess": "本地数据已上传到远程", + "uploadFailed": "上传失败" + }, "data": { "title": "数据管理", "settlement": "结算", "settlementAndRestart": "结算并重新开始", "settlementHint": "将当前未结算记录划分为一个阶段,并将所有学生积分清零。", + "quickSetupReset": "快速设置重置", + "resetAllAndReenter": "删除所有数据并重新进入快速设置", + "resetHint": "删除学生、理由、记录、奖励、标签和全部设置,然后重新进入快速设置。", "importExport": "导入 / 导出", - "exportJson": "导出 JSON", - "importJson": "导入 JSON", - "importHint": "导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。", - "logs": "日志", - "logLevel": "日志级别", - "logOperation": "日志操作", - "viewLogs": "查看日志", - "exportLogs": "导出日志", - "clearLogs": "清空日志", - "logsCleared": "日志已清空", - "systemLogs": "系统日志 (最后200条)", - "noLogs": "暂无日志", - "logLevelUpdated": "日志级别已更新", - "readLogsFailed": "读取日志失败", - "logsExported": "日志已导出", - "exportFailed": "导出失败", - "exportSuccess": "导出成功", + "exportJson": "导出 JSON", + "importJson": "导入 JSON", + "importHint": "导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。", + "logs": "日志", + "logLevel": "日志级别", + "logOperation": "日志操作", + "viewLogs": "查看日志", + "exportLogs": "导出日志", + "clearLogs": "清空日志", + "logsCleared": "日志已清空", + "systemLogs": "系统日志 (最后200条)", + "noLogs": "暂无日志", + "logLevelUpdated": "日志级别已更新", + "readLogsFailed": "读取日志失败", + "logsExported": "日志已导出", + "exportFailed": "导出失败", + "exportSuccess": "导出成功", "importSuccess": "导入成功,正在刷新", "importFailed": "导入失败", + "resetSuccess": "已重置,正在重新进入快速设置", + "resetFailed": "重置失败", "clearFailed": "清空失败", "confirmSettlement": "确认结算并重新开始?", "settlementConfirm1": "将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。", "settlementConfirm2": "学生名单不变;结算后的历史在「结算历史」页面查看。", + "confirmResetAll": "确认删除所有数据并重新进入快速设置?", + "resetConfirm1": "这会删除学生、理由、积分记录、奖励、标签以及全部设置,且不可恢复。", + "resetConfirm2": "重置后应用将重新加载,并直接进入快速设置。", "settlementSuccess": "结算成功,已重新开始积分", "settlementFailed": "结算失败", "settle": "结算", - "logLevels": { - "debug": "DEBUG (调试)", - "info": "INFO (信息)", - "warn": "WARN (警告)", - "error": "ERROR (错误)" - } - }, - "url": { - "title": "URL 协议 (secscore://)", - "protocol": "URL 协议", - "description": "可以通过 URL 链接唤起 SecScore 并执行操作,例如:", - "register": "注册 URL 协议", - "registered": "URL 协议已注册", - "registerFailed": "注册失败", - "installerRequired": "需要安装版 SecScore,开发模式下可能无效。" - }, - "mcp": { - "title": "MCP 服务", - "description": "管理内置 MCP HTTP 服务,供外部 MCP 客户端调用。", - "running": "运行中", - "stopped": "已停止", - "noUrl": "暂无地址", - "host": "主机", - "port": "端口", - "start": "启动", - "stop": "停止", - "refresh": "刷新状态", - "hint": "建议仅绑定到 127.0.0.1;启动后 MCP 入口为 http://host:port/mcp", - "hostRequired": "请输入 MCP 主机地址", - "portInvalid": "请输入有效端口(1-65535)", - "startSuccess": "MCP 服务启动成功", - "startFailed": "MCP 服务启动失败", - "stopSuccess": "MCP 服务已停止", - "stopFailed": "MCP 服务停止失败", - "startTimeout": "启动 MCP 服务超时,请稍后重试", - "stopTimeout": "停止 MCP 服务超时,请稍后重试" - }, - "app": { - "title": "应用控制", - "description": "快速重启或退出应用。", - "restart": "重启应用", - "quit": "退出应用", - "confirmRestartTitle": "确认重启应用?", - "confirmRestartContent": "应用将立即关闭并重新启动。", - "confirmQuitTitle": "确认退出应用?", - "confirmQuitContent": "应用将立即退出。", - "restartFailed": "重启失败", - "quitFailed": "退出失败" - }, - "about": { - "title": "关于", - "appName": "教育积分管理", - "version": "版本", - "copyright": "版权", - "ipcStatus": "IPC 状态", - "ipcConnected": "已连接", - "ipcDisconnected": "未连接 (Preload 失败)", - "environment": "环境", - "toggleDevTools": "切换开发者工具", - "license": "SecScore遵循GPL3.0协议", - "copyRightText": "CopyRight © 2025-{{year}} SECTL" - } - }, - "sidebar": { - "home": "首页", - "students": "学生管理", - "score": "积分操作", - "boards": "看板", - "leaderboard": "排行榜", - "settlements": "结算历史", - "reasons": "理由管理", - "autoScore": "自动加分", - "rewardExchange": "奖励兑换", - "rewardSettings": "奖励设置", - "settings": "系统设置", - "remoteDb": "远程数据库", - "refreshStatus": "刷新状态", - "syncNow": "立即同步", - "syncSuccess": "同步成功", - "syncFailed": "同步失败", - "getDbStatusFailed": "获取数据库状态失败", - "notRemoteMode": "当前不是远程数据库模式,请重启应用后重试", - "dbNotConnected": "数据库未连接" - }, - "auth": { - "unlock": "权限解锁", - "unlockHint": "输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。", - "unlockButton": "解锁", - "unlocked": "权限已解锁", - "logout": "已切换为只读", - "passwordPlaceholder": "例如 123456", - "passwordError": "密码错误", - "enterPassword": "输入密码", - "lock": "锁定" - }, - "languages": { - "zh-CN": "简体中文", - "en-US": "English" - }, - "theme": { - "colorAndBackground": "颜色与背景", - "gradientLabels": { - "blue": "清爽蓝", - "pink": "柔和粉", - "cyan": "青蓝", - "purple": "紫韵" - }, - "gradientDirections": { - "vertical": "纵向", - "horizontal": "横向", - "diagonal": "对角" - }, - "generate": "生成", - "saved": "已保存", - "saveFailed": "保存失败", - "mode": "模式", - "myTheme": "我的主题" - }, - "permissions": { - "admin": "管理权限", - "points": "积分权限", - "view": "只读" - }, - "home": { - "title": "学生积分主页", - "subtitle": "共 {{count}} 名学生,点击卡片进行积分操作", - "searchPlaceholder": "搜索姓名/拼音...", - "sortBy": { - "alphabet": "姓名排序", - "surname": "姓氏分组", - "score": "积分排行" - }, - "layoutBy": { - "grouped": "分组列表", - "squareGrid": "正方形网格" - }, - "noStudents": "暂无学生数据,请前往学生管理添加", - "noMatch": "未找到匹配的学生", - "clearSearch": "清除搜索", - "points": "分", - "currentScore": "当前积分", - "quickOptions": "快捷选项", - "adjustPoints": "调整分值", - "customPoints": "自定义分值", - "customPointsHint": "可在输入框微调特输入任意分值", - "reason": "操作理由", - "reasonPlaceholder": "输入加分/扣分的原因(可选)", - "preview": "变更预览", - "noReason": "(无理由)", - "category": { - "others": "其他" - }, - "scoreAdded": "已为 {{name}} 加{{points}}分", - "scoreDeducted": "已为 {{name}} 扣{{points}}分", - "submitFailed": "提交失败", - "pleaseSelectPoints": "请选择或输入分值", - "reasonDefault": "{{action}}{{points}}分", - "studentCount": "{{count}} 人", - "operationTitle": "积分操作:{{name}}", - "submitOperation": "提交操作", - "operationTitleBatch": "积分操作:已选 {{count}} 人", - "undoLastAction": "撤销上一步", - "undoLastHint": "撤销上一条:{{name}} {{delta}}", - "undoUnavailable": "当前没有可撤销的积分操作", - "undoLastSuccess": "已撤销上一步积分操作", - "multiSelect": "多选", - "batchMode": "多选模式", - "selectAll": "全选", - "clearSelected": "清空已选", - "batchOperate": "批量积分", - "selected": "已选", - "selectedCount": "已选 {{count}} 人", - "batchSuccess": "已为 {{count}} 名学生提交积分", - "batchPartial": "成功提交 {{success}}/{{total}} 名学生积分", - "selectStudentFirst": "请先选择学生", - "addPoints": "加分", - "deductPoints": "扣分", - "pointsChange": "积分变更", - "reasonLabel": "理由:" - }, - "students": { - "title": "学生管理", - "importList": "导入名单", - "addStudent": "添加学生", - "name": "姓名", - "avatar": "头像", - "currentScore": "当前积分", - "tags": "标签", - "noTags": "无标签", - "editTags": "编辑标签", - "editAvatar": "设置头像", - "deleteConfirm": "确认删除该学生?", - "addTitle": "添加学生", - "addConfirm": "添加", - "namePlaceholder": "请输入学生姓名", - "nameRequired": "请输入姓名", - "nameExists": "学生姓名已存在", - "addSuccess": "添加成功", - "addFailed": "添加失败", - "deleteSuccess": "删除成功", - "deleteFailed": "删除失败", - "tagSaveSuccess": "标签保存成功", - "tagSaveFailed": "保存标签失败", - "importTitle": "导入名单", - "importTextHint": "支持粘贴多行文本,一行一个姓名", - "importTextPlaceholder": "例如:\n张三\n李四\n王五", - "importByText": "从文本导入", - "importByXlsx": "通过 xlsx 导入", - "xlsxPreview": "xlsx 预览与导入", - "file": "文件", - "selectNameCol": "点击表头选择姓名列", - "previewRows": "预览前 50 行", - "importConfirm": "导入", - "importComplete": "导入完成:新增 {{inserted}},跳过 {{skipped}}", - "workerNotReady": "Worker 未初始化", - "parseXlsxFailed": "解析 xlsx 失败", - "selectNameColFirst": "请先点击选择\"姓名列\"", - "noNamesFound": "所选列未解析到可导入的姓名", - "importFailed": "导入失败", - "editTagTitle": "编辑标签 - {{name}}", - "editAvatarTitle": "设置头像 - {{name}}", - "avatarUpload": "上传图片", - "avatarClear": "清除头像", - "noAvatar": "无头像", - "avatarTip": "支持常见图片格式,文件大小不超过 2MB", - "avatarSaveSuccess": "头像保存成功", - "avatarSaveFailed": "头像保存失败", - "avatarInvalidFile": "请选择图片文件", - "avatarTooLarge": "图片不能超过 2MB", - "avatarReadFailed": "读取图片失败" - }, - "score": { - "title": "积分管理", - "student": "学生", - "change": "变动", - "reason": "理由", - "time": "时间", - "undoConfirm": "确定要撤销这条记录吗?学生积分将回滚。", - "undo": "撤销", - "undoSuccess": "已撤销操作", - "undoFailed": "撤销失败", - "recentRecords": "最近记录", - "pleaseSelectStudent": "请选择或搜索学生", - "selectAllStudents": "全班", - "clearSelectedStudents": "清空", - "selectedCount": "已选 {{selected}} / {{total}} 人", - "points": "分数", - "addPoints": "加分", - "deductPoints": "扣分", - "quickReason": "快捷理由", - "selectReason": "选择预设理由", - "reasonContent": "理由内容", - "reasonPlaceholder": "手动输入或选择快捷理由", - "submit": "确认提交", - "pleaseEnterInfo": "请填写完整信息", - "pleaseEnterPoints": "请填写分值或选择预设理由", - "batchSuccess": "已为 {{count}} 名学生提交积分", - "batchPartial": "成功提交 {{success}}/{{total}} 名学生的积分" - }, - "reasons": { - "title": "理由管理", - "addReason": "添加预设理由", - "category": "分类", - "content": "理由内容", - "presetPoints": "预设分值", - "deleteConfirm": "确认删除该理由?", - "addTitle": "添加理由", - "addConfirm": "添加", - "categoryPlaceholder": "例如: 学习, 纪律", - "contentPlaceholder": "请输入理由", - "pointsPlaceholder": "例如: 2 或 -2", - "contentRequired": "请输入理由内容", - "pointsRequired": "请输入预设分值", - "reasonExists": "该分类下已存在相同理由", - "addSuccess": "添加成功", - "addFailed": "添加失败", - "deleteSuccess": "删除成功", - "deleteFailed": "删除失败", - "others": "其他" - }, - "rewardExchange": { - "title": "奖励兑换", - "enterMode": "进入奖励兑换模式", - "exitMode": "退出奖励兑换模式", - "modeHint": "当前为奖励兑换模式,点击学生卡可选择可兑换奖励。", - "normalHint": "当前显示学生原始积分,点击按钮后将显示可兑换积分。", - "remainingRewardPoints": "剩余兑换积分", - "currentScore": "当前积分", - "chooseRewardTitle": "为 {{name}} 选择奖励", - "currentRewardPoints": "当前可兑换积分:{{points}}", - "costLabel": "消耗积分:{{points}}", - "redeemNow": "立即兑换", - "noAffordableRewards": "当前没有可兑换的奖励,请先积累积分。", - "redeemSuccess": "{{student}} 成功兑换「{{reward}}」,消耗 {{points}} 分", - "redeemFailed": "兑换失败", - "recordsTitle": "兑换记录", - "recordStudent": "学生", - "recordReward": "奖励", - "recordCost": "消耗积分", - "recordTime": "兑换时间" - }, - "rewardSettings": { - "title": "奖励设置", - "addReward": "添加奖励", - "addTitle": "添加奖励", - "editTitle": "编辑奖励", - "rewardName": "奖励名称", - "costPoints": "消耗积分", - "namePlaceholder": "例如:免作业券", - "costPlaceholder": "请输入消耗积分", - "nameRequired": "请输入奖励名称", - "costRequired": "请输入正整数积分", - "createSuccess": "奖励创建成功", - "createFailed": "奖励创建失败", - "updateSuccess": "奖励更新成功", - "updateFailed": "奖励更新失败", - "deleteSuccess": "奖励删除成功", - "deleteFailed": "奖励删除失败", - "deleteConfirm": "确认删除奖励「{{name}}」?" - }, - "board": { - "title": "看板", - "editable": "可编辑", - "readonly": "只读", - "addBoard": "新增看板", - "removeBoard": "删除看板", - "removeBoardConfirm": "确认删除当前看板?", - "boardConfig": "看板配置", - "boardNamePlaceholder": "输入看板名称", - "renameBoard": "重命名看板", - "newBoard": "新看板", - "untitledBoard": "未命名看板", - "addList": "新增学生列表", - "removeList": "删除列表", - "removeListConfirm": "确认删除当前学生列表?", - "editList": "编辑", - "sqlEditorTitle": "编辑学生列表", - "splitHorizontal": "左右拆分", - "splitVertical": "上下拆分", - "dragHandle": "拖动调整布局", - "listNamePlaceholder": "输入列表名称", - "newList": "新列表", - "applyPreset": "套用预设", - "run": "运行 SQL", - "runAll": "运行全部", - "viewModes": { - "list": "列表视图", - "card": "卡片视图", - "grid": "网格视图" - }, - "metrics": { - "totalScore": "总分", - "rewardPoints": "奖励分", - "weekChange": "近7天", - "weekDeducted": "近7天扣分", - "todayAnswered": "今日回答" - }, - "sqlPlaceholder": "输入 SQL(仅支持单条 SELECT / WITH 查询)", - "templateHint": "支持时间模板变量:{{today_start}}、{{since_7d}}、{{since_30d}}、{{now}}", - "templateDescription": "可直接在 SQL 中使用模板变量,运行时会自动替换为 ISO 时间,兼容 SQLite 与 PostgreSQL。", - "saving": "保存中...", - "saveFailed": "保存看板失败", - "runFailed": "查询执行失败", - "keepAtLeastOneBoard": "至少保留一个看板", - "keepAtLeastOneList": "至少保留一个学生列表", - "presets": { - "weekLowDeduct": { - "name": "上周低扣分排行", - "description": "上一周扣分 < 3 的学生积分榜" - }, - "todayActive": { - "name": "今日活跃榜", - "description": "按今日回答次数与积分变化排序" - }, - "rewardRanking": { - "name": "奖励积分榜", - "description": "按奖励积分和总积分排序" - } - } - }, - "leaderboard": { - "title": "积分排行榜", - "rank": "排名", - "name": "姓名", - "totalScore": "总积分", - "todayChange": "今日变化", - "weekChange": "本周变化", - "monthChange": "本月变化", - "viewHistory": "查看", - "exportXlsx": "导出 XLSX", - "exportSuccess": "导出成功", - "queryFailed": "查询失败", - "historyTitle": "{{name}} - 操作记录", - "close": "关闭", - "today": "今天", - "week": "本周", - "month": "本月", - "operationRecord": "操作记录", - "change": "变化" - }, - "settlements": { - "title": "结算历史", - "phase": "阶段 #{{id}}", - "viewLeaderboard": "查看排行榜", - "recordCount": "记录数: {{count}}", - "noRecords": "暂无结算记录", - "noSettlements": "暂无结算记录", - "back": "返回", - "leaderboardTitle": "结算排行榜", - "phaseScore": "阶段积分", - "queryFailed": "查询失败", - "rank": "排名", - "name": "姓名" - }, - "autoScore": { - "title": "自动化加分管理", - "name": "自动化名称", - "namePlaceholder": "例如:每日签到加分", - "nameRequired": "请输入自动化名称", - "triggers": "触发器", - "actions": "行动", - "applicableStudents": "适用学生", - "allStudents": "所有学生", - "lastExecuted": "最后执行", - "notExecuted": "未执行", - "invalidTime": "无效时间", - "addTrigger": "添加规则", - "addAction": "添加行动", - "whenTriggered": "当以下规则触发时", - "triggeredActions": "满足规则时触发的行动", - "addAutomation": "添加自动化", - "updateAutomation": "更新自动化", - "cancelEdit": "取消编辑", - "resetForm": "重置表单", - "deleteConfirm": "确定要删除这条自动化吗?", - "adminRequired": "需要管理员权限以查看自动加分自动化,请先登录管理员账号", - "adminCreateRequired": "需要管理员权限以创建或更新自动加分自动化", - "adminDeleteRequired": "需要管理员权限以删除自动加分自动化", - "adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化", - "fetchFailed": "获取自动化失败", - "triggerRequired": "请至少添加一个触发器", - "actionRequired": "请至少添加一个行动", - "createSuccess": "自动化创建成功", - "updateSuccess": "自动化更新成功", - "createFailed": "创建自动化失败", - "updateFailed": "更新自动化失败", - "deleteSuccess": "自动化删除成功", - "deleteFailed": "删除自动化失败", - "enabled": "自动化已启用", - "disabled": "自动化已禁用", - "enableFailed": "启用自动化失败", - "disableFailed": "禁用自动化失败", - "noTriggerAvailable": "没有可用的触发器类型,请检查配置", - "noActionAvailable": "没有可用的行动类型,请检查配置", - "sort": "排序", - "status": "状态", - "triggerCount": "{{count}} 个触发器", - "actionCount": "{{count}} 个行动", - "studentCount": "{{count}} 名学生", - "studentPlaceholder": "请选择或搜索学生(留空表示所有学生)", - "triggerCondition": "触发条件", - "executeAction": "执行操作", - "addOperation": "添加操作", - "scoreLabel": "分数", - "tagNameLabel": "标签名称", - "operationNoteLabel": "操作说明(可选)", - "triggerIntervalTime": "间隔时间", - "triggerStudentTag": "学生标签", - "actionAddScore": "加分", - "actionAddTag": "添加标签", - "minutesPlaceholder": "请输入分钟数", - "minutes": "分钟", - "tagsPlaceholder": "请输入标签名称", - "scorePlaceholder": "请输入分数", - "tagNamePlaceholder": "请输入标签名称", - "reasonPlaceholder": "请输入理由", - "relationAnd": "并且", - "relationOr": "或者" - }, - "triggers": { - "studentTag": { - "label": "学生标签", - "description": "当学生匹配特定标签时触发自动化", - "placeholder": "请选择标签", - "required": "请输入标签名称" - }, - "randomTime": { - "label": "随机时间触发", - "description": "在指定时间范围内随机触发自动化", - "required": "请输入有效的时间范围(分钟)" - }, - "intervalTime": { - "label": "间隔时间", - "description": "当间隔时间到达时触发自动化", - "placeholder": "请输入时间间隔", - "required": "请输入有效的时间", - "monthLabel": "月", - "weekLabel": "周", - "timeLabel": "时间" - } - }, - "actions": { - "sendNotification": { - "label": "发送通知", - "description": "向学生发送通知", - "placeholder": "请输入通知内容" - }, - "addTag": { - "label": "添加标签", - "description": "为学生添加标签", - "placeholder": "请输入标签" - }, - "addScore": { - "label": "添加分数", - "description": "为学生添加分数", - "pointsPlaceholder": "请输入分数", - "reasonPlaceholder": "请输入理由" - } - }, - "wizard": { - "welcomeTitle": "欢迎使用 SecScore 积分管理", - "welcomeDesc": "感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。", - "configComplete": "配置完成!", - "configFailed": "配置保存失败", - "startJourney": "开启积分之旅" - }, - "tags": { - "editTitle": "编辑标签", - "inputPlaceholder": "输入标签名称,按 Enter 添加", - "selectedTags": "已选标签(点击取消)", - "noTagsSelected": "未选择标签", - "availableTags": "可选标签(点击选择)", - "noAvailableTags": "无可用标签", - "fetchFailed": "获取标签列表失败", - "nameTooLong": "标签名称不能超过 30 个字符", - "addFailed": "添加标签失败", - "deleteSuccess": "标签删除成功", - "deleteFailed": "删除标签失败" - }, - "recovery": { - "title": "SecScore 找回字符串", - "saved": "我已保存", - "export": "导出文本文件", - "exportHint": "建议导出后离线保存,遗失将无法找回。", - "hint": "建议导出后离线保存,遗失将无法找回。" - } -} + "logLevels": { + "debug": "DEBUG (调试)", + "info": "INFO (信息)", + "warn": "WARN (警告)", + "error": "ERROR (错误)" + } + }, + "url": { + "title": "URL 协议 (secscore://)", + "protocol": "URL 协议", + "description": "可以通过 URL 链接唤起 SecScore 并执行操作,例如:", + "register": "注册 URL 协议", + "registered": "URL 协议已注册", + "registerFailed": "注册失败", + "installerRequired": "需要安装版 SecScore,开发模式下可能无效。" + }, + "mcp": { + "title": "MCP 服务", + "description": "管理内置 MCP HTTP 服务,供外部 MCP 客户端调用。", + "running": "运行中", + "stopped": "已停止", + "noUrl": "暂无地址", + "host": "主机", + "port": "端口", + "start": "启动", + "stop": "停止", + "refresh": "刷新状态", + "hint": "建议仅绑定到 127.0.0.1;启动后 MCP 入口为 http://host:port/mcp", + "hostRequired": "请输入 MCP 主机地址", + "portInvalid": "请输入有效端口(1-65535)", + "startSuccess": "MCP 服务启动成功", + "startFailed": "MCP 服务启动失败", + "stopSuccess": "MCP 服务已停止", + "stopFailed": "MCP 服务停止失败", + "startTimeout": "启动 MCP 服务超时,请稍后重试", + "stopTimeout": "停止 MCP 服务超时,请稍后重试" + }, + "app": { + "title": "应用控制", + "description": "快速重启或退出应用。", + "restart": "重启应用", + "quit": "退出应用", + "confirmRestartTitle": "确认重启应用?", + "confirmRestartContent": "应用将立即关闭并重新启动。", + "confirmQuitTitle": "确认退出应用?", + "confirmQuitContent": "应用将立即退出。", + "restartFailed": "重启失败", + "quitFailed": "退出失败" + }, + "about": { + "title": "关于", + "appName": "教育积分管理", + "version": "版本", + "copyright": "版权", + "ipcStatus": "IPC 状态", + "ipcConnected": "已连接", + "ipcDisconnected": "未连接 (Preload 失败)", + "environment": "环境", + "toggleDevTools": "切换开发者工具", + "license": "SecScore遵循GPL3.0协议", + "copyRightText": "CopyRight © 2025-{{year}} SECTL" + } + }, + "sidebar": { + "home": "首页", + "students": "学生管理", + "score": "积分操作", + "boards": "看板", + "leaderboard": "排行榜", + "settlements": "结算历史", + "reasons": "理由管理", + "autoScore": "自动加分", + "rewardExchange": "奖励兑换", + "rewardSettings": "奖励设置", + "settings": "系统设置", + "remoteDb": "远程数据库", + "refreshStatus": "刷新状态", + "syncNow": "立即同步", + "syncSuccess": "同步成功", + "syncFailed": "同步失败", + "getDbStatusFailed": "获取数据库状态失败", + "notRemoteMode": "当前不是远程数据库模式,请重启应用后重试", + "dbNotConnected": "数据库未连接" + }, + "auth": { + "unlock": "权限解锁", + "unlockHint": "输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。", + "unlockButton": "解锁", + "unlocked": "权限已解锁", + "logout": "已切换为只读", + "passwordPlaceholder": "例如 123456", + "passwordError": "密码错误", + "enterPassword": "输入密码", + "lock": "锁定" + }, + "languages": { + "zh-CN": "简体中文", + "en-US": "English" + }, + "theme": { + "colorAndBackground": "颜色与背景", + "gradientLabels": { + "blue": "清爽蓝", + "pink": "柔和粉", + "cyan": "青蓝", + "purple": "紫韵" + }, + "gradientDirections": { + "vertical": "纵向", + "horizontal": "横向", + "diagonal": "对角" + }, + "generate": "生成", + "saved": "已保存", + "saveFailed": "保存失败", + "mode": "模式", + "myTheme": "我的主题" + }, + "permissions": { + "admin": "管理权限", + "points": "积分权限", + "view": "只读" + }, + "home": { + "title": "学生积分主页", + "subtitle": "共 {{count}} 名学生,点击卡片进行积分操作", + "searchPlaceholder": "搜索姓名/拼音...", + "sortBy": { + "alphabet": "姓名排序", + "surname": "姓氏分组", + "score": "积分排行" + }, + "layoutBy": { + "grouped": "分组列表", + "squareGrid": "正方形网格" + }, + "noStudents": "暂无学生数据,请前往学生管理添加", + "noMatch": "未找到匹配的学生", + "clearSearch": "清除搜索", + "points": "分", + "currentScore": "当前积分", + "quickOptions": "快捷选项", + "adjustPoints": "调整分值", + "customPoints": "自定义分值", + "customPointsHint": "可在输入框微调特输入任意分值", + "reason": "操作理由", + "reasonPlaceholder": "输入加分/扣分的原因(可选)", + "preview": "变更预览", + "noReason": "(无理由)", + "category": { + "others": "其他" + }, + "scoreAdded": "已为 {{name}} 加{{points}}分", + "scoreDeducted": "已为 {{name}} 扣{{points}}分", + "submitFailed": "提交失败", + "pleaseSelectPoints": "请选择或输入分值", + "reasonDefault": "{{action}}{{points}}分", + "studentCount": "{{count}} 人", + "operationTitle": "积分操作:{{name}}", + "submitOperation": "提交操作", + "operationTitleBatch": "积分操作:已选 {{count}} 人", + "undoLastAction": "撤销上一步", + "undoLastHint": "撤销上一条:{{name}} {{delta}}", + "undoUnavailable": "当前没有可撤销的积分操作", + "undoLastSuccess": "已撤销上一步积分操作", + "multiSelect": "多选", + "batchMode": "多选模式", + "selectAll": "全选", + "clearSelected": "清空已选", + "batchOperate": "批量积分", + "selected": "已选", + "selectedCount": "已选 {{count}} 人", + "batchSuccess": "已为 {{count}} 名学生提交积分", + "batchPartial": "成功提交 {{success}}/{{total}} 名学生积分", + "selectStudentFirst": "请先选择学生", + "addPoints": "加分", + "deductPoints": "扣分", + "pointsChange": "积分变更", + "reasonLabel": "理由:" + }, + "students": { + "title": "学生管理", + "importList": "导入名单", + "addStudent": "添加学生", + "name": "姓名", + "avatar": "头像", + "currentScore": "当前积分", + "tags": "标签", + "noTags": "无标签", + "editTags": "编辑标签", + "editAvatar": "设置头像", + "deleteConfirm": "确认删除该学生?", + "addTitle": "添加学生", + "addConfirm": "添加", + "namePlaceholder": "请输入学生姓名", + "nameRequired": "请输入姓名", + "nameExists": "学生姓名已存在", + "addSuccess": "添加成功", + "addFailed": "添加失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "tagSaveSuccess": "标签保存成功", + "tagSaveFailed": "保存标签失败", + "importTitle": "导入名单", + "importTextHint": "支持粘贴多行文本,一行一个姓名", + "importTextPlaceholder": "例如:\n张三\n李四\n王五", + "importByText": "从文本导入", + "importByXlsx": "通过 xlsx 导入", + "xlsxPreview": "xlsx 预览与导入", + "file": "文件", + "selectNameCol": "点击表头选择姓名列", + "previewRows": "预览前 50 行", + "importConfirm": "导入", + "importComplete": "导入完成:新增 {{inserted}},跳过 {{skipped}}", + "workerNotReady": "Worker 未初始化", + "parseXlsxFailed": "解析 xlsx 失败", + "selectNameColFirst": "请先点击选择\"姓名列\"", + "noNamesFound": "所选列未解析到可导入的姓名", + "importFailed": "导入失败", + "editTagTitle": "编辑标签 - {{name}}", + "editAvatarTitle": "设置头像 - {{name}}", + "avatarUpload": "上传图片", + "avatarClear": "清除头像", + "noAvatar": "无头像", + "avatarTip": "支持常见图片格式,文件大小不超过 2MB", + "avatarSaveSuccess": "头像保存成功", + "avatarSaveFailed": "头像保存失败", + "avatarInvalidFile": "请选择图片文件", + "avatarTooLarge": "图片不能超过 2MB", + "avatarReadFailed": "读取图片失败" + }, + "score": { + "title": "积分管理", + "student": "学生", + "change": "变动", + "reason": "理由", + "time": "时间", + "undoConfirm": "确定要撤销这条记录吗?学生积分将回滚。", + "undo": "撤销", + "undoSuccess": "已撤销操作", + "undoFailed": "撤销失败", + "recentRecords": "最近记录", + "pleaseSelectStudent": "请选择或搜索学生", + "selectAllStudents": "全班", + "clearSelectedStudents": "清空", + "selectedCount": "已选 {{selected}} / {{total}} 人", + "points": "分数", + "addPoints": "加分", + "deductPoints": "扣分", + "quickReason": "快捷理由", + "selectReason": "选择预设理由", + "reasonContent": "理由内容", + "reasonPlaceholder": "手动输入或选择快捷理由", + "submit": "确认提交", + "pleaseEnterInfo": "请填写完整信息", + "pleaseEnterPoints": "请填写分值或选择预设理由", + "batchSuccess": "已为 {{count}} 名学生提交积分", + "batchPartial": "成功提交 {{success}}/{{total}} 名学生的积分" + }, + "reasons": { + "title": "理由管理", + "addReason": "添加预设理由", + "category": "分类", + "content": "理由内容", + "presetPoints": "预设分值", + "deleteConfirm": "确认删除该理由?", + "addTitle": "添加理由", + "addConfirm": "添加", + "categoryPlaceholder": "例如: 学习, 纪律", + "contentPlaceholder": "请输入理由", + "pointsPlaceholder": "例如: 2 或 -2", + "contentRequired": "请输入理由内容", + "pointsRequired": "请输入预设分值", + "reasonExists": "该分类下已存在相同理由", + "addSuccess": "添加成功", + "addFailed": "添加失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "others": "其他" + }, + "rewardExchange": { + "title": "奖励兑换", + "enterMode": "进入奖励兑换模式", + "exitMode": "退出奖励兑换模式", + "modeHint": "当前为奖励兑换模式,点击学生卡可选择可兑换奖励。", + "normalHint": "当前显示学生原始积分,点击按钮后将显示可兑换积分。", + "remainingRewardPoints": "剩余兑换积分", + "currentScore": "当前积分", + "chooseRewardTitle": "为 {{name}} 选择奖励", + "currentRewardPoints": "当前可兑换积分:{{points}}", + "costLabel": "消耗积分:{{points}}", + "redeemNow": "立即兑换", + "noAffordableRewards": "当前没有可兑换的奖励,请先积累积分。", + "redeemSuccess": "{{student}} 成功兑换「{{reward}}」,消耗 {{points}} 分", + "redeemFailed": "兑换失败", + "recordsTitle": "兑换记录", + "recordStudent": "学生", + "recordReward": "奖励", + "recordCost": "消耗积分", + "recordTime": "兑换时间" + }, + "rewardSettings": { + "title": "奖励设置", + "addReward": "添加奖励", + "addTitle": "添加奖励", + "editTitle": "编辑奖励", + "rewardName": "奖励名称", + "costPoints": "消耗积分", + "namePlaceholder": "例如:免作业券", + "costPlaceholder": "请输入消耗积分", + "nameRequired": "请输入奖励名称", + "costRequired": "请输入正整数积分", + "createSuccess": "奖励创建成功", + "createFailed": "奖励创建失败", + "updateSuccess": "奖励更新成功", + "updateFailed": "奖励更新失败", + "deleteSuccess": "奖励删除成功", + "deleteFailed": "奖励删除失败", + "deleteConfirm": "确认删除奖励「{{name}}」?" + }, + "board": { + "title": "看板", + "editable": "可编辑", + "readonly": "只读", + "addBoard": "新增看板", + "removeBoard": "删除看板", + "removeBoardConfirm": "确认删除当前看板?", + "boardConfig": "看板配置", + "boardNamePlaceholder": "输入看板名称", + "renameBoard": "重命名看板", + "newBoard": "新看板", + "untitledBoard": "未命名看板", + "addList": "新增学生列表", + "removeList": "删除列表", + "removeListConfirm": "确认删除当前学生列表?", + "editList": "编辑", + "sqlEditorTitle": "编辑学生列表", + "splitHorizontal": "左右拆分", + "splitVertical": "上下拆分", + "dragHandle": "拖动调整布局", + "listNamePlaceholder": "输入列表名称", + "newList": "新列表", + "applyPreset": "套用预设", + "run": "运行 SQL", + "runAll": "运行全部", + "viewModes": { + "list": "列表视图", + "card": "卡片视图", + "grid": "网格视图" + }, + "metrics": { + "totalScore": "总分", + "rewardPoints": "奖励分", + "weekChange": "近7天", + "weekDeducted": "近7天扣分", + "todayAnswered": "今日回答" + }, + "sqlPlaceholder": "输入 SQL(仅支持单条 SELECT / WITH 查询)", + "templateHint": "支持时间模板变量:{{today_start}}、{{since_7d}}、{{since_30d}}、{{now}}", + "templateDescription": "可直接在 SQL 中使用模板变量,运行时会自动替换为 ISO 时间,兼容 SQLite 与 PostgreSQL。", + "saving": "保存中...", + "saveFailed": "保存看板失败", + "runFailed": "查询执行失败", + "keepAtLeastOneBoard": "至少保留一个看板", + "keepAtLeastOneList": "至少保留一个学生列表", + "presets": { + "weekLowDeduct": { + "name": "上周低扣分排行", + "description": "上一周扣分 < 3 的学生积分榜" + }, + "todayActive": { + "name": "今日活跃榜", + "description": "按今日回答次数与积分变化排序" + }, + "rewardRanking": { + "name": "奖励积分榜", + "description": "按奖励积分和总积分排序" + } + } + }, + "leaderboard": { + "title": "积分排行榜", + "rank": "排名", + "name": "姓名", + "totalScore": "总积分", + "todayChange": "今日变化", + "weekChange": "本周变化", + "monthChange": "本月变化", + "viewHistory": "查看", + "exportXlsx": "导出 XLSX", + "exportSuccess": "导出成功", + "queryFailed": "查询失败", + "historyTitle": "{{name}} - 操作记录", + "close": "关闭", + "today": "今天", + "week": "本周", + "month": "本月", + "operationRecord": "操作记录", + "change": "变化" + }, + "settlements": { + "title": "结算历史", + "phase": "阶段 #{{id}}", + "viewLeaderboard": "查看排行榜", + "recordCount": "记录数: {{count}}", + "noRecords": "暂无结算记录", + "noSettlements": "暂无结算记录", + "back": "返回", + "leaderboardTitle": "结算排行榜", + "phaseScore": "阶段积分", + "queryFailed": "查询失败", + "rank": "排名", + "name": "姓名" + }, + "autoScore": { + "title": "自动化加分管理", + "name": "自动化名称", + "namePlaceholder": "例如:每日签到加分", + "nameRequired": "请输入自动化名称", + "triggers": "触发器", + "actions": "行动", + "applicableStudents": "适用学生", + "allStudents": "所有学生", + "lastExecuted": "最后执行", + "notExecuted": "未执行", + "invalidTime": "无效时间", + "addTrigger": "添加规则", + "addAction": "添加行动", + "whenTriggered": "当以下规则触发时", + "triggeredActions": "满足规则时触发的行动", + "addAutomation": "添加自动化", + "updateAutomation": "更新自动化", + "cancelEdit": "取消编辑", + "resetForm": "重置表单", + "deleteConfirm": "确定要删除这条自动化吗?", + "adminRequired": "需要管理员权限以查看自动加分自动化,请先登录管理员账号", + "adminCreateRequired": "需要管理员权限以创建或更新自动加分自动化", + "adminDeleteRequired": "需要管理员权限以删除自动加分自动化", + "adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化", + "fetchFailed": "获取自动化失败", + "triggerRequired": "请至少添加一个触发器", + "actionRequired": "请至少添加一个行动", + "createSuccess": "自动化创建成功", + "updateSuccess": "自动化更新成功", + "createFailed": "创建自动化失败", + "updateFailed": "更新自动化失败", + "deleteSuccess": "自动化删除成功", + "deleteFailed": "删除自动化失败", + "enabled": "自动化已启用", + "disabled": "自动化已禁用", + "enableFailed": "启用自动化失败", + "disableFailed": "禁用自动化失败", + "noTriggerAvailable": "没有可用的触发器类型,请检查配置", + "noActionAvailable": "没有可用的行动类型,请检查配置", + "sort": "排序", + "status": "状态", + "triggerCount": "{{count}} 个触发器", + "actionCount": "{{count}} 个行动", + "studentCount": "{{count}} 名学生", + "studentPlaceholder": "请选择或搜索学生(留空表示所有学生)", + "triggerCondition": "触发条件", + "executeAction": "执行操作", + "addOperation": "添加操作", + "scoreLabel": "分数", + "tagNameLabel": "标签名称", + "operationNoteLabel": "操作说明(可选)", + "triggerIntervalTime": "间隔时间", + "triggerStudentTag": "学生标签", + "actionAddScore": "加分", + "actionAddTag": "添加标签", + "minutesPlaceholder": "请输入分钟数", + "minutes": "分钟", + "tagsPlaceholder": "请输入标签名称", + "scorePlaceholder": "请输入分数", + "tagNamePlaceholder": "请输入标签名称", + "reasonPlaceholder": "请输入理由", + "relationAnd": "并且", + "relationOr": "或者" + }, + "triggers": { + "studentTag": { + "label": "学生标签", + "description": "当学生匹配特定标签时触发自动化", + "placeholder": "请选择标签", + "required": "请输入标签名称" + }, + "randomTime": { + "label": "随机时间触发", + "description": "在指定时间范围内随机触发自动化", + "required": "请输入有效的时间范围(分钟)" + }, + "intervalTime": { + "label": "间隔时间", + "description": "当间隔时间到达时触发自动化", + "placeholder": "请输入时间间隔", + "required": "请输入有效的时间", + "monthLabel": "月", + "weekLabel": "周", + "timeLabel": "时间" + } + }, + "actions": { + "sendNotification": { + "label": "发送通知", + "description": "向学生发送通知", + "placeholder": "请输入通知内容" + }, + "addTag": { + "label": "添加标签", + "description": "为学生添加标签", + "placeholder": "请输入标签" + }, + "addScore": { + "label": "添加分数", + "description": "为学生添加分数", + "pointsPlaceholder": "请输入分数", + "reasonPlaceholder": "请输入理由" + } + }, + "wizard": { + "welcomeTitle": "欢迎使用 SecScore 积分管理", + "welcomeDesc": "感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。", + "configComplete": "配置完成!", + "configFailed": "配置保存失败", + "startJourney": "开启积分之旅" + }, + "tags": { + "editTitle": "编辑标签", + "inputPlaceholder": "输入标签名称,按 Enter 添加", + "selectedTags": "已选标签(点击取消)", + "noTagsSelected": "未选择标签", + "availableTags": "可选标签(点击选择)", + "noAvailableTags": "无可用标签", + "fetchFailed": "获取标签列表失败", + "nameTooLong": "标签名称不能超过 30 个字符", + "addFailed": "添加标签失败", + "deleteSuccess": "标签删除成功", + "deleteFailed": "删除标签失败" + }, + "recovery": { + "title": "SecScore 找回字符串", + "saved": "我已保存", + "export": "导出文本文件", + "exportHint": "建议导出后离线保存,遗失将无法找回。", + "hint": "建议导出后离线保存,遗失将无法找回。" + } +} diff --git a/src/preload/types.ts b/src/preload/types.ts index 9fdacb7..1fe2adc 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -1,404 +1,405 @@ -import { invoke } from "@tauri-apps/api/core" -import { listen, UnlistenFn } from "@tauri-apps/api/event" - -export interface themeConfig { - name: string - id: string - mode: "light" | "dark" - config: { - tdesign: Record - custom: Record - } -} - -export interface settingChange { - key: string - value: any - oldValue: any -} - -export type settingsKey = - | "is_wizard_completed" - | "log_level" - | "window_zoom" - | "search_keyboard_layout" - | "disable_search_keyboard" - | "themes_custom" - | "auto_score_enabled" - | "auto_score_rules" - | "current_theme_id" - | "dashboards_config" - | "pg_connection_string" - | "pg_connection_status" - -export interface settingsSpec { - is_wizard_completed: boolean - log_level: string - window_zoom: number - search_keyboard_layout: "t9" | "qwerty26" - disable_search_keyboard: boolean - themes_custom: themeConfig[] - auto_score_enabled: boolean - auto_score_rules: any[] - current_theme_id: string - dashboards_config: any[] - pg_connection_string: string - pg_connection_status: { - connected: boolean - type: "sqlite" | "postgresql" - error?: string - } -} - -const api = { - // Theme - getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"), - getCurrentTheme: (): Promise<{ success: boolean; data: themeConfig }> => invoke("theme_current"), - setTheme: (themeId: string): Promise<{ success: boolean }> => invoke("theme_set", { themeId }), - saveTheme: (theme: themeConfig): Promise<{ success: boolean }> => invoke("theme_save", { theme }), - deleteTheme: (themeId: string): Promise<{ success: boolean }> => - invoke("theme_delete", { themeId }), - onThemeChanged: (callback: (theme: themeConfig) => void): Promise => { - return listen("theme:updated", (event) => { - const payload = event.payload as themeConfig | { theme?: themeConfig } | undefined - const theme = - payload && typeof payload === "object" && "theme" in payload - ? payload.theme - : (payload as themeConfig | undefined) - if (theme) callback(theme) - }) - }, - - // DB - Student - queryStudents: (params?: any): Promise<{ success: boolean; data: any[] }> => - invoke("student_query", { params }), - createStudent: (data: { - name: string - }): Promise<{ success: boolean; data?: number; message?: string }> => - invoke("student_create", { data }), - updateStudent: (id: number, data: any): Promise<{ success: boolean }> => - invoke("student_update", { id, data }), - deleteStudent: (id: number): Promise<{ success: boolean }> => invoke("student_delete", { id }), - importStudentsFromXlsx: (params: { - names: string[] - }): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> => - invoke("student_import_from_xlsx", { params }), - - // DB - Tags - tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"), - tagsGetByStudent: (studentId: number): Promise<{ success: boolean; data: any[] }> => - invoke("tags_get_by_student", { studentId }), - tagsCreate: (name: string): Promise<{ success: boolean; data: any }> => - invoke("tags_create", { name }), - tagsDelete: (id: number): Promise<{ success: boolean }> => invoke("tags_delete", { id }), - tagsUpdateStudentTags: (studentId: number, tagIds: number[]): Promise<{ success: boolean }> => - invoke("tags_update_student_tags", { studentId, tagIds }), - - // DB - Reason - queryReasons: (): Promise<{ success: boolean; data: any[] }> => invoke("reason_query"), - createReason: (data: any): Promise<{ success: boolean; data?: number; message?: string }> => - invoke("reason_create", { data }), - updateReason: (id: number, data: any): Promise<{ success: boolean }> => - invoke("reason_update", { id, data }), - deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }), - - // DB - Reward - rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"), - rewardSettingCreate: (data: { - name: string - cost_points: number - }): Promise<{ success: boolean; data?: number; message?: string }> => - invoke("reward_setting_create", { data }), - rewardSettingUpdate: (id: number, data: any): Promise<{ success: boolean; message?: string }> => - invoke("reward_setting_update", { id, data }), - rewardSettingDelete: (id: number): Promise<{ success: boolean; message?: string }> => - invoke("reward_setting_delete", { id }), - rewardRedeem: (data: { - student_name: string - reward_id: number - }): Promise<{ - success: boolean - data?: { redemption_id: number; remaining_reward_points: number } - message?: string - }> => invoke("reward_redeem", { data }), - rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => - invoke("reward_redemption_query", { params }), - - // DB - Event - queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => - invoke("event_query", { params }), - createEvent: (data: { - studentName: string - reasonContent: string - delta: number - }): Promise<{ success: boolean; data?: number; message?: string }> => - invoke("event_create", { data }), - deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }), - queryEventsByStudent: (params: { - studentName: string - limit?: number - startTime?: string - }): Promise<{ success: boolean; data: any[] }> => invoke("event_query_by_student", { params }), - queryLeaderboard: (params: { - range: "today" | "week" | "month" - }): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> => - invoke("leaderboard_query", { params }), - boardQuerySql: (params: { - sql: string - limit?: number - }): Promise<{ success: boolean; data: any[]; message?: string }> => - invoke("board_query_sql", { params }), - - // Settlement - querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"), - createSettlement: (): Promise<{ success: boolean; data: any }> => invoke("db_settlement_create"), - querySettlementLeaderboard: (params: { - settlementId: number - }): Promise<{ success: boolean; data: any }> => invoke("db_settlement_leaderboard", { params }), - - // Settings & Sync - getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => - invoke("settings_get_all"), - getSetting: ( - key: K - ): Promise<{ success: boolean; data: settingsSpec[K] }> => invoke("settings_get", { key }), - setSetting: ( - key: K, - value: settingsSpec[K] - ): Promise<{ success: boolean }> => invoke("settings_set", { key, value }), - onSettingChanged: (callback: (change: settingChange) => void): Promise => { - return listen("settings:changed", (event) => { - callback(event.payload) - }) - }, - - // Auth & Security - authGetStatus: (): Promise<{ - success: boolean - data: { - permission: string - hasAdminPassword: boolean - hasPointsPassword: boolean - hasRecoveryString: boolean - } - }> => invoke("auth_get_status"), - authLogin: ( - password: string - ): Promise<{ success: boolean; data?: { permission: string }; message?: string }> => - invoke("auth_login", { password }), - authLogout: (): Promise<{ success: boolean; data: { permission: string } }> => - invoke("auth_logout"), - authSetPasswords: (payload: { - adminPassword?: string | null - pointsPassword?: string | null - }): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> => - invoke("auth_set_passwords", payload), - authGenerateRecovery: (): Promise<{ success: boolean; data: { recoveryString: string } }> => - invoke("auth_generate_recovery"), - authResetByRecovery: ( - recoveryString: string - ): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> => - invoke("auth_reset_by_recovery", { recoveryString }), - authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"), - +import { invoke } from "@tauri-apps/api/core" +import { listen, UnlistenFn } from "@tauri-apps/api/event" + +export interface themeConfig { + name: string + id: string + mode: "light" | "dark" + config: { + tdesign: Record + custom: Record + } +} + +export interface settingChange { + key: string + value: any + oldValue: any +} + +export type settingsKey = + | "is_wizard_completed" + | "log_level" + | "window_zoom" + | "search_keyboard_layout" + | "disable_search_keyboard" + | "themes_custom" + | "auto_score_enabled" + | "auto_score_rules" + | "current_theme_id" + | "dashboards_config" + | "pg_connection_string" + | "pg_connection_status" + +export interface settingsSpec { + is_wizard_completed: boolean + log_level: string + window_zoom: number + search_keyboard_layout: "t9" | "qwerty26" + disable_search_keyboard: boolean + themes_custom: themeConfig[] + auto_score_enabled: boolean + auto_score_rules: any[] + current_theme_id: string + dashboards_config: any[] + pg_connection_string: string + pg_connection_status: { + connected: boolean + type: "sqlite" | "postgresql" + error?: string + } +} + +const api = { + // Theme + getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"), + getCurrentTheme: (): Promise<{ success: boolean; data: themeConfig }> => invoke("theme_current"), + setTheme: (themeId: string): Promise<{ success: boolean }> => invoke("theme_set", { themeId }), + saveTheme: (theme: themeConfig): Promise<{ success: boolean }> => invoke("theme_save", { theme }), + deleteTheme: (themeId: string): Promise<{ success: boolean }> => + invoke("theme_delete", { themeId }), + onThemeChanged: (callback: (theme: themeConfig) => void): Promise => { + return listen("theme:updated", (event) => { + const payload = event.payload as themeConfig | { theme?: themeConfig } | undefined + const theme = + payload && typeof payload === "object" && "theme" in payload + ? payload.theme + : (payload as themeConfig | undefined) + if (theme) callback(theme) + }) + }, + + // DB - Student + queryStudents: (params?: any): Promise<{ success: boolean; data: any[] }> => + invoke("student_query", { params }), + createStudent: (data: { + name: string + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("student_create", { data }), + updateStudent: (id: number, data: any): Promise<{ success: boolean }> => + invoke("student_update", { id, data }), + deleteStudent: (id: number): Promise<{ success: boolean }> => invoke("student_delete", { id }), + importStudentsFromXlsx: (params: { + names: string[] + }): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> => + invoke("student_import_from_xlsx", { params }), + + // DB - Tags + tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"), + tagsGetByStudent: (studentId: number): Promise<{ success: boolean; data: any[] }> => + invoke("tags_get_by_student", { studentId }), + tagsCreate: (name: string): Promise<{ success: boolean; data: any }> => + invoke("tags_create", { name }), + tagsDelete: (id: number): Promise<{ success: boolean }> => invoke("tags_delete", { id }), + tagsUpdateStudentTags: (studentId: number, tagIds: number[]): Promise<{ success: boolean }> => + invoke("tags_update_student_tags", { studentId, tagIds }), + + // DB - Reason + queryReasons: (): Promise<{ success: boolean; data: any[] }> => invoke("reason_query"), + createReason: (data: any): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("reason_create", { data }), + updateReason: (id: number, data: any): Promise<{ success: boolean }> => + invoke("reason_update", { id, data }), + deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }), + + // DB - Reward + rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"), + rewardSettingCreate: (data: { + name: string + cost_points: number + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("reward_setting_create", { data }), + rewardSettingUpdate: (id: number, data: any): Promise<{ success: boolean; message?: string }> => + invoke("reward_setting_update", { id, data }), + rewardSettingDelete: (id: number): Promise<{ success: boolean; message?: string }> => + invoke("reward_setting_delete", { id }), + rewardRedeem: (data: { + student_name: string + reward_id: number + }): Promise<{ + success: boolean + data?: { redemption_id: number; remaining_reward_points: number } + message?: string + }> => invoke("reward_redeem", { data }), + rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => + invoke("reward_redemption_query", { params }), + + // DB - Event + queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => + invoke("event_query", { params }), + createEvent: (data: { + studentName: string + reasonContent: string + delta: number + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("event_create", { data }), + deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }), + queryEventsByStudent: (params: { + studentName: string + limit?: number + startTime?: string + }): Promise<{ success: boolean; data: any[] }> => invoke("event_query_by_student", { params }), + queryLeaderboard: (params: { + range: "today" | "week" | "month" + }): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> => + invoke("leaderboard_query", { params }), + boardQuerySql: (params: { + sql: string + limit?: number + }): Promise<{ success: boolean; data: any[]; message?: string }> => + invoke("board_query_sql", { params }), + + // Settlement + querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"), + createSettlement: (): Promise<{ success: boolean; data: any }> => invoke("db_settlement_create"), + querySettlementLeaderboard: (params: { + settlementId: number + }): Promise<{ success: boolean; data: any }> => invoke("db_settlement_leaderboard", { params }), + + // Settings & Sync + getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => + invoke("settings_get_all"), + getSetting: ( + key: K + ): Promise<{ success: boolean; data: settingsSpec[K] }> => invoke("settings_get", { key }), + setSetting: ( + key: K, + value: settingsSpec[K] + ): Promise<{ success: boolean }> => invoke("settings_set", { key, value }), + onSettingChanged: (callback: (change: settingChange) => void): Promise => { + return listen("settings:changed", (event) => { + callback(event.payload) + }) + }, + + // Auth & Security + authGetStatus: (): Promise<{ + success: boolean + data: { + permission: string + hasAdminPassword: boolean + hasPointsPassword: boolean + hasRecoveryString: boolean + } + }> => invoke("auth_get_status"), + authLogin: ( + password: string + ): Promise<{ success: boolean; data?: { permission: string }; message?: string }> => + invoke("auth_login", { password }), + authLogout: (): Promise<{ success: boolean; data: { permission: string } }> => + invoke("auth_logout"), + authSetPasswords: (payload: { + adminPassword?: string | null + pointsPassword?: string | null + }): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> => + invoke("auth_set_passwords", payload), + authGenerateRecovery: (): Promise<{ success: boolean; data: { recoveryString: string } }> => + invoke("auth_generate_recovery"), + authResetByRecovery: ( + recoveryString: string + ): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> => + invoke("auth_reset_by_recovery", { recoveryString }), + authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"), + // Data import/export exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"), importDataJson: (jsonText: string): Promise<{ success: boolean }> => invoke("data_import_json", { jsonText }), - - // Window - windowMinimize: (): Promise => invoke("window_minimize"), - windowMaximize: (): Promise => invoke("window_maximize"), - windowClose: (): Promise => invoke("window_close"), - windowIsMaximized: (): Promise => invoke("window_is_maximized"), - toggleDevTools: (): Promise => invoke("toggle_devtools"), - windowResize: (width: number, height: number): Promise => - invoke("window_resize", { width, height }), - windowSetResizable: (resizable: boolean): Promise => - invoke("window_set_resizable", { resizable }), - onWindowMaximizedChanged: (callback: (maximized: boolean) => void): Promise => { - return listen("window:maximized-changed", (event) => { - callback(event.payload) - }) - }, - onNavigate: (callback: (route: string) => void): Promise => { - return listen("app:navigate", (event) => { - callback(event.payload) - }) - }, - - // Logger - queryLogs: ( - input?: number | { lines?: number } - ): Promise<{ success: boolean; data: string[] }> => { - const lines = typeof input === "number" ? input : input?.lines - return invoke("log_query", { lines }) - }, - clearLogs: (): Promise<{ success: boolean }> => invoke("log_clear"), - setLogLevel: (level: string): Promise<{ success: boolean }> => invoke("log_set_level", { level }), - writeLog: (payload: { - level: string - message: string - meta?: any - }): Promise<{ success: boolean }> => invoke("log_write", { payload }), - - // Database Connection - dbTestConnection: ( - connectionString: string - ): Promise<{ success: boolean; data: { success: boolean; error?: string } }> => - invoke("db_test_connection", { connectionString }), - dbSwitchConnection: ( - connectionString: string - ): Promise<{ success: boolean; data: { type: "sqlite" | "postgresql" } }> => - invoke("db_switch_connection", { connectionString }), - dbGetStatus: (): Promise<{ - success: boolean - data: { type: string; connected: boolean; error?: string } - }> => invoke("db_get_status"), - dbSync: (): Promise<{ success: boolean; data: { success: boolean; message?: string } }> => - invoke("db_sync"), - dbSyncPreview: (): Promise<{ - success: boolean - data: { - can_sync: boolean - need_sync: boolean - local_only: number - remote_only: number - conflicts: Array<{ - table: string - key: string - local_summary: string - remote_summary: string - }> - message?: string - } - }> => invoke("db_sync_preview"), - dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{ - success: boolean - data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string } - }> => invoke("db_sync_apply", { strategy }), - - // HTTP Server - httpServerStart: (config?: { - port?: number - host?: string - corsOrigin?: string - }): Promise<{ success: boolean; data: { url: string; config: any } }> => - invoke("http_server_start", { config }), - httpServerStop: (): Promise<{ success: boolean }> => invoke("http_server_stop"), - httpServerStatus: (): Promise<{ - success: boolean - data: { isRunning: boolean; config?: any; url?: string } - }> => invoke("http_server_status"), - - // MCP Server - mcpServerStart: (config?: { - port?: number - host?: string - }): Promise<{ success: boolean; data?: { url: string; config: { port: number; host: string } } }> => - invoke("mcp_server_start", { config }), - mcpServerStop: (): Promise<{ success: boolean }> => invoke("mcp_server_stop"), - mcpServerStatus: (): Promise<{ - success: boolean - data?: { - is_running: boolean - config: { port: number; host: string } - url?: string | null - } - }> => invoke("mcp_server_status"), - - // File System - fsGetConfigStructure: (): Promise<{ - success: boolean - data: { configRoot: string; automatic: string; script: string } - }> => invoke("fs_get_config_structure"), - fsReadJson: ( - relativePath: string, - folder?: "automatic" | "script" - ): Promise<{ success: boolean; data: any }> => invoke("fs_read_json", { relativePath, folder }), - fsWriteJson: ( - relativePath: string, - data: any, - folder?: "automatic" | "script" - ): Promise<{ success: boolean }> => invoke("fs_write_json", { relativePath, data, folder }), - fsReadText: ( - relativePath: string, - folder?: "automatic" | "script" - ): Promise<{ success: boolean; data: string }> => - invoke("fs_read_text", { relativePath, folder }), - fsWriteText: ( - content: string, - relativePath: string, - folder?: "automatic" | "script" - ): Promise<{ success: boolean }> => invoke("fs_write_text", { content, relativePath, folder }), - fsDeleteFile: ( - relativePath: string, - folder?: "automatic" | "script" - ): Promise<{ success: boolean }> => invoke("fs_delete_file", { relativePath, folder }), - fsListFiles: (folder?: "automatic" | "script"): Promise<{ success: boolean; data: any[] }> => - invoke("fs_list_files", { folder }), - fsFileExists: ( - relativePath: string, - folder?: "automatic" | "script" - ): Promise<{ success: boolean; data: boolean }> => - invoke("fs_file_exists", { relativePath, folder }), - - // App - registerUrlProtocol: (): Promise<{ - success: boolean - data?: { registered: boolean } - message?: string - }> => invoke("register_url_protocol"), - appQuit: (): Promise => invoke("app_quit"), - appRestart: (): Promise => invoke("app_restart"), - - // Auto Score - autoScoreGetRules: (): Promise<{ success: boolean; data: any[] }> => - invoke("auto_score_get_rules"), - autoScoreAddRule: (rule: any): Promise<{ success: boolean; data?: number; message?: string }> => - invoke("auto_score_add_rule", { rule }), - autoScoreUpdateRule: ( - rule: any - ): Promise<{ success: boolean; data?: boolean; message?: string }> => - invoke("auto_score_update_rule", { rule }), - autoScoreDeleteRule: ( - ruleId: number - ): Promise<{ success: boolean; data?: boolean; message?: string }> => - invoke("auto_score_delete_rule", { ruleId }), - autoScoreToggleRule: (params: { - ruleId: number - enabled: boolean - }): Promise<{ success: boolean; data?: boolean; message?: string }> => - invoke("auto_score_toggle_rule", params), - autoScoreGetStatus: (): Promise<{ success: boolean; data: { enabled: boolean } }> => - invoke("auto_score_get_status"), - autoScoreSortRules: ( - ruleIds: number[] - ): Promise<{ success: boolean; data?: boolean; message?: string }> => - invoke("auto_score_sort_rules", { ruleIds }), - - // Generic invoke wrapper for backward compatibility with callers using `api.invoke` - invoke: async (channel: string, ...args: any[]): Promise => { - switch (channel) { - case "auto-score:getRules": - return api.autoScoreGetRules() - case "auto-score:addRule": - return api.autoScoreAddRule(args[0]) - case "auto-score:updateRule": - return api.autoScoreUpdateRule(args[0]) - case "auto-score:deleteRule": - return api.autoScoreDeleteRule(args[0]) - case "auto-score:toggleRule": - return api.autoScoreToggleRule(args[0]) - case "auto-score:sortRules": - return api.autoScoreSortRules(args[0]) - case "auto-score:getStatus": - return api.autoScoreGetStatus() - default: - throw new Error(`Unsupported legacy invoke channel: ${channel}`) - } - }, -} - -export default api -export { api } + dataResetAll: (): Promise<{ success: boolean }> => invoke("data_reset_all"), + + // Window + windowMinimize: (): Promise => invoke("window_minimize"), + windowMaximize: (): Promise => invoke("window_maximize"), + windowClose: (): Promise => invoke("window_close"), + windowIsMaximized: (): Promise => invoke("window_is_maximized"), + toggleDevTools: (): Promise => invoke("toggle_devtools"), + windowResize: (width: number, height: number): Promise => + invoke("window_resize", { width, height }), + windowSetResizable: (resizable: boolean): Promise => + invoke("window_set_resizable", { resizable }), + onWindowMaximizedChanged: (callback: (maximized: boolean) => void): Promise => { + return listen("window:maximized-changed", (event) => { + callback(event.payload) + }) + }, + onNavigate: (callback: (route: string) => void): Promise => { + return listen("app:navigate", (event) => { + callback(event.payload) + }) + }, + + // Logger + queryLogs: ( + input?: number | { lines?: number } + ): Promise<{ success: boolean; data: string[] }> => { + const lines = typeof input === "number" ? input : input?.lines + return invoke("log_query", { lines }) + }, + clearLogs: (): Promise<{ success: boolean }> => invoke("log_clear"), + setLogLevel: (level: string): Promise<{ success: boolean }> => invoke("log_set_level", { level }), + writeLog: (payload: { + level: string + message: string + meta?: any + }): Promise<{ success: boolean }> => invoke("log_write", { payload }), + + // Database Connection + dbTestConnection: ( + connectionString: string + ): Promise<{ success: boolean; data: { success: boolean; error?: string } }> => + invoke("db_test_connection", { connectionString }), + dbSwitchConnection: ( + connectionString: string + ): Promise<{ success: boolean; data: { type: "sqlite" | "postgresql" } }> => + invoke("db_switch_connection", { connectionString }), + dbGetStatus: (): Promise<{ + success: boolean + data: { type: string; connected: boolean; error?: string } + }> => invoke("db_get_status"), + dbSync: (): Promise<{ success: boolean; data: { success: boolean; message?: string } }> => + invoke("db_sync"), + dbSyncPreview: (): Promise<{ + success: boolean + data: { + can_sync: boolean + need_sync: boolean + local_only: number + remote_only: number + conflicts: Array<{ + table: string + key: string + local_summary: string + remote_summary: string + }> + message?: string + } + }> => invoke("db_sync_preview"), + dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{ + success: boolean + data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string } + }> => invoke("db_sync_apply", { strategy }), + + // HTTP Server + httpServerStart: (config?: { + port?: number + host?: string + corsOrigin?: string + }): Promise<{ success: boolean; data: { url: string; config: any } }> => + invoke("http_server_start", { config }), + httpServerStop: (): Promise<{ success: boolean }> => invoke("http_server_stop"), + httpServerStatus: (): Promise<{ + success: boolean + data: { isRunning: boolean; config?: any; url?: string } + }> => invoke("http_server_status"), + + // MCP Server + mcpServerStart: (config?: { + port?: number + host?: string + }): Promise<{ success: boolean; data?: { url: string; config: { port: number; host: string } } }> => + invoke("mcp_server_start", { config }), + mcpServerStop: (): Promise<{ success: boolean }> => invoke("mcp_server_stop"), + mcpServerStatus: (): Promise<{ + success: boolean + data?: { + is_running: boolean + config: { port: number; host: string } + url?: string | null + } + }> => invoke("mcp_server_status"), + + // File System + fsGetConfigStructure: (): Promise<{ + success: boolean + data: { configRoot: string; automatic: string; script: string } + }> => invoke("fs_get_config_structure"), + fsReadJson: ( + relativePath: string, + folder?: "automatic" | "script" + ): Promise<{ success: boolean; data: any }> => invoke("fs_read_json", { relativePath, folder }), + fsWriteJson: ( + relativePath: string, + data: any, + folder?: "automatic" | "script" + ): Promise<{ success: boolean }> => invoke("fs_write_json", { relativePath, data, folder }), + fsReadText: ( + relativePath: string, + folder?: "automatic" | "script" + ): Promise<{ success: boolean; data: string }> => + invoke("fs_read_text", { relativePath, folder }), + fsWriteText: ( + content: string, + relativePath: string, + folder?: "automatic" | "script" + ): Promise<{ success: boolean }> => invoke("fs_write_text", { content, relativePath, folder }), + fsDeleteFile: ( + relativePath: string, + folder?: "automatic" | "script" + ): Promise<{ success: boolean }> => invoke("fs_delete_file", { relativePath, folder }), + fsListFiles: (folder?: "automatic" | "script"): Promise<{ success: boolean; data: any[] }> => + invoke("fs_list_files", { folder }), + fsFileExists: ( + relativePath: string, + folder?: "automatic" | "script" + ): Promise<{ success: boolean; data: boolean }> => + invoke("fs_file_exists", { relativePath, folder }), + + // App + registerUrlProtocol: (): Promise<{ + success: boolean + data?: { registered: boolean } + message?: string + }> => invoke("register_url_protocol"), + appQuit: (): Promise => invoke("app_quit"), + appRestart: (): Promise => invoke("app_restart"), + + // Auto Score + autoScoreGetRules: (): Promise<{ success: boolean; data: any[] }> => + invoke("auto_score_get_rules"), + autoScoreAddRule: (rule: any): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("auto_score_add_rule", { rule }), + autoScoreUpdateRule: ( + rule: any + ): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_update_rule", { rule }), + autoScoreDeleteRule: ( + ruleId: number + ): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_delete_rule", { ruleId }), + autoScoreToggleRule: (params: { + ruleId: number + enabled: boolean + }): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_toggle_rule", params), + autoScoreGetStatus: (): Promise<{ success: boolean; data: { enabled: boolean } }> => + invoke("auto_score_get_status"), + autoScoreSortRules: ( + ruleIds: number[] + ): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_sort_rules", { ruleIds }), + + // Generic invoke wrapper for backward compatibility with callers using `api.invoke` + invoke: async (channel: string, ...args: any[]): Promise => { + switch (channel) { + case "auto-score:getRules": + return api.autoScoreGetRules() + case "auto-score:addRule": + return api.autoScoreAddRule(args[0]) + case "auto-score:updateRule": + return api.autoScoreUpdateRule(args[0]) + case "auto-score:deleteRule": + return api.autoScoreDeleteRule(args[0]) + case "auto-score:toggleRule": + return api.autoScoreToggleRule(args[0]) + case "auto-score:sortRules": + return api.autoScoreSortRules(args[0]) + case "auto-score:getStatus": + return api.autoScoreGetStatus() + default: + throw new Error(`Unsupported legacy invoke channel: ${channel}`) + } + }, +} + +export default api +export { api }