mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
feat: 自动化加分基本修复完毕 现在是全新的自动化qwqqq!
This commit is contained in:
Binary file not shown.
@@ -335,7 +335,10 @@ pub async fn oauth_get_authorization_url(
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
|
||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes)
|
||||
base64::Engine::encode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
&random_bytes,
|
||||
)
|
||||
});
|
||||
|
||||
let url = format!(
|
||||
@@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url(
|
||||
urlencoding::encode(&state)
|
||||
);
|
||||
|
||||
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state }))
|
||||
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
|
||||
url,
|
||||
state,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -355,8 +361,11 @@ pub async fn oauth_exchange_code(
|
||||
platform_secret: String,
|
||||
callback_url: String,
|
||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||
println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url);
|
||||
|
||||
println!(
|
||||
"[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}",
|
||||
code, platform_id, callback_url
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let params = [
|
||||
("grant_type", "authorization_code"),
|
||||
@@ -365,9 +374,9 @@ pub async fn oauth_exchange_code(
|
||||
("client_secret", &platform_secret),
|
||||
("redirect_uri", &callback_url),
|
||||
];
|
||||
|
||||
|
||||
println!("[OAuth] 请求参数:{:?}", params);
|
||||
|
||||
|
||||
let response = client
|
||||
.post("https://sectl.top/api/oauth/token")
|
||||
.form(¶ms)
|
||||
@@ -376,8 +385,11 @@ pub async fn oauth_exchange_code(
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
println!("[OAuth] 响应状态:{}", response.status());
|
||||
|
||||
let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
println!("[OAuth] 响应内容:{}", response_text);
|
||||
|
||||
if !response_text.is_empty() && response_text.contains("error") {
|
||||
|
||||
@@ -3,6 +3,7 @@ use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -284,3 +285,55 @@ pub async fn fs_file_exists(
|
||||
|
||||
Ok(IpcResponse::success(file_path.exists()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fs_open_path(
|
||||
relative_path: String,
|
||||
folder: String,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let folder_type = ConfigFolder::from_str(&folder)
|
||||
.ok_or_else(|| format!("Invalid folder type: {}", folder))?;
|
||||
|
||||
let folder_path = ensure_folder_exists(&folder_type)?;
|
||||
let file_path = folder_path.join(&relative_path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Ok(IpcResponse::error("File not found"));
|
||||
}
|
||||
|
||||
open_path_with_system(&file_path)?;
|
||||
Ok(IpcResponse::success_empty())
|
||||
}
|
||||
|
||||
fn open_path_with_system(path: &PathBuf) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "", path.to_string_lossy().as_ref()])
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
|
||||
@@ -1,342 +1,345 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthServerStartResult {
|
||||
pub url: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthCallbackResult {
|
||||
pub code: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_start_callback_server(
|
||||
app_handle: tauri::AppHandle,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
|
||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||
|
||||
// 如果服务器已经在运行,直接返回 URL
|
||||
if shutdown_tx.is_some() {
|
||||
let port = 16888u16;
|
||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
||||
}
|
||||
|
||||
let port = 16888u16;
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||
let url_for_spawn = url.clone();
|
||||
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
*shutdown_tx = Some(tx);
|
||||
drop(shutdown_tx);
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let app = axum::Router::new()
|
||||
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
||||
.layer(axum::extract::Extension(app_handle_clone));
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to bind OAuth callback server: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("OAuth callback server started at {}", url_for_spawn);
|
||||
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
tokio::select! {
|
||||
_ = server => {},
|
||||
_ = rx => {
|
||||
println!("OAuth callback server shutting down");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_stop_callback_server(
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||
|
||||
if let Some(tx) = shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
|
||||
async fn handle_oauth_callback(
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let code = params.get("code").cloned();
|
||||
let state = params.get("state").cloned();
|
||||
let error = params.get("error").cloned();
|
||||
let error_description = params.get("error_description").cloned();
|
||||
|
||||
println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error);
|
||||
|
||||
let result = OAuthCallbackResult {
|
||||
code: code.clone(),
|
||||
state: state.clone(),
|
||||
error: error.clone(),
|
||||
error_description: error_description.clone(),
|
||||
};
|
||||
|
||||
match app_handle.emit("oauth-callback", result) {
|
||||
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
|
||||
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
|
||||
}
|
||||
|
||||
let html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OAuth 授权完成</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e4e4e4;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(74, 222, 128, 0.4);
|
||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>授权成功</h1>
|
||||
<p>请返回 SecScore 应用查看登录结果</p>
|
||||
<p class="hint">此窗口可以关闭</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let error_html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OAuth 授权失败</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e4e4e4;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.4);
|
||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>授权失败</h1>
|
||||
<p>请返回 SecScore 应用查看错误信息</p>
|
||||
<p class="hint">此窗口可以关闭</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let response_html = if error.is_some() { error_html } else { html };
|
||||
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
response_html,
|
||||
)
|
||||
}
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tauri::State;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::response::IpcResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthServerStartResult {
|
||||
pub url: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthCallbackResult {
|
||||
pub code: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_description: Option<String>,
|
||||
}
|
||||
|
||||
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_start_callback_server(
|
||||
app_handle: tauri::AppHandle,
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
|
||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||
|
||||
// 如果服务器已经在运行,直接返回 URL
|
||||
if shutdown_tx.is_some() {
|
||||
let port = 16888u16;
|
||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
||||
}
|
||||
|
||||
let port = 16888u16;
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||
let url_for_spawn = url.clone();
|
||||
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
*shutdown_tx = Some(tx);
|
||||
drop(shutdown_tx);
|
||||
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let app = axum::Router::new()
|
||||
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
||||
.layer(axum::extract::Extension(app_handle_clone));
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to bind OAuth callback server: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("OAuth callback server started at {}", url_for_spawn);
|
||||
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
tokio::select! {
|
||||
_ = server => {},
|
||||
_ = rx => {
|
||||
println!("OAuth callback server shutting down");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn oauth_stop_callback_server(
|
||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, String> {
|
||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||
|
||||
if let Some(tx) = shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
Ok(IpcResponse::success(()))
|
||||
}
|
||||
|
||||
async fn handle_oauth_callback(
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
let code = params.get("code").cloned();
|
||||
let state = params.get("state").cloned();
|
||||
let error = params.get("error").cloned();
|
||||
let error_description = params.get("error_description").cloned();
|
||||
|
||||
println!(
|
||||
"[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}",
|
||||
code, state, error
|
||||
);
|
||||
|
||||
let result = OAuthCallbackResult {
|
||||
code: code.clone(),
|
||||
state: state.clone(),
|
||||
error: error.clone(),
|
||||
error_description: error_description.clone(),
|
||||
};
|
||||
|
||||
match app_handle.emit("oauth-callback", result) {
|
||||
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
|
||||
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
|
||||
}
|
||||
|
||||
let html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OAuth 授权完成</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e4e4e4;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(74, 222, 128, 0.4);
|
||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>授权成功</h1>
|
||||
<p>请返回 SecScore 应用查看登录结果</p>
|
||||
<p class="hint">此窗口可以关闭</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let error_html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OAuth 授权失败</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #e4e4e4;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
animation: slideUp 0.5s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.4);
|
||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #a0a0a0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>授权失败</h1>
|
||||
<p>请返回 SecScore 应用查看错误信息</p>
|
||||
<p class="hint">此窗口可以关闭</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let response_html = if error.is_some() { error_html } else { html };
|
||||
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
response_html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn run() {
|
||||
fs_delete_file,
|
||||
fs_list_files,
|
||||
fs_file_exists,
|
||||
fs_open_path,
|
||||
http_server_start,
|
||||
http_server_stop,
|
||||
http_server_status,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use chrono::{DateTime, Months, Utc};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait,
|
||||
QueryFilter, Set, Statement, TransactionTrait,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::{json, Value as JsonValue};
|
||||
use std::collections::HashSet;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::time::{interval, Duration};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::entities::{score_events, student_tags, students, tags};
|
||||
use crate::services::settings::{SettingsKey, SettingsValue};
|
||||
use crate::state::SafeAppState;
|
||||
|
||||
const DEFAULT_INTERVAL_MINUTES: i64 = 30;
|
||||
const AUTO_SCORE_TICK_SECONDS: u64 = 15;
|
||||
const AUTO_SCORE_SQL_LIMIT: u64 = 5000;
|
||||
const AUTO_SCORE_REASON_PREFIX: &str = "自动化";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AutoScoreTrigger {
|
||||
@@ -45,6 +58,37 @@ pub struct AutoScoreRule {
|
||||
pub last_executed: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum PlannedAction {
|
||||
AddScore(i32),
|
||||
AddTags(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct StudentRefs {
|
||||
ids: HashSet<i32>,
|
||||
names: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct RuleExecutionStats {
|
||||
affected_students: usize,
|
||||
created_events: usize,
|
||||
added_tags: usize,
|
||||
}
|
||||
|
||||
impl StudentRefs {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.ids.is_empty() && self.names.is_empty()
|
||||
}
|
||||
|
||||
fn intersect(mut self, other: StudentRefs) -> StudentRefs {
|
||||
self.ids.retain(|id| other.ids.contains(id));
|
||||
self.names.retain(|name| other.names.contains(name));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoScoreRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -87,12 +131,14 @@ impl AutoScoreService {
|
||||
|
||||
pub async fn initialize(
|
||||
&mut self,
|
||||
_app_handle: &AppHandle,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.initialized = true;
|
||||
Self::spawn_scheduler(app_handle.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,38 +257,137 @@ impl AutoScoreService {
|
||||
}
|
||||
|
||||
pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option<i64> {
|
||||
let now = Utc::now();
|
||||
|
||||
for trigger in &rule.triggers {
|
||||
if trigger.event != "interval_time_passed" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or(
|
||||
IntervalTriggerValue {
|
||||
amount: DEFAULT_INTERVAL_MINUTES,
|
||||
unit: IntervalUnit::Minute,
|
||||
},
|
||||
);
|
||||
|
||||
let base_time = rule
|
||||
.last_executed
|
||||
.as_ref()
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or(now);
|
||||
|
||||
let next_execute_time = add_interval_to_time(base_time, &interval)?;
|
||||
let delay_ms = (next_execute_time - now).num_milliseconds();
|
||||
return Some(delay_ms.max(0));
|
||||
}
|
||||
|
||||
None
|
||||
check_interval_trigger(rule)
|
||||
}
|
||||
|
||||
pub async fn notify_rules_changed(&self, app_handle: &AppHandle) {
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules);
|
||||
}
|
||||
|
||||
fn spawn_scheduler(app_handle: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut ticker = interval(Duration::from_secs(AUTO_SCORE_TICK_SECONDS));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(error) = Self::run_scheduler_tick(&app_handle).await {
|
||||
eprintln!("auto score scheduler tick failed: {}", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let state = app_handle.state::<SafeAppState>().inner().clone();
|
||||
|
||||
let rules_snapshot = {
|
||||
let state_guard = state.read();
|
||||
let auto_score = state_guard.auto_score.read();
|
||||
auto_score.get_rules().to_vec()
|
||||
};
|
||||
|
||||
if !rules_snapshot.iter().any(|rule| rule.enabled) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conn = {
|
||||
let state_guard = state.read();
|
||||
let db_conn = state_guard.db.read().clone();
|
||||
db_conn
|
||||
}
|
||||
.ok_or_else(|| "Database not connected".to_string())?;
|
||||
|
||||
let mut next_rules = rules_snapshot.clone();
|
||||
let mut changed = false;
|
||||
|
||||
for rule in next_rules.iter_mut().filter(|rule| rule.enabled) {
|
||||
let Some(delay_ms) = check_interval_trigger(rule) else {
|
||||
continue;
|
||||
};
|
||||
if delay_ms > 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match execute_rule(&conn, rule).await {
|
||||
Ok(stats) => {
|
||||
if stats.affected_students == 0 {
|
||||
Self::log_rule_skipped(&state, rule, "no matched students");
|
||||
continue;
|
||||
}
|
||||
rule.last_executed = Some(Utc::now().to_rfc3339());
|
||||
changed = true;
|
||||
Self::log_rule_executed(&state, rule, stats);
|
||||
}
|
||||
Err(error) => {
|
||||
Self::log_rule_failed(&state, rule, &error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
persist_rules_to_settings(&state, &next_rules).await?;
|
||||
|
||||
{
|
||||
let state_guard = state.read();
|
||||
let mut auto_score = state_guard.auto_score.write();
|
||||
auto_score.replace_rules(next_rules.clone());
|
||||
}
|
||||
|
||||
let enabled = next_rules.iter().any(|rule| rule.enabled);
|
||||
let _ = app_handle.emit("auto-score:rulesChanged", &next_rules);
|
||||
let _ = app_handle.emit(
|
||||
"settings:changed",
|
||||
json!({
|
||||
"key": "auto_score_enabled",
|
||||
"value": enabled,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) {
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
logger.info_with_meta(
|
||||
"auto_score:rule_executed",
|
||||
json!({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.name,
|
||||
"affected_students": stats.affected_students,
|
||||
"created_events": stats.created_events,
|
||||
"added_tags": stats.added_tags,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn log_rule_failed(state: &SafeAppState, rule: &AutoScoreRule, error: &str) {
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
logger.warn_with_meta(
|
||||
"auto_score:rule_failed",
|
||||
json!({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.name,
|
||||
"error": error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn log_rule_skipped(state: &SafeAppState, rule: &AutoScoreRule, reason: &str) {
|
||||
let state_guard = state.read();
|
||||
let logger = state_guard.logger.read();
|
||||
logger.info_with_meta(
|
||||
"auto_score:rule_skipped",
|
||||
json!({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.name,
|
||||
"reason": reason,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rule(mut rule: AutoScoreRule) -> Result<AutoScoreRule, String> {
|
||||
@@ -296,6 +441,21 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, Stri
|
||||
value: Some(value),
|
||||
})
|
||||
}
|
||||
"query_sql" | "student_query_sql" | "student_sql" => {
|
||||
let raw_sql = trigger
|
||||
.value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "SQL trigger requires non-empty SQL".to_string())?;
|
||||
if !is_valid_auto_score_sql(&raw_sql) {
|
||||
return Err("Only read-only SQL expression/query is allowed".to_string());
|
||||
}
|
||||
|
||||
Ok(AutoScoreTrigger {
|
||||
event,
|
||||
value: Some(raw_sql),
|
||||
})
|
||||
}
|
||||
_ => Err(format!("Unsupported trigger event: {}", event)),
|
||||
}
|
||||
}
|
||||
@@ -305,8 +465,8 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
|
||||
|
||||
match event.as_str() {
|
||||
"add_score" => {
|
||||
let value = normalize_non_zero_numeric_string(action.value.as_deref())
|
||||
.ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?;
|
||||
let value = normalize_non_zero_integer_string(action.value.as_deref())
|
||||
.ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?;
|
||||
|
||||
Ok(AutoScoreAction {
|
||||
event,
|
||||
@@ -384,18 +544,18 @@ fn stringify_tag_values(values: &[String]) -> Option<String> {
|
||||
serde_json::to_string(values).ok()
|
||||
}
|
||||
|
||||
fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option<String> {
|
||||
fn normalize_non_zero_integer_string(value: Option<&str>) -> Option<String> {
|
||||
let normalized = value?.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parsed = normalized.parse::<f64>().ok()?;
|
||||
if !parsed.is_finite() || parsed == 0.0 {
|
||||
let parsed = normalized.parse::<i32>().ok()?;
|
||||
if parsed == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized.to_string())
|
||||
Some(parsed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_last_executed(value: Option<String>) -> Option<String> {
|
||||
@@ -454,3 +614,519 @@ fn add_interval_to_time(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_interval_trigger(rule: &AutoScoreRule) -> Option<i64> {
|
||||
let now = Utc::now();
|
||||
|
||||
for trigger in &rule.triggers {
|
||||
if trigger.event != "interval_time_passed" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or(
|
||||
IntervalTriggerValue {
|
||||
amount: DEFAULT_INTERVAL_MINUTES,
|
||||
unit: IntervalUnit::Minute,
|
||||
},
|
||||
);
|
||||
|
||||
let Some(base_time) = rule
|
||||
.last_executed
|
||||
.as_ref()
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return Some(0);
|
||||
};
|
||||
|
||||
let next_execute_time = add_interval_to_time(base_time, &interval)?;
|
||||
let delay_ms = (next_execute_time - now).num_milliseconds();
|
||||
return Some(delay_ms.max(0));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn contains_forbidden_keyword(sql: &str) -> bool {
|
||||
let forbidden: HashSet<&str> = [
|
||||
"insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum",
|
||||
"grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze",
|
||||
"merge", "call", "execute",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut token = String::new();
|
||||
for ch in sql.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !token.is_empty() {
|
||||
if forbidden.contains(token.as_str()) {
|
||||
return true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
|
||||
!token.is_empty() && forbidden.contains(token.as_str())
|
||||
}
|
||||
|
||||
fn starts_with_select_or_with(sql: &str) -> bool {
|
||||
sql.split_whitespace()
|
||||
.next()
|
||||
.map(|first| {
|
||||
let first_lower = first.to_ascii_lowercase();
|
||||
first_lower == "select" || first_lower == "with"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_sql_comment_or_multi_statement(sql: &str) -> bool {
|
||||
sql.contains(';') || sql.contains("--") || sql.contains("/*") || sql.contains("*/")
|
||||
}
|
||||
|
||||
fn is_readonly_query(sql: &str) -> bool {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_sql_comment_or_multi_statement(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !starts_with_select_or_with(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!contains_forbidden_keyword(&trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_safe_sql_expression(sql: &str) -> bool {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if has_sql_comment_or_multi_statement(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!contains_forbidden_keyword(&trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_valid_auto_score_sql(sql: &str) -> bool {
|
||||
let trimmed = sql.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if starts_with_select_or_with(trimmed) {
|
||||
is_readonly_query(trimmed)
|
||||
} else {
|
||||
is_safe_sql_expression(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_readonly_student_query(sql_or_expression: &str) -> Result<String, String> {
|
||||
let trimmed = sql_or_expression.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Empty SQL".to_string());
|
||||
}
|
||||
|
||||
let normalized = if starts_with_select_or_with(trimmed) {
|
||||
if !is_readonly_query(trimmed) {
|
||||
return Err("Only read-only SELECT/CTE query is allowed".to_string());
|
||||
}
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
if !is_safe_sql_expression(trimmed) {
|
||||
return Err("Only safe SQL expression is allowed".to_string());
|
||||
}
|
||||
format!("SELECT id, name FROM students WHERE ({})", trimmed)
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"SELECT * FROM ({}) AS ss_auto_score_query LIMIT {}",
|
||||
normalized, AUTO_SCORE_SQL_LIMIT
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_rules_to_settings(
|
||||
state: &SafeAppState,
|
||||
rules: &[AutoScoreRule],
|
||||
) -> Result<(), String> {
|
||||
let rules_json = AutoScoreService::serialize_rules(rules)?;
|
||||
let enabled = rules.iter().any(|rule| rule.enabled);
|
||||
|
||||
let state_guard = state.read();
|
||||
let db_conn = state_guard.db.read().clone();
|
||||
let mut settings = state_guard.settings.write();
|
||||
settings.attach_db(db_conn);
|
||||
settings.initialize().await?;
|
||||
settings
|
||||
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
|
||||
.await?;
|
||||
settings
|
||||
.set_value(
|
||||
SettingsKey::AutoScoreEnabled,
|
||||
SettingsValue::Boolean(enabled),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_rule(
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
) -> Result<RuleExecutionStats, String> {
|
||||
let mut target_students = resolve_target_students(conn, rule).await?;
|
||||
|
||||
if !rule.student_names.is_empty() {
|
||||
let whitelist: HashSet<String> = rule
|
||||
.student_names
|
||||
.iter()
|
||||
.map(|name| name.trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
target_students.retain(|student| whitelist.contains(&student.name));
|
||||
}
|
||||
|
||||
if target_students.is_empty() {
|
||||
return Ok(RuleExecutionStats::default());
|
||||
}
|
||||
|
||||
let planned_actions = plan_actions(&rule.actions)?;
|
||||
if planned_actions.is_empty() {
|
||||
return Err("No executable action".to_string());
|
||||
}
|
||||
|
||||
let txn = conn.begin().await.map_err(|e| e.to_string())?;
|
||||
let mut stats = RuleExecutionStats {
|
||||
affected_students: target_students.len(),
|
||||
..RuleExecutionStats::default()
|
||||
};
|
||||
|
||||
let mut all_action_tags = Vec::new();
|
||||
for action in &planned_actions {
|
||||
if let PlannedAction::AddTags(tags) = action {
|
||||
all_action_tags.extend(tags.iter().cloned());
|
||||
}
|
||||
}
|
||||
let all_action_tags = dedupe_trimmed_strings(all_action_tags);
|
||||
|
||||
let mut tag_name_to_id = std::collections::HashMap::new();
|
||||
for tag_name in all_action_tags {
|
||||
let tag_id = ensure_tag_exists_in_txn(&txn, &tag_name).await?;
|
||||
tag_name_to_id.insert(tag_name, tag_id);
|
||||
}
|
||||
|
||||
for mut student in target_students {
|
||||
for action in &planned_actions {
|
||||
match action {
|
||||
PlannedAction::AddScore(delta) => {
|
||||
let now = now_iso();
|
||||
let val_prev = student.score;
|
||||
let val_curr = val_prev + delta;
|
||||
let reward_points = student.reward_points + delta;
|
||||
|
||||
let event = score_events::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
uuid: Set(Uuid::new_v4().to_string()),
|
||||
student_name: Set(student.name.clone()),
|
||||
reason_content: Set(build_auto_score_reason(&rule.name, *delta)),
|
||||
delta: Set(*delta),
|
||||
val_prev: Set(val_prev),
|
||||
val_curr: Set(val_curr),
|
||||
event_time: Set(now.clone()),
|
||||
settlement_id: Set(None),
|
||||
};
|
||||
event.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
stats.created_events += 1;
|
||||
|
||||
let mut student_active: students::ActiveModel = student.clone().into();
|
||||
student_active.score = Set(val_curr);
|
||||
student_active.reward_points = Set(reward_points);
|
||||
student_active.updated_at = Set(now);
|
||||
student_active
|
||||
.update(&txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
student.score = val_curr;
|
||||
student.reward_points = reward_points;
|
||||
}
|
||||
PlannedAction::AddTags(tag_names) => {
|
||||
for tag_name in tag_names {
|
||||
let Some(tag_id) = tag_name_to_id.get(tag_name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let inserted =
|
||||
attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?;
|
||||
if inserted {
|
||||
stats.added_tags += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, String> {
|
||||
let mut planned = Vec::new();
|
||||
for action in actions {
|
||||
match action.event.as_str() {
|
||||
"add_score" => {
|
||||
let delta = action
|
||||
.value
|
||||
.as_deref()
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.filter(|value| *value != 0)
|
||||
.ok_or_else(|| "Invalid add_score value".to_string())?;
|
||||
planned.push(PlannedAction::AddScore(delta));
|
||||
}
|
||||
"add_tag" => {
|
||||
let tags = parse_tag_values(action.value.as_deref());
|
||||
if tags.is_empty() {
|
||||
return Err("Invalid add_tag value".to_string());
|
||||
}
|
||||
planned.push(PlannedAction::AddTags(tags));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(planned)
|
||||
}
|
||||
|
||||
async fn resolve_target_students(
|
||||
conn: &DatabaseConnection,
|
||||
rule: &AutoScoreRule,
|
||||
) -> Result<Vec<students::Model>, String> {
|
||||
let explicit_sql_values: Vec<String> = rule
|
||||
.triggers
|
||||
.iter()
|
||||
.filter(|trigger| {
|
||||
matches!(
|
||||
trigger.event.as_str(),
|
||||
"query_sql" | "student_query_sql" | "student_sql"
|
||||
)
|
||||
})
|
||||
.filter_map(|trigger| trigger.value.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
if !explicit_sql_values.is_empty() {
|
||||
let mut refs: Option<StudentRefs> = None;
|
||||
for sql_value in explicit_sql_values {
|
||||
let next_refs = query_student_refs_by_sql(conn, &sql_value).await?;
|
||||
refs = Some(match refs {
|
||||
None => next_refs,
|
||||
Some(existing) => existing.intersect(next_refs),
|
||||
});
|
||||
}
|
||||
|
||||
let refs = refs.unwrap_or_default();
|
||||
if refs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return load_students_from_refs(conn, refs).await;
|
||||
}
|
||||
|
||||
let tag_filters: Vec<String> = rule
|
||||
.triggers
|
||||
.iter()
|
||||
.filter(|trigger| trigger.event == "student_has_tag")
|
||||
.flat_map(|trigger| parse_tag_values(trigger.value.as_deref()))
|
||||
.collect();
|
||||
|
||||
let tag_filters = dedupe_trimmed_strings(tag_filters);
|
||||
if tag_filters.is_empty() {
|
||||
return students::Entity::find()
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
let tag_models = tags::Entity::find()
|
||||
.filter(tags::Column::Name.is_in(tag_filters))
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if tag_models.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let tag_ids: Vec<i32> = tag_models.iter().map(|tag| tag.id).collect();
|
||||
let links = student_tags::Entity::find()
|
||||
.filter(student_tags::Column::TagId.is_in(tag_ids))
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if links.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let student_ids: HashSet<i32> = links.iter().map(|link| link.student_id).collect();
|
||||
students::Entity::find()
|
||||
.filter(students::Column::Id.is_in(student_ids))
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn query_student_refs_by_sql(
|
||||
conn: &DatabaseConnection,
|
||||
sql_or_expression: &str,
|
||||
) -> Result<StudentRefs, String> {
|
||||
let wrapped_sql = build_readonly_student_query(sql_or_expression)?;
|
||||
let rows = conn
|
||||
.query_all(Statement::from_string(
|
||||
conn.get_database_backend(),
|
||||
wrapped_sql,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut refs = StudentRefs::default();
|
||||
for row in &rows {
|
||||
if let Some(id) = try_get_i32(row, "id").or_else(|| try_get_i32(row, "student_id")) {
|
||||
refs.ids.insert(id);
|
||||
}
|
||||
if let Some(name) =
|
||||
try_get_string(row, "name").or_else(|| try_get_string(row, "student_name"))
|
||||
{
|
||||
refs.names.insert(name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
async fn load_students_from_refs(
|
||||
conn: &DatabaseConnection,
|
||||
refs: StudentRefs,
|
||||
) -> Result<Vec<students::Model>, String> {
|
||||
if refs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut condition = Condition::any();
|
||||
if !refs.ids.is_empty() {
|
||||
condition = condition.add(students::Column::Id.is_in(refs.ids));
|
||||
}
|
||||
if !refs.names.is_empty() {
|
||||
condition = condition.add(students::Column::Name.is_in(refs.names));
|
||||
}
|
||||
|
||||
students::Entity::find()
|
||||
.filter(condition)
|
||||
.all(conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option<i32> {
|
||||
row.try_get::<i32>("", column)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
row.try_get::<i64>("", column)
|
||||
.ok()
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
})
|
||||
.or_else(|| {
|
||||
row.try_get::<String>("", column)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option<String> {
|
||||
row.try_get::<String>("", column).ok().and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_tag_exists_in_txn(
|
||||
txn: &sea_orm::DatabaseTransaction,
|
||||
tag_name: &str,
|
||||
) -> Result<i32, String> {
|
||||
if let Some(existing) = tags::Entity::find()
|
||||
.filter(tags::Column::Name.eq(tag_name))
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
return Ok(existing.id);
|
||||
}
|
||||
|
||||
let now = now_iso();
|
||||
let inserted = tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
name: Set(tag_name.to_string()),
|
||||
created_at: Set(now.clone()),
|
||||
updated_at: Set(now),
|
||||
}
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(inserted.id)
|
||||
}
|
||||
|
||||
async fn attach_tag_to_student_if_missing(
|
||||
txn: &sea_orm::DatabaseTransaction,
|
||||
student_id: i32,
|
||||
tag_id: i32,
|
||||
) -> Result<bool, String> {
|
||||
let existing = student_tags::Entity::find()
|
||||
.filter(student_tags::Column::StudentId.eq(student_id))
|
||||
.filter(student_tags::Column::TagId.eq(tag_id))
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let now = now_iso();
|
||||
student_tags::ActiveModel {
|
||||
id: sea_orm::ActiveValue::NotSet,
|
||||
student_id: Set(student_id),
|
||||
tag_id: Set(tag_id),
|
||||
created_at: Set(now),
|
||||
}
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
|
||||
fn build_auto_score_reason(rule_name: &str, delta: i32) -> String {
|
||||
if delta > 0 {
|
||||
format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
|
||||
} else {
|
||||
format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi
|
||||
|
||||
const TRIGGER_FIELD_INTERVAL = "interval_minutes"
|
||||
const TRIGGER_FIELD_TAG = "student_tag"
|
||||
const TRIGGER_FIELD_SQL = "student_sql"
|
||||
const TRIGGER_TYPE_INTERVAL = "interval_duration"
|
||||
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
|
||||
const OP_EQUAL = "equal"
|
||||
@@ -146,6 +147,24 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
trigger.event === "query_sql" ||
|
||||
trigger.event === "student_query_sql" ||
|
||||
trigger.event === "student_sql"
|
||||
) {
|
||||
const sql = toStringValue(trigger.value).trim()
|
||||
if (!sql) return null
|
||||
return {
|
||||
id: QbUtils.uuid(),
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: TRIGGER_FIELD_SQL,
|
||||
operator: OP_EQUAL,
|
||||
value: [sql],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -175,6 +194,15 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (field === TRIGGER_FIELD_SQL) {
|
||||
const sql = toStringValue(value).trim()
|
||||
if (!sql) return null
|
||||
return {
|
||||
event: "query_sql",
|
||||
value: sql,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -197,6 +225,25 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
|
||||
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
|
||||
({
|
||||
...AntdConfig,
|
||||
conjunctions: {
|
||||
...AntdConfig.conjunctions,
|
||||
AND: {
|
||||
...AntdConfig.conjunctions.AND,
|
||||
label: t("autoScore.relationAnd"),
|
||||
},
|
||||
OR: {
|
||||
...AntdConfig.conjunctions.OR,
|
||||
label: t("autoScore.relationOr"),
|
||||
},
|
||||
},
|
||||
operators: {
|
||||
...AntdConfig.operators,
|
||||
[OP_MULTISELECT_CONTAINS]: {
|
||||
...AntdConfig.operators[OP_MULTISELECT_CONTAINS],
|
||||
label: t("autoScore.operatorContains"),
|
||||
labelForFormat: t("autoScore.operatorContains"),
|
||||
},
|
||||
},
|
||||
widgets: {
|
||||
...AntdConfig.widgets,
|
||||
[TRIGGER_WIDGET_INTERVAL]: {
|
||||
@@ -250,6 +297,15 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
showSearch: true,
|
||||
},
|
||||
},
|
||||
[TRIGGER_FIELD_SQL]: {
|
||||
label: t("autoScore.triggerStudentSql"),
|
||||
type: "text",
|
||||
operators: [OP_EQUAL],
|
||||
valueSources: ["value"],
|
||||
fieldSettings: {
|
||||
placeholder: t("autoScore.triggerStudentSqlPlaceholder"),
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
...AntdConfig.settings,
|
||||
@@ -257,6 +313,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
compactMode: false,
|
||||
renderSize: "medium",
|
||||
showNot: true,
|
||||
notLabel: t("autoScore.relationNot"),
|
||||
forceShowConj: true,
|
||||
canLeaveEmptyGroup: false,
|
||||
canReorder: true,
|
||||
@@ -267,6 +324,9 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
||||
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
||||
|
||||
export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree =>
|
||||
QbUtils.checkTree(tree, config)
|
||||
|
||||
export const triggersToQueryTree = (
|
||||
config: Config,
|
||||
triggers: AutoScoreTrigger[]
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createEmptyTriggerTree,
|
||||
createTriggerQueryConfig,
|
||||
hasUnsupportedTriggerLogic,
|
||||
normalizeTriggerTree,
|
||||
normalizeActionDrafts,
|
||||
queryTreeToTriggers,
|
||||
triggersToQueryTree,
|
||||
@@ -54,6 +55,8 @@ interface AutoScoreManagerProps {
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
|
||||
|
||||
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [form] = Form.useForm<RuleFormValues>()
|
||||
@@ -92,6 +95,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}, [currentPage, pageSize, rules.length])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
|
||||
}, [triggerConfig])
|
||||
|
||||
const resetEditor = () => {
|
||||
setEditingRuleId(null)
|
||||
form.setFieldsValue({ name: "", studentNames: [] })
|
||||
@@ -262,6 +269,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
|
||||
const res = await api.autoScoreDeleteRule(ruleId)
|
||||
if (res.success) {
|
||||
await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0)
|
||||
messageApi.success(t("autoScore.deleteSuccess"))
|
||||
if (editingRuleId === ruleId) {
|
||||
resetEditor()
|
||||
@@ -295,6 +303,38 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRuleFile = async (rule: AutoScoreRule) => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
try {
|
||||
const relativePath = getRuleFileRelativePath(rule.id)
|
||||
const fileContent = {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions,
|
||||
lastExecuted: rule.lastExecuted ?? null,
|
||||
exportedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic")
|
||||
|
||||
if (!writeRes?.success) {
|
||||
throw new Error("prepare rule file failed")
|
||||
}
|
||||
|
||||
const openRes = await api.fsOpenPath(relativePath, "automatic")
|
||||
if (!openRes?.success) {
|
||||
throw new Error(openRes?.message || "open rule file failed")
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("autoScore.openFileFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastExecuted = (value?: string | null) => {
|
||||
if (!value) return t("autoScore.notExecuted")
|
||||
const date = new Date(value)
|
||||
@@ -358,12 +398,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
{
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 140,
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" disabled={!canEdit} onClick={() => handleEdit(row)}>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<Button type="link" onClick={() => handleOpenRuleFile(row).catch(() => void 0)}>
|
||||
{t("autoScore.openFile")}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t("autoScore.deleteConfirm")}
|
||||
onConfirm={() => {
|
||||
@@ -391,7 +434,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: "16px" }}
|
||||
message={t("autoScore.adminRequired")}
|
||||
title={t("autoScore.adminRequired")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -403,7 +446,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
name="name"
|
||||
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
|
||||
>
|
||||
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} />
|
||||
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} autoComplete="off"/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
|
||||
<Select
|
||||
|
||||
@@ -814,9 +814,11 @@
|
||||
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
|
||||
"triggerIntervalTime": "Interval Time",
|
||||
"triggerStudentTag": "Student Tag",
|
||||
"triggerStudentSql": "Student SQL",
|
||||
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
|
||||
"actionAddScore": "Add Score",
|
||||
"actionAddTag": "Add Tag",
|
||||
"intervalAmountPlaceholder": "Enter interval amount",
|
||||
"intervalAmountPlaceholder": "Enter interval time",
|
||||
"intervalUnitMonth": "month(s) later",
|
||||
"intervalUnitDay": "day(s) later",
|
||||
"intervalUnitMinute": "minute(s) later",
|
||||
@@ -827,7 +829,11 @@
|
||||
"tagNamePlaceholder": "Enter tag name",
|
||||
"reasonPlaceholder": "Enter reason",
|
||||
"relationAnd": "AND",
|
||||
"relationOr": "OR"
|
||||
"relationOr": "OR",
|
||||
"operatorContains": "Contains",
|
||||
"relationNot": "Not",
|
||||
"openFile": "Open File",
|
||||
"openFileFailed": "Failed to open automation file"
|
||||
},
|
||||
"triggers": {
|
||||
"studentTag": {
|
||||
|
||||
@@ -812,9 +812,11 @@
|
||||
"operationNoteLabel": "操作说明(可选)",
|
||||
"triggerIntervalTime": "间隔时间",
|
||||
"triggerStudentTag": "学生标签",
|
||||
"triggerStudentSql": "学生 SQL 条件",
|
||||
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
|
||||
"actionAddScore": "加分",
|
||||
"actionAddTag": "添加标签",
|
||||
"intervalAmountPlaceholder": "请输入间隔数量",
|
||||
"intervalAmountPlaceholder": "请输入间隔时间数值",
|
||||
"intervalUnitMonth": "个月后",
|
||||
"intervalUnitDay": "天后",
|
||||
"intervalUnitMinute": "分钟后",
|
||||
@@ -824,8 +826,12 @@
|
||||
"scorePlaceholder": "请输入分数",
|
||||
"tagNamePlaceholder": "请输入标签名称",
|
||||
"reasonPlaceholder": "请输入理由",
|
||||
"relationAnd": "并且",
|
||||
"relationOr": "或者"
|
||||
"relationAnd": "且",
|
||||
"relationOr": "或",
|
||||
"operatorContains": "包含",
|
||||
"relationNot": "非",
|
||||
"openFile": "打开文件",
|
||||
"openFileFailed": "打开自动化文件失败"
|
||||
},
|
||||
"triggers": {
|
||||
"studentTag": {
|
||||
|
||||
@@ -613,6 +613,11 @@ const api = {
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean; data: boolean }> =>
|
||||
invoke("fs_file_exists", { relativePath, folder }),
|
||||
fsOpenPath: (
|
||||
relativePath: string,
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean; message?: string }> =>
|
||||
invoke("fs_open_path", { relativePath, folder }),
|
||||
|
||||
// App
|
||||
registerUrlProtocol: (): Promise<{
|
||||
|
||||
Reference in New Issue
Block a user