mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat: 新增独立管理窗口
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"]}}
|
||||
{"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"]}}
|
||||
+103
-35
@@ -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<u16> = OsStr::new("runas\0").encode_wide().collect();
|
||||
let file: Vec<u16> = OsStr::new(&format!("{}\0", exe_str)).encode_wide().collect();
|
||||
let file: Vec<u16> = OsStr::new(&format!("{}\0", exe_str))
|
||||
.encode_wide()
|
||||
.collect();
|
||||
let params: Vec<u16> = 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<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<RegisterUrlProtocolResult>, 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 {
|
||||
|
||||
@@ -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<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OnlineStatusResponse>, 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<IpcRespons
|
||||
println!("[OAuth] 保存登录状态 - user_id: {}", state_data.user_id);
|
||||
|
||||
let file_path = get_oauth_state_file_path();
|
||||
let json = serde_json::to_string_pretty(&state_data)
|
||||
.map_err(|e| format!("序列化失败: {}", e))?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(&state_data).map_err(|e| format!("序列化失败: {}", e))?;
|
||||
|
||||
std::fs::write(&file_path, json)
|
||||
.map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
std::fs::write(&file_path, json).map_err(|e| format!("写入文件失败: {}", e))?;
|
||||
|
||||
println!("[OAuth] 登录状态已保存到: {:?}", file_path);
|
||||
Ok(IpcResponse::success(()))
|
||||
@@ -863,11 +866,10 @@ pub async fn oauth_load_login_state() -> Result<IpcResponse<Option<OAuthState>>,
|
||||
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<IpcResponse<()>, 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ fn check_admin_permission(state: &Arc<RwLock<AppState>>, sender_id: Option<u32>)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn plugin_get_all(state: State<'_, Arc<RwLock<AppState>>>) -> Result<IpcResponse<Vec<Plugin>>, String> {
|
||||
pub fn plugin_get_all(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<Plugin>>, 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<RwLock<AppState>>>) -> Result<IpcResponse<PluginStats>, String> {
|
||||
pub fn plugin_get_stats(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<PluginStats>, String> {
|
||||
let state_guard = state.read();
|
||||
let plugins = state_guard.plugins.read();
|
||||
let stats = plugins.get_plugin_stats();
|
||||
@@ -147,10 +151,16 @@ impl From<Plugin> for PluginListItem {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn plugin_get_list(state: State<'_, Arc<RwLock<AppState>>>) -> Result<IpcResponse<Vec<PluginListItem>>, String> {
|
||||
pub fn plugin_get_list(
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<Vec<PluginListItem>>, String> {
|
||||
let state_guard = state.read();
|
||||
let plugins = state_guard.plugins.read();
|
||||
let list: Vec<PluginListItem> = (*plugins).get_all_plugins().iter().map(|p| (*p).clone().into()).collect();
|
||||
let list: Vec<PluginListItem> = (*plugins)
|
||||
.get_all_plugins()
|
||||
.iter()
|
||||
.map(|p| (*p).clone().into())
|
||||
.collect();
|
||||
Ok(IpcResponse::success(list))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> Result<bool, String> {
|
||||
#[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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> Result<bool, String> {
|
||||
#[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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> 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<RwLock<AppState>>>,
|
||||
) -> Result<(), String> {
|
||||
show_management_window(&app)
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
#[cfg(desktop)]
|
||||
fn setup_tray(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>>
|
||||
fn setup_window_events(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -986,7 +986,9 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_reward_exchange_action_value(raw_value: Option<&str>) -> Option<RewardExchangeActionValue> {
|
||||
fn parse_reward_exchange_action_value(
|
||||
raw_value: Option<&str>,
|
||||
) -> Option<RewardExchangeActionValue> {
|
||||
let raw_value = raw_value.map(str::trim)?;
|
||||
if raw_value.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
fn resolve_plugin_relative_path(
|
||||
plugin_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
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<Plugin, String> {
|
||||
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);
|
||||
|
||||
+50
-28
@@ -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<MobileNavKey, React.ReactNode> = {
|
||||
home: <HomeOutlined style={{ fontSize: "18px" }} />,
|
||||
students: <UserOutlined style={{ fontSize: "18px" }} />,
|
||||
@@ -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 {
|
||||
<Layout
|
||||
style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
{!isPortraitMode && (
|
||||
{!isPortraitMode && isManagementWindow && (
|
||||
<div
|
||||
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
|
||||
style={
|
||||
@@ -785,6 +804,7 @@ function MainContent(): React.JSX.Element {
|
||||
floatingExpand={isPortraitMode}
|
||||
floatingExpanded={floatingSidebarExpanded}
|
||||
onFloatingExpandedChange={setFloatingSidebarExpanded}
|
||||
showHome={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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> | 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)) && (
|
||||
<Button
|
||||
size="small"
|
||||
type={immersiveMode ? "primary" : "default"}
|
||||
icon={immersiveMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={onToggleImmersiveMode}
|
||||
title={immersiveMode ? "退出沉浸模式" : "进入沉浸模式"}
|
||||
icon={<SettingOutlined />}
|
||||
onClick={onOpenManagementWindow}
|
||||
title="管理"
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
@@ -651,16 +653,18 @@ export function ContentArea({
|
||||
className={`ss-route-page${shouldAnimateSubPage ? " is-subpage-enter" : ""}${isBoardPage ? " is-board-page" : ""}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
canEdit={permission === "admin" || permission === "points"}
|
||||
isPortraitMode={isPortraitMode}
|
||||
immersiveMode={immersiveMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{showHomeRoute && (
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
canEdit={permission === "admin" || permission === "points"}
|
||||
isPortraitMode={isPortraitMode}
|
||||
immersiveMode={immersiveMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path="/students"
|
||||
element={<StudentManager canEdit={permission === "admin"} />}
|
||||
@@ -685,7 +689,7 @@ export function ContentArea({
|
||||
/>
|
||||
<Route path="/plugins" element={<PluginManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to={fallbackRoute} replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface SidebarProps {
|
||||
floatingExpand: boolean
|
||||
floatingExpanded: boolean
|
||||
onFloatingExpandedChange: (expanded: boolean) => void
|
||||
showHome?: boolean
|
||||
}
|
||||
|
||||
interface DbStatus {
|
||||
@@ -43,6 +44,7 @@ export function Sidebar({
|
||||
floatingExpand,
|
||||
floatingExpanded,
|
||||
onFloatingExpandedChange,
|
||||
showHome = true,
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: "sqlite", connected: true })
|
||||
@@ -209,7 +211,7 @@ export function Sidebar({
|
||||
label: t("sidebar.settings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
]
|
||||
].filter((item) => showHome || item.key !== "home")
|
||||
|
||||
const showFloatingPanel = floatingExpand && collapsed && floatingExpanded
|
||||
|
||||
|
||||
@@ -638,6 +638,7 @@ const api = {
|
||||
windowMaximize: (): Promise<boolean> => invoke("window_maximize"),
|
||||
windowClose: (): Promise<void> => invoke("window_close"),
|
||||
windowIsMaximized: (): Promise<boolean> => invoke("window_is_maximized"),
|
||||
openManagementWindow: (): Promise<void> => invoke("window_open_management"),
|
||||
startDraggingWindow: (): Promise<void> => invoke("window_start_dragging"),
|
||||
toggleDevTools: (): Promise<void> => invoke("toggle_devtools"),
|
||||
windowResize: (width: number, height: number): Promise<void> =>
|
||||
|
||||
Reference in New Issue
Block a user