From 7971c6adbc11af0f6f530129e0e6673ef80d84ac Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 10 Jul 2026 22:26:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/capabilities/default.json | 2 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/commands/app.rs | 138 ++++++++++++++++++------ src-tauri/src/commands/auth.rs | 44 ++++---- src-tauri/src/commands/plugin.rs | 18 +++- src-tauri/src/commands/window.rs | 127 +++++++++++++++------- src-tauri/src/lib.rs | 8 +- src-tauri/src/services/auto_score.rs | 4 +- src-tauri/src/services/plugin.rs | 36 +++++-- src/App.tsx | 78 +++++++++----- src/components/ContentArea.tsx | 44 ++++---- src/components/Sidebar.tsx | 4 +- src/preload/types.ts | 1 + 13 files changed, 343 insertions(+), 163 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9bc1781..1cd4342 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "identifier": "default", "description": "Default capabilities for SecScore application", - "windows": ["main"], + "windows": ["main", "management"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 3dbad03..ec4cbd6 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for SecScore application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-start-dragging","core:webview:default","core:app:default","core:tray:default","core:tray:allow-new","core:tray:allow-set-icon","core:tray:allow-set-menu","core:menu:default","shell:allow-open"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for SecScore application","local":true,"windows":["main","management"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-start-dragging","core:webview:default","core:app:default","core:tray:default","core:tray:allow-new","core:tray:allow-set-icon","core:tray:allow-set-menu","core:menu:default","shell:allow-open"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 57ae32c..aea5f62 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -68,14 +68,23 @@ if ($urlProtocol -and $command) {{ let (registered, details) = match output { Ok(o) => { let stdout = String::from_utf8_lossy(&o.stdout).to_string(); - eprintln!("[URL Protocol] Windows PowerShell check output: {}", stdout.trim()); + eprintln!( + "[URL Protocol] Windows PowerShell check output: {}", + stdout.trim() + ); if stdout.contains("REGISTERED") { - (true, "URL protocol fully registered in Windows Registry".to_string()) + ( + true, + "URL protocol fully registered in Windows Registry".to_string(), + ) } else if stdout.contains("PARTIAL") { (false, format!("Partial registration: {}", stdout.trim())) } else { - (false, "Registry key HKCU\\Software\\Classes\\secscore not found".to_string()) + ( + false, + "Registry key HKCU\\Software\\Classes\\secscore not found".to_string(), + ) } } Err(e) => { @@ -84,7 +93,10 @@ if ($urlProtocol -and $command) {{ } }; - eprintln!("[URL Protocol] Windows check result: registered={}, {}", registered, details); + eprintln!( + "[URL Protocol] Windows check result: registered={}, {}", + registered, details + ); Ok(IpcResponse::success(UrlProtocolStatus { registered, @@ -113,7 +125,8 @@ if ($urlProtocol -and $command) {{ let stdout = String::from_utf8_lossy(&o.stdout); if stdout.contains("secscore") { registered = true; - details = "secscore:// registered via Launch Services (app bundle)".to_string(); + details = + "secscore:// registered via Launch Services (app bundle)".to_string(); } else { details = "secscore:// not found in Launch Services dump".to_string(); } @@ -135,7 +148,10 @@ if ($urlProtocol -and $command) {{ } } - eprintln!("[URL Protocol] macOS check result: registered={}, {}", registered, details); + eprintln!( + "[URL Protocol] macOS check result: registered={}, {}", + registered, details + ); Ok(IpcResponse::success(UrlProtocolStatus { registered, @@ -165,7 +181,8 @@ if ($urlProtocol -and $command) {{ let handler = String::from_utf8_lossy(&o.stdout).trim().to_string(); if handler == "secscore.desktop" { registered = true; - details = "secscore.desktop is default URL scheme handler".to_string(); + details = "secscore.desktop is default URL scheme handler" + .to_string(); } else { registered = true; details = format!( @@ -176,11 +193,15 @@ if ($urlProtocol -and $command) {{ } _ => { registered = true; - details = "Desktop file exists with MimeType (xdg-settings query failed)".to_string(); + details = + "Desktop file exists with MimeType (xdg-settings query failed)" + .to_string(); } } } else { - details = "Desktop file exists but missing x-scheme-handler/secscore MimeType".to_string(); + details = + "Desktop file exists but missing x-scheme-handler/secscore MimeType" + .to_string(); } } Err(e) => { @@ -191,7 +212,10 @@ if ($urlProtocol -and $command) {{ details = "secscore.desktop not found in ~/.local/share/applications".to_string(); } - eprintln!("[URL Protocol] Linux check result: registered={}, {}", registered, details); + eprintln!( + "[URL Protocol] Linux check result: registered={}, {}", + registered, details + ); Ok(IpcResponse::success(UrlProtocolStatus { registered, @@ -244,7 +268,10 @@ if (-not (Test-Path 'HKCU:\Software\Classes\{protocol}')) {{ Ok(o) => { let stdout = String::from_utf8_lossy(&o.stdout); let ok = o.status.success() && stdout.contains("SUCCESS"); - eprintln!("[URL Protocol] Windows PowerShell unregister result: {}", ok); + eprintln!( + "[URL Protocol] Windows PowerShell unregister result: {}", + ok + ); ok } Err(e) => { @@ -271,7 +298,10 @@ if (-not (Test-Path 'HKCU:\Software\Classes\{protocol}')) {{ match output { Ok(o) => { - eprintln!("[URL Protocol] macOS lsregister -u result: {}", o.status.success()); + eprintln!( + "[URL Protocol] macOS lsregister -u result: {}", + o.status.success() + ); } Err(e) => { eprintln!("[URL Protocol] macOS lsregister -u error: {}", e); @@ -310,7 +340,10 @@ if (-not (Test-Path 'HKCU:\Software\Classes\{protocol}')) {{ if desktop_path.exists() { match fs::remove_file(&desktop_path) { Ok(_) => { - eprintln!("[URL Protocol] Linux desktop file removed: {:?}", desktop_path); + eprintln!( + "[URL Protocol] Linux desktop file removed: {:?}", + desktop_path + ); let apps_dir = home.join(".local/share/applications"); let _ = std::process::Command::new("update-desktop-database") .arg(apps_dir.to_string_lossy().to_string()) @@ -397,7 +430,9 @@ pub async fn request_elevation( use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW; let verb: Vec = OsStr::new("runas\0").encode_wide().collect(); - let file: Vec = OsStr::new(&format!("{}\0", exe_str)).encode_wide().collect(); + let file: Vec = OsStr::new(&format!("{}\0", exe_str)) + .encode_wide() + .collect(); let params: Vec = OsStr::new("\0").encode_wide().collect(); let result = unsafe { @@ -442,7 +477,10 @@ pub async fn request_elevation( app.exit(0); Ok(IpcResponse::success(())) } - Ok(s) => Err(format!("Elevation cancelled or failed (exit code: {:?})", s.code())), + Ok(s) => Err(format!( + "Elevation cancelled or failed (exit code: {:?})", + s.code() + )), Err(e) => Err(format!("Failed to request elevation: {}", e)), } } @@ -453,16 +491,17 @@ pub async fn request_elevation( std::env::current_exe().map_err(|e| format!("Failed to get exe path: {}", e))?; let exe_str = exe_path.to_string_lossy().to_string(); - let status = std::process::Command::new("pkexec") - .arg(&exe_str) - .status(); + let status = std::process::Command::new("pkexec").arg(&exe_str).status(); match status { Ok(s) if s.success() => { app.exit(0); Ok(IpcResponse::success(())) } - Ok(s) => Err(format!("Elevation cancelled or failed (exit code: {:?})", s.code())), + Ok(s) => Err(format!( + "Elevation cancelled or failed (exit code: {:?})", + s.code() + )), Err(e) => Err(format!("Failed to request elevation: {}", e)), } } @@ -480,7 +519,10 @@ pub async fn register_url_protocol( _state: tauri::State<'_, Arc>>, ) -> Result, String> { let protocol = "secscore"; - eprintln!("[URL Protocol] Starting registration for protocol: {}", protocol); + eprintln!( + "[URL Protocol] Starting registration for protocol: {}", + protocol + ); #[cfg(target_os = "windows")] { @@ -525,7 +567,12 @@ try {{ let stdout = String::from_utf8_lossy(&o.stdout); let stderr = String::from_utf8_lossy(&o.stderr); let success = o.status.success() && stdout.contains("SUCCESS"); - eprintln!("[URL Protocol] Windows PowerShell result: success={}, stdout={}, stderr={}", success, stdout.trim(), stderr.trim()); + eprintln!( + "[URL Protocol] Windows PowerShell result: success={}, stdout={}, stderr={}", + success, + stdout.trim(), + stderr.trim() + ); if success { let query_cmd = format!( r#"reg query "HKCU\Software\Classes\{}\shell\open\command" /ve"#, @@ -540,7 +587,10 @@ try {{ } } - eprintln!("[URL Protocol] Windows registration verification: {}", registered); + eprintln!( + "[URL Protocol] Windows registration verification: {}", + registered + ); let _ = app; return Ok(IpcResponse::success(RegisterUrlProtocolResult { @@ -560,7 +610,10 @@ try {{ let mut registered = false; if let Some(bundle_path) = app_bundle { - eprintln!("[URL Protocol] macOS: Found app bundle at {:?}", bundle_path); + eprintln!( + "[URL Protocol] macOS: Found app bundle at {:?}", + bundle_path + ); let output = Command::new( "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ) @@ -573,7 +626,10 @@ try {{ registered = true; } Ok(o) => { - eprintln!("[URL Protocol] macOS: lsregister -f failed (exit: {:?}), trying -R -f", o.status.code()); + eprintln!( + "[URL Protocol] macOS: lsregister -f failed (exit: {:?}), trying -R -f", + o.status.code() + ); let _ = Command::new("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister") .args(["-R", "-f", &bundle_path.to_string_lossy()]) .output(); @@ -660,7 +716,10 @@ MimeType=x-scheme-handler/secscore; match fs::write(&desktop_path, &desktop_content) { Ok(_) => { - eprintln!("[URL Protocol] Linux: Desktop file written to {:?}", desktop_path); + eprintln!( + "[URL Protocol] Linux: Desktop file written to {:?}", + desktop_path + ); } Err(e) => { eprintln!("[URL Protocol] Linux: Desktop file write error: {}", e); @@ -674,7 +733,10 @@ MimeType=x-scheme-handler/secscore; let update_result = std::process::Command::new("update-desktop-database") .arg(apps_dir.to_string_lossy().to_string()) .status(); - eprintln!("[URL Protocol] Linux: update-desktop-database result: {:?}", update_result); + eprintln!( + "[URL Protocol] Linux: update-desktop-database result: {:?}", + update_result + ); let xdg_set_result = std::process::Command::new("xdg-settings") .args([ @@ -684,22 +746,28 @@ MimeType=x-scheme-handler/secscore; "secscore.desktop", ]) .status(); - eprintln!("[URL Protocol] Linux: xdg-settings set result: {:?}", xdg_set_result); + eprintln!( + "[URL Protocol] Linux: xdg-settings set result: {:?}", + xdg_set_result + ); let xdg_mime_result = std::process::Command::new("xdg-mime") - .args([ - "default", - "secscore.desktop", - "x-scheme-handler/secscore", - ]) + .args(["default", "secscore.desktop", "x-scheme-handler/secscore"]) .status(); - eprintln!("[URL Protocol] Linux: xdg-mime default result: {:?}", xdg_mime_result); + eprintln!( + "[URL Protocol] Linux: xdg-mime default result: {:?}", + xdg_mime_result + ); let verify_exists = desktop_path.exists(); let verify_content = fs::read_to_string(&desktop_path).unwrap_or_default(); - let actually_registered = verify_exists && verify_content.contains("x-scheme-handler/secscore"); + let actually_registered = + verify_exists && verify_content.contains("x-scheme-handler/secscore"); - eprintln!("[URL Protocol] Linux registration verification: {}", actually_registered); + eprintln!( + "[URL Protocol] Linux registration verification: {}", + actually_registered + ); let _ = app; return Ok(IpcResponse::success(RegisterUrlProtocolResult { diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 3d6103f..5811021 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -417,10 +417,7 @@ fn generate_code_challenge(verifier: &str) -> String { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); let result = hasher.finalize(); - base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - &result, - ) + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &result) } #[tauri::command] @@ -472,7 +469,9 @@ pub async fn oauth_exchange_code( ); let device_uuid = get_or_create_device_uuid(); - let ip_address = get_local_ip().await.unwrap_or_else(|_| "127.0.0.1".to_string()); + let ip_address = get_local_ip() + .await + .unwrap_or_else(|_| "127.0.0.1".to_string()); let state_guard = state.read(); let client = &state_guard.http_client; @@ -522,7 +521,10 @@ pub async fn oauth_exchange_code( } } - println!("[OAuth] Token 处理完成,access_token 长度: {}", token_response.access_token.len()); + println!( + "[OAuth] Token 处理完成,access_token 长度: {}", + token_response.access_token.len() + ); Ok(IpcResponse::success(token_response)) } @@ -761,7 +763,9 @@ pub async fn oauth_report_online( state: State<'_, Arc>>, ) -> Result, String> { let device_uuid = get_or_create_device_uuid(); - let ip_address = get_local_ip().await.unwrap_or_else(|_| "127.0.0.1".to_string()); + let ip_address = get_local_ip() + .await + .unwrap_or_else(|_| "127.0.0.1".to_string()); println!( "[OAuth] 上报在线状态 - platform_id: {}, device_uuid: {}, device_type: {}", @@ -844,11 +848,10 @@ pub async fn oauth_save_login_state(state_data: OAuthState) -> Result Result>, return Ok(IpcResponse::success(None)); } - let json = std::fs::read_to_string(&file_path) - .map_err(|e| format!("读取文件失败: {}", e))?; + let json = std::fs::read_to_string(&file_path).map_err(|e| format!("读取文件失败: {}", e))?; - let state: OAuthState = serde_json::from_str(&json) - .map_err(|e| format!("解析 JSON 失败: {}", e))?; + let state: OAuthState = + serde_json::from_str(&json).map_err(|e| format!("解析 JSON 失败: {}", e))?; println!("[OAuth] 登录状态已加载 - user_id: {}", state.user_id); Ok(IpcResponse::success(Some(state))) @@ -878,8 +880,7 @@ pub async fn oauth_clear_login_state() -> Result, String> { let file_path = get_oauth_state_file_path(); if file_path.exists() { - std::fs::remove_file(&file_path) - .map_err(|e| format!("删除文件失败: {}", e))?; + std::fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?; println!("[OAuth] 登录状态已清除"); } @@ -895,7 +896,9 @@ pub async fn oauth_refresh_access_token( println!("[OAuth] 刷新 access_token"); let device_uuid = get_or_create_device_uuid(); - let ip_address = get_local_ip().await.unwrap_or_else(|_| "127.0.0.1".to_string()); + let ip_address = get_local_ip() + .await + .unwrap_or_else(|_| "127.0.0.1".to_string()); let state_guard = state.read(); let client = &state_guard.http_client; @@ -928,8 +931,8 @@ pub async fn oauth_refresh_access_token( return Ok(IpcResponse::error(&response_text)); } - let mut token_response: OAuthTokenResponse = serde_json::from_str(&response_text) - .map_err(|e| format!("解析响应失败: {}", e))?; + let mut token_response: OAuthTokenResponse = + serde_json::from_str(&response_text).map_err(|e| format!("解析响应失败: {}", e))?; // 处理 access_token 格式: JWT|refresh_token if token_response.access_token.contains('|') { @@ -941,4 +944,3 @@ pub async fn oauth_refresh_access_token( Ok(IpcResponse::success(token_response)) } - diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index d39c1e3..3e836de 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -19,7 +19,9 @@ fn check_admin_permission(state: &Arc>, sender_id: Option) } #[tauri::command] -pub fn plugin_get_all(state: State<'_, Arc>>) -> Result>, String> { +pub fn plugin_get_all( + state: State<'_, Arc>>, +) -> Result>, String> { let state_guard = state.read(); let plugins = state_guard.plugins.read(); let all_plugins = (*plugins).get_all_plugins().to_vec(); @@ -41,7 +43,9 @@ pub fn plugin_get( } #[tauri::command] -pub fn plugin_get_stats(state: State<'_, Arc>>) -> Result, String> { +pub fn plugin_get_stats( + state: State<'_, Arc>>, +) -> Result, String> { let state_guard = state.read(); let plugins = state_guard.plugins.read(); let stats = plugins.get_plugin_stats(); @@ -147,10 +151,16 @@ impl From for PluginListItem { } #[tauri::command] -pub fn plugin_get_list(state: State<'_, Arc>>) -> Result>, String> { +pub fn plugin_get_list( + state: State<'_, Arc>>, +) -> Result>, String> { let state_guard = state.read(); let plugins = state_guard.plugins.read(); - let list: Vec = (*plugins).get_all_plugins().iter().map(|p| (*p).clone().into()).collect(); + let list: Vec = (*plugins) + .get_all_plugins() + .iter() + .map(|p| (*p).clone().into()) + .collect(); Ok(IpcResponse::success(list)) } diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 0b29a1b..814fd80 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -1,23 +1,23 @@ use parking_lot::RwLock; use std::sync::Arc; -use tauri::AppHandle; +use tauri::{AppHandle, WebviewWindow}; #[cfg(desktop)] -use tauri::Manager; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; use crate::state::AppState; #[tauri::command] pub async fn window_minimize( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = app; + let _ = window; } #[cfg(desktop)] - if let Some(window) = app.get_webview_window("main") { + { window.minimize().map_err(|e| e.to_string())?; } Ok(()) @@ -25,83 +25,79 @@ pub async fn window_minimize( #[tauri::command] pub async fn window_maximize( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result { #[cfg(not(desktop))] { - let _ = app; + let _ = window; return Ok(false); } #[cfg(desktop)] { - if let Some(window) = app.get_webview_window("main") { - let is_maximized = window.is_maximized().map_err(|e| e.to_string())?; + let is_maximized = window.is_maximized().map_err(|e| e.to_string())?; - if is_maximized { - window.unmaximize().map_err(|e| e.to_string())?; - Ok(false) - } else { - window.maximize().map_err(|e| e.to_string())?; - Ok(true) - } + if is_maximized { + window.unmaximize().map_err(|e| e.to_string())?; + Ok(false) } else { - Err("Window not found".to_string()) + window.maximize().map_err(|e| e.to_string())?; + Ok(true) } } } #[tauri::command] pub async fn window_close( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = app; + let _ = window; } #[cfg(desktop)] - if let Some(window) = app.get_webview_window("main") { - window.hide().map_err(|e| e.to_string())?; + { + if window.label() == "main" { + window.hide().map_err(|e| e.to_string())?; + } else { + window.close().map_err(|e| e.to_string())?; + } } Ok(()) } #[tauri::command] pub async fn window_is_maximized( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result { #[cfg(not(desktop))] { - let _ = app; + let _ = window; return Ok(false); } #[cfg(desktop)] { - if let Some(window) = app.get_webview_window("main") { - window.is_maximized().map_err(|e| e.to_string()) - } else { - Ok(false) - } + window.is_maximized().map_err(|e| e.to_string()) } } #[tauri::command] pub async fn toggle_devtools( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = app; + let _ = window; } #[cfg(all(debug_assertions, desktop))] - if let Some(window) = app.get_webview_window("main") { + { if window.is_devtools_open() { window.close_devtools(); } else { @@ -115,16 +111,16 @@ pub async fn toggle_devtools( pub async fn window_resize( width: u32, height: u32, - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = (width, height, app); + let _ = (width, height, window); } #[cfg(desktop)] - if let Some(window) = app.get_webview_window("main") { + { window .set_size(tauri::Size::Logical(tauri::LogicalSize { width: width as f64, @@ -138,16 +134,16 @@ pub async fn window_resize( #[tauri::command] pub async fn window_set_resizable( resizable: bool, - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = (resizable, app); + let _ = (resizable, window); } #[cfg(desktop)] - if let Some(window) = app.get_webview_window("main") { + { window.set_resizable(resizable).map_err(|e| e.to_string())?; } Ok(()) @@ -155,17 +151,68 @@ pub async fn window_set_resizable( #[tauri::command] pub async fn window_start_dragging( - app: AppHandle, + window: WebviewWindow, _state: tauri::State<'_, Arc>>, ) -> Result<(), String> { #[cfg(not(desktop))] { - let _ = app; + let _ = window; } #[cfg(desktop)] - if let Some(window) = app.get_webview_window("main") { + { window.start_dragging().map_err(|e| e.to_string())?; } Ok(()) } + +pub fn show_management_window(app: &AppHandle) -> Result<(), String> { + #[cfg(not(desktop))] + { + let _ = app; + return Ok(()); + } + + #[cfg(desktop)] + { + if let Some(window) = app.get_webview_window("management") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + let window = WebviewWindowBuilder::new( + app, + "management", + WebviewUrl::App("index.html?window=management#/students".into()), + ) + .title("SecScore 管理") + .inner_size(1180.0, 680.0) + .min_inner_size(360.0, 640.0) + .resizable(true) + .decorations(true) + .transparent(true) + .hidden_title(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .traffic_light_position(tauri::LogicalPosition::new(16.0, 22.0)) + .center() + .build() + .map_err(|e| e.to_string())?; + + #[cfg(target_os = "macos")] + { + let _ = window.set_shadow(true); + } + + window.set_focus().map_err(|e| e.to_string())?; + Ok(()) + } +} + +#[tauri::command] +pub async fn window_open_management( + app: AppHandle, + _state: tauri::State<'_, Arc>>, +) -> Result<(), String> { + show_management_window(&app) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ba210d6..4cf36be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -128,6 +128,7 @@ pub fn run() { window_maximize, window_close, window_is_maximized, + window_open_management, toggle_devtools, window_resize, window_set_resizable, @@ -377,10 +378,11 @@ fn setup_database(app: &mut App) -> Result<(), Box> { #[cfg(desktop)] fn setup_tray(app: &mut App) -> Result<(), Box> { let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; + let management_item = MenuItem::with_id(app, "management", "打开管理页", 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 menu = Menu::with_items(app, &[&show_item, &management_item, &hide_item, &quit_item])?; let _tray = TrayIconBuilder::new() .icon(Image::from_bytes(include_bytes!("../icons/icon.png"))?) @@ -398,6 +400,9 @@ fn setup_tray(app: &mut App) -> Result<(), Box> { let _ = window.hide(); } } + "management" => { + let _ = show_management_window(app); + } "quit" => { app.exit(0); } @@ -457,4 +462,3 @@ fn setup_window_events(app: &mut App) -> Result<(), Box> fn setup_window_events(_app: &mut App) -> Result<(), Box> { Ok(()) } - diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 6c6b51c..4a63347 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -986,7 +986,9 @@ fn normalize_optional_string(value: Option) -> Option { }) } -fn parse_reward_exchange_action_value(raw_value: Option<&str>) -> Option { +fn parse_reward_exchange_action_value( + raw_value: Option<&str>, +) -> Option { let raw_value = raw_value.map(str::trim)?; if raw_value.is_empty() { return None; diff --git a/src-tauri/src/services/plugin.rs b/src-tauri/src/services/plugin.rs index 3cec7dc..616feda 100644 --- a/src-tauri/src/services/plugin.rs +++ b/src-tauri/src/services/plugin.rs @@ -118,7 +118,8 @@ impl PluginService { let plugins_dir = Self::get_plugins_dir(app_handle); if !plugins_dir.exists() { - fs::create_dir_all(&plugins_dir).map_err(|e| format!("Failed to create plugins directory: {}", e))?; + fs::create_dir_all(&plugins_dir) + .map_err(|e| format!("Failed to create plugins directory: {}", e))?; } self.plugins.clear(); @@ -132,7 +133,9 @@ impl PluginService { return Ok(()); } - for entry in fs::read_dir(dir).map_err(|e| format!("Failed to read plugins directory: {}", e))? { + for entry in + fs::read_dir(dir).map_err(|e| format!("Failed to read plugins directory: {}", e))? + { let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let path = entry.path(); @@ -149,7 +152,11 @@ impl PluginService { ); continue; } - if self.plugins.iter().any(|existing| existing.id == manifest.id) { + if self + .plugins + .iter() + .any(|existing| existing.id == manifest.id) + { eprintln!( "Skip duplicated plugin id {} at {}", manifest.id, @@ -190,7 +197,9 @@ impl PluginService { .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-') { - return Err("Plugin id can only contain letters, numbers, dot, underscore, hyphen".to_string()); + return Err( + "Plugin id can only contain letters, numbers, dot, underscore, hyphen".to_string(), + ); } if name.is_empty() { return Err("Plugin name cannot be empty".to_string()); @@ -257,14 +266,17 @@ impl PluginService { fn persist_manifest_enabled(manifest_path: &Path, enabled: bool) -> Result<(), String> { let mut manifest = Self::load_plugin_manifest_from_file(manifest_path)?; manifest.enabled = enabled; - let serialized = - serde_json::to_string_pretty(&manifest).map_err(|e| format!("Failed to serialize manifest.json: {}", e))?; + let serialized = serde_json::to_string_pretty(&manifest) + .map_err(|e| format!("Failed to serialize manifest.json: {}", e))?; fs::write(manifest_path, format!("{}\n", serialized)) .map_err(|e| format!("Failed to write manifest.json: {}", e))?; Ok(()) } - fn resolve_plugin_relative_path(plugin_dir: &Path, relative_path: &str) -> Result { + fn resolve_plugin_relative_path( + plugin_dir: &Path, + relative_path: &str, + ) -> Result { let relative_path = relative_path.trim(); if relative_path.is_empty() { return Err("Plugin entry file path cannot be empty".to_string()); @@ -309,7 +321,11 @@ impl PluginService { plugin_dir: PathBuf, ) -> Result { Self::validate_manifest(&manifest)?; - if self.plugins.iter().any(|existing| existing.id == manifest.id) { + if self + .plugins + .iter() + .any(|existing| existing.id == manifest.id) + { return Err("Plugin already installed".to_string()); } if !plugin_dir.exists() || !plugin_dir.is_dir() { @@ -322,7 +338,9 @@ impl PluginService { let source_manifest = Self::load_plugin_manifest(&plugin_dir)?; Self::validate_manifest(&source_manifest)?; if source_manifest.id != manifest.id { - return Err("Manifest mismatch: plugin id in folder does not match selected plugin".to_string()); + return Err( + "Manifest mismatch: plugin id in folder does not match selected plugin".to_string(), + ); } let plugins_dir = Self::get_plugins_dir(app_handle); diff --git a/src/App.tsx b/src/App.tsx index 1de65f9..0ce38d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM 0, 4 ) +const MANAGEMENT_DEFAULT_ROUTE = "/students" const applyGlobalFontFamily = (fontValue?: string) => { const fontFamily = resolveStoredFontFamily(fontValue) document.documentElement.style.setProperty("--ss-font-family", fontFamily) @@ -61,7 +62,11 @@ function MainContent(): React.JSX.Element { typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent) && !/iphone|ipad|ipod|android/i.test(navigator.userAgent) - const [immersiveMode, setImmersiveMode] = useState(false) + const isManagementWindow = useMemo(() => { + const params = new URLSearchParams(window.location.search) + return params.get("window") === "management" + }, []) + const [immersiveMode] = useState(!isManagementWindow) const normalizeStoredBottomKeys = (raw: unknown): MobileNavKey[] => { if (Array.isArray(raw)) { @@ -82,11 +87,16 @@ function MainContent(): React.JSX.Element { const currentPath = location.pathname === "/" ? "/home" : location.pathname const targetPath = route === "/" ? "/home" : route - if (immersiveMode && !targetPath.startsWith("/home")) { + if (!isManagementWindow && !targetPath.startsWith("/home")) { navigate("/", { replace: true }) return } + if (isManagementWindow && targetPath.startsWith("/home")) { + navigate(MANAGEMENT_DEFAULT_ROUTE, { replace: true }) + return + } + if (currentPath !== targetPath) { navigate(route) } @@ -104,7 +114,7 @@ function MainContent(): React.JSX.Element { disposed = true if (unlisten) unlisten() } - }, [navigate, location.pathname, immersiveMode]) + }, [navigate, location.pathname, isManagementWindow]) const [wizardVisible, setWizardVisible] = useState(false) const [permission, setPermission] = useState<"admin" | "points" | "view">("view") @@ -154,7 +164,7 @@ function MainContent(): React.JSX.Element { const activeMenu = useMemo(() => { const p = location.pathname - if (p === "/" || p.startsWith("/home")) return "home" + if (p === "/" || p.startsWith("/home")) return isManagementWindow ? "students" : "home" if (p.startsWith("/students")) return "students" if (p.startsWith("/score")) return "score" if (p.startsWith("/boards")) return "boards" @@ -165,8 +175,8 @@ function MainContent(): React.JSX.Element { if (p.startsWith("/reward-settings")) return "reward-settings" if (p.startsWith("/plugins")) return "plugins" if (p.startsWith("/settings")) return "settings" - return "home" - }, [location.pathname]) + return isManagementWindow ? "students" : "home" + }, [location.pathname, isManagementWindow]) useEffect(() => { const runtime = pluginRuntimeRef.current @@ -602,7 +612,8 @@ function MainContent(): React.JSX.Element { const key = String(v) setMoreNavVisible(false) setEditingNav(false) - if (immersiveMode && key !== "home") return + if (!isManagementWindow && key !== "home") return + if (isManagementWindow && key === "home") return if (key === "home") navigate("/") if (key === "students") navigate("/students") if (key === "score") navigate("/score") @@ -626,29 +637,32 @@ function MainContent(): React.JSX.Element { } useEffect(() => { - if (!immersiveMode) return - if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { + if ( + !isManagementWindow && + location.pathname !== "/" && + !location.pathname.startsWith("/home") + ) { navigate("/", { replace: true }) + return } - }, [immersiveMode, location.pathname, navigate]) + if ( + isManagementWindow && + (location.pathname === "/" || location.pathname.startsWith("/home")) + ) { + navigate(MANAGEMENT_DEFAULT_ROUTE, { replace: true }) + } + }, [isManagementWindow, location.pathname, navigate]) - const toggleImmersiveMode = () => { - setImmersiveMode((prev) => { - const next = !prev - if (next) { - setFloatingSidebarExpanded(false) - if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { - navigate("/", { replace: true }) - } - } - return next - }) - } + const openManagementWindow = useCallback(async () => { + const api = (window as any).api + if (!api?.openManagementWindow) return + await api.openManagementWindow() + }, []) const isDark = currentTheme?.mode === "dark" const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9" const isMobileDevice = isIosDevice || isAndroidDevice - const showMobileBottomNav = isPortraitMode && !immersiveMode + const showMobileBottomNav = isPortraitMode && isManagementWindow const mobileBottomNavIconMap: Record = { home: , students: , @@ -664,8 +678,13 @@ function MainContent(): React.JSX.Element { } const mobileNavAvailableItems = useMemo( - () => MOBILE_NAV_ITEMS.filter((item) => !(item.adminOnly && permission !== "admin")), - [permission] + () => + MOBILE_NAV_ITEMS.filter( + (item) => + (isManagementWindow ? item.key !== "home" : item.key === "home") && + !(item.adminOnly && permission !== "admin") + ), + [isManagementWindow, permission] ) const mobileBottomSelectedKeys = useMemo(() => { const availableKeySet = new Set(mobileNavAvailableItems.map((item) => item.key)) @@ -768,7 +787,7 @@ function MainContent(): React.JSX.Element { - {!isPortraitMode && ( + {!isPortraitMode && isManagementWindow && (
)} @@ -804,8 +824,10 @@ function MainContent(): React.JSX.Element { onToggleSidebar={toggleSidebar} immersiveMode={immersiveMode} isHomePage={activeMenu === "home"} - onToggleImmersiveMode={toggleImmersiveMode} - showSidebarToggle={!isPortraitMode} + onOpenManagementWindow={openManagementWindow} + showSidebarToggle={!isPortraitMode && isManagementWindow} + showHomeRoute={!isManagementWindow} + fallbackRoute={isManagementWindow ? MANAGEMENT_DEFAULT_ROUTE : "/"} bottomInset={showMobileBottomNav ? 84 : 0} /> {showMobileBottomNav && ( diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index d5353ac..46be642 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -3,9 +3,8 @@ import { Layout, Space, Button, Tag, Spin, Avatar, Popover, Progress } from "ant import { MenuFoldOutlined, MenuUnfoldOutlined, - FullscreenOutlined, - FullscreenExitOutlined, LeftOutlined, + SettingOutlined, } from "@ant-design/icons" import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom" import { useTranslation } from "react-i18next" @@ -71,8 +70,10 @@ interface ContentAreaProps { onToggleSidebar: () => void immersiveMode: boolean isHomePage: boolean - onToggleImmersiveMode: () => void + onOpenManagementWindow?: () => Promise | void showSidebarToggle?: boolean + showHomeRoute?: boolean + fallbackRoute?: string bottomInset?: number } @@ -99,8 +100,10 @@ export function ContentArea({ onToggleSidebar, immersiveMode, isHomePage, - onToggleImmersiveMode, + onOpenManagementWindow, showSidebarToggle = true, + showHomeRoute = true, + fallbackRoute = "/", bottomInset = 0, }: ContentAreaProps): React.JSX.Element { const { t } = useTranslation() @@ -118,7 +121,7 @@ export function ContentArea({ const isBoardPage = normalizedPath.startsWith("/boards") const isMobileHeaderMode = isPortraitMode && isMobileDevice && !immersiveMode const isPrimaryMobilePage = - normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings") + (showHomeRoute && normalizedPath.startsWith("/home")) || normalizedPath.startsWith("/settings") const showMobileBack = isMobileHeaderMode && !isPrimaryMobilePage const mobilePageTitle = (() => { if (normalizedPath.startsWith("/home")) return t("sidebar.home") @@ -549,10 +552,9 @@ export function ContentArea({ {(immersiveMode || (isHomePage && !isMobileDevice)) && (