mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +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;
|
use rand::Rng;
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
|
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!(
|
let url = format!(
|
||||||
@@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url(
|
|||||||
urlencoding::encode(&state)
|
urlencoding::encode(&state)
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state }))
|
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
|
||||||
|
url,
|
||||||
|
state,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -355,8 +361,11 @@ pub async fn oauth_exchange_code(
|
|||||||
platform_secret: String,
|
platform_secret: String,
|
||||||
callback_url: String,
|
callback_url: String,
|
||||||
) -> Result<IpcResponse<OAuthTokenResponse>, 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 client = reqwest::Client::new();
|
||||||
let params = [
|
let params = [
|
||||||
("grant_type", "authorization_code"),
|
("grant_type", "authorization_code"),
|
||||||
@@ -365,9 +374,9 @@ pub async fn oauth_exchange_code(
|
|||||||
("client_secret", &platform_secret),
|
("client_secret", &platform_secret),
|
||||||
("redirect_uri", &callback_url),
|
("redirect_uri", &callback_url),
|
||||||
];
|
];
|
||||||
|
|
||||||
println!("[OAuth] 请求参数:{:?}", params);
|
println!("[OAuth] 请求参数:{:?}", params);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post("https://sectl.top/api/oauth/token")
|
.post("https://sectl.top/api/oauth/token")
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
@@ -376,8 +385,11 @@ pub async fn oauth_exchange_code(
|
|||||||
.map_err(|e| format!("Request failed: {}", e))?;
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
println!("[OAuth] 响应状态:{}", response.status());
|
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);
|
println!("[OAuth] 响应内容:{}", response_text);
|
||||||
|
|
||||||
if !response_text.is_empty() && response_text.contains("error") {
|
if !response_text.is_empty() && response_text.contains("error") {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use parking_lot::RwLock;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
@@ -284,3 +285,55 @@ pub async fn fs_file_exists(
|
|||||||
|
|
||||||
Ok(IpcResponse::success(file_path.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 parking_lot::RwLock;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::oneshot;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::response::IpcResponse;
|
use super::response::IpcResponse;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OAuthServerStartResult {
|
pub struct OAuthServerStartResult {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OAuthCallbackResult {
|
pub struct OAuthCallbackResult {
|
||||||
pub code: Option<String>,
|
pub code: Option<String>,
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub error_description: Option<String>,
|
pub error_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
||||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn oauth_start_callback_server(
|
pub async fn oauth_start_callback_server(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
|
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
|
||||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||||
|
|
||||||
// 如果服务器已经在运行,直接返回 URL
|
// 如果服务器已经在运行,直接返回 URL
|
||||||
if shutdown_tx.is_some() {
|
if shutdown_tx.is_some() {
|
||||||
let port = 16888u16;
|
let port = 16888u16;
|
||||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = 16888u16;
|
let port = 16888u16;
|
||||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
let url_for_spawn = url.clone();
|
let url_for_spawn = url.clone();
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel::<()>();
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
*shutdown_tx = Some(tx);
|
*shutdown_tx = Some(tx);
|
||||||
drop(shutdown_tx);
|
drop(shutdown_tx);
|
||||||
|
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let app = axum::Router::new()
|
let app = axum::Router::new()
|
||||||
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
||||||
.layer(axum::extract::Extension(app_handle_clone));
|
.layer(axum::extract::Extension(app_handle_clone));
|
||||||
|
|
||||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to bind OAuth callback server: {}", e);
|
eprintln!("Failed to bind OAuth callback server: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("OAuth callback server started at {}", url_for_spawn);
|
println!("OAuth callback server started at {}", url_for_spawn);
|
||||||
|
|
||||||
let server = axum::serve(listener, app);
|
let server = axum::serve(listener, app);
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = server => {},
|
_ = server => {},
|
||||||
_ = rx => {
|
_ = rx => {
|
||||||
println!("OAuth callback server shutting down");
|
println!("OAuth callback server shutting down");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn oauth_stop_callback_server(
|
pub async fn oauth_stop_callback_server(
|
||||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<()>, String> {
|
) -> Result<IpcResponse<()>, String> {
|
||||||
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||||
|
|
||||||
if let Some(tx) = shutdown_tx.take() {
|
if let Some(tx) = shutdown_tx.take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(IpcResponse::success(()))
|
Ok(IpcResponse::success(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_oauth_callback(
|
async fn handle_oauth_callback(
|
||||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||||
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
|
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
|
||||||
) -> impl axum::response::IntoResponse {
|
) -> impl axum::response::IntoResponse {
|
||||||
let code = params.get("code").cloned();
|
let code = params.get("code").cloned();
|
||||||
let state = params.get("state").cloned();
|
let state = params.get("state").cloned();
|
||||||
let error = params.get("error").cloned();
|
let error = params.get("error").cloned();
|
||||||
let error_description = params.get("error_description").cloned();
|
let error_description = params.get("error_description").cloned();
|
||||||
|
|
||||||
println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error);
|
println!(
|
||||||
|
"[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}",
|
||||||
let result = OAuthCallbackResult {
|
code, state, error
|
||||||
code: code.clone(),
|
);
|
||||||
state: state.clone(),
|
|
||||||
error: error.clone(),
|
let result = OAuthCallbackResult {
|
||||||
error_description: error_description.clone(),
|
code: code.clone(),
|
||||||
};
|
state: state.clone(),
|
||||||
|
error: error.clone(),
|
||||||
match app_handle.emit("oauth-callback", result) {
|
error_description: error_description.clone(),
|
||||||
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
|
};
|
||||||
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
|
|
||||||
}
|
match app_handle.emit("oauth-callback", result) {
|
||||||
|
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
|
||||||
let html = r#"
|
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
|
||||||
<!DOCTYPE html>
|
}
|
||||||
<html>
|
|
||||||
<head>
|
let html = r#"
|
||||||
<meta charset="UTF-8">
|
<!DOCTYPE html>
|
||||||
<title>OAuth 授权完成</title>
|
<html>
|
||||||
<style>
|
<head>
|
||||||
* {
|
<meta charset="UTF-8">
|
||||||
margin: 0;
|
<title>OAuth 授权完成</title>
|
||||||
padding: 0;
|
<style>
|
||||||
box-sizing: border-box;
|
* {
|
||||||
}
|
margin: 0;
|
||||||
body {
|
padding: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
box-sizing: border-box;
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
body {
|
||||||
align-items: center;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
justify-content: center;
|
||||||
color: #e4e4e4;
|
align-items: center;
|
||||||
}
|
min-height: 100vh;
|
||||||
.container {
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
text-align: center;
|
color: #e4e4e4;
|
||||||
padding: 48px 40px;
|
}
|
||||||
background: rgba(255, 255, 255, 0.05);
|
.container {
|
||||||
border-radius: 20px;
|
text-align: center;
|
||||||
backdrop-filter: blur(20px);
|
padding: 48px 40px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
background: rgba(255, 255, 255, 0.05);
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
border-radius: 20px;
|
||||||
animation: slideUp 0.5s ease-out;
|
backdrop-filter: blur(20px);
|
||||||
}
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||||
@keyframes slideUp {
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
from {
|
animation: slideUp 0.5s ease-out;
|
||||||
opacity: 0;
|
}
|
||||||
transform: translateY(20px);
|
@keyframes slideUp {
|
||||||
}
|
from {
|
||||||
to {
|
opacity: 0;
|
||||||
opacity: 1;
|
transform: translateY(20px);
|
||||||
transform: translateY(0);
|
}
|
||||||
}
|
to {
|
||||||
}
|
opacity: 1;
|
||||||
.icon {
|
transform: translateY(0);
|
||||||
width: 80px;
|
}
|
||||||
height: 80px;
|
}
|
||||||
margin: 0 auto 24px;
|
.icon {
|
||||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
width: 80px;
|
||||||
border-radius: 50%;
|
height: 80px;
|
||||||
display: flex;
|
margin: 0 auto 24px;
|
||||||
align-items: center;
|
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||||
justify-content: center;
|
border-radius: 50%;
|
||||||
box-shadow: 0 4px 20px rgba(74, 222, 128, 0.4);
|
display: flex;
|
||||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
@keyframes scaleIn {
|
box-shadow: 0 4px 20px rgba(74, 222, 128, 0.4);
|
||||||
from {
|
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||||
opacity: 0;
|
}
|
||||||
transform: scale(0.5);
|
@keyframes scaleIn {
|
||||||
}
|
from {
|
||||||
to {
|
opacity: 0;
|
||||||
opacity: 1;
|
transform: scale(0.5);
|
||||||
transform: scale(1);
|
}
|
||||||
}
|
to {
|
||||||
}
|
opacity: 1;
|
||||||
.icon svg {
|
transform: scale(1);
|
||||||
width: 40px;
|
}
|
||||||
height: 40px;
|
}
|
||||||
color: white;
|
.icon svg {
|
||||||
}
|
width: 40px;
|
||||||
h1 {
|
height: 40px;
|
||||||
margin: 0 0 12px 0;
|
color: white;
|
||||||
font-size: 28px;
|
}
|
||||||
font-weight: 600;
|
h1 {
|
||||||
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
margin: 0 0 12px 0;
|
||||||
-webkit-background-clip: text;
|
font-size: 28px;
|
||||||
-webkit-text-fill-color: transparent;
|
font-weight: 600;
|
||||||
background-clip: text;
|
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
|
||||||
}
|
-webkit-background-clip: text;
|
||||||
p {
|
-webkit-text-fill-color: transparent;
|
||||||
margin: 0;
|
background-clip: text;
|
||||||
color: #a0a0a0;
|
}
|
||||||
font-size: 15px;
|
p {
|
||||||
line-height: 1.6;
|
margin: 0;
|
||||||
}
|
color: #a0a0a0;
|
||||||
.hint {
|
font-size: 15px;
|
||||||
margin-top: 20px;
|
line-height: 1.6;
|
||||||
font-size: 13px;
|
}
|
||||||
color: #666;
|
.hint {
|
||||||
}
|
margin-top: 20px;
|
||||||
</style>
|
font-size: 13px;
|
||||||
</head>
|
color: #666;
|
||||||
<body>
|
}
|
||||||
<div class="container">
|
</style>
|
||||||
<div class="icon">
|
</head>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<body>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
<div class="container">
|
||||||
</svg>
|
<div class="icon">
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<h1>授权成功</h1>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
<p>请返回 SecScore 应用查看登录结果</p>
|
</svg>
|
||||||
<p class="hint">此窗口可以关闭</p>
|
</div>
|
||||||
</div>
|
<h1>授权成功</h1>
|
||||||
<script>
|
<p>请返回 SecScore 应用查看登录结果</p>
|
||||||
setTimeout(() => {
|
<p class="hint">此窗口可以关闭</p>
|
||||||
window.close();
|
</div>
|
||||||
}, 3000);
|
<script>
|
||||||
</script>
|
setTimeout(() => {
|
||||||
</body>
|
window.close();
|
||||||
</html>
|
}, 3000);
|
||||||
"#;
|
</script>
|
||||||
|
</body>
|
||||||
let error_html = r#"
|
</html>
|
||||||
<!DOCTYPE html>
|
"#;
|
||||||
<html>
|
|
||||||
<head>
|
let error_html = r#"
|
||||||
<meta charset="UTF-8">
|
<!DOCTYPE html>
|
||||||
<title>OAuth 授权失败</title>
|
<html>
|
||||||
<style>
|
<head>
|
||||||
* {
|
<meta charset="UTF-8">
|
||||||
margin: 0;
|
<title>OAuth 授权失败</title>
|
||||||
padding: 0;
|
<style>
|
||||||
box-sizing: border-box;
|
* {
|
||||||
}
|
margin: 0;
|
||||||
body {
|
padding: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
box-sizing: border-box;
|
||||||
display: flex;
|
}
|
||||||
justify-content: center;
|
body {
|
||||||
align-items: center;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
justify-content: center;
|
||||||
color: #e4e4e4;
|
align-items: center;
|
||||||
}
|
min-height: 100vh;
|
||||||
.container {
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
text-align: center;
|
color: #e4e4e4;
|
||||||
padding: 48px 40px;
|
}
|
||||||
background: rgba(255, 255, 255, 0.05);
|
.container {
|
||||||
border-radius: 20px;
|
text-align: center;
|
||||||
backdrop-filter: blur(20px);
|
padding: 48px 40px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
background: rgba(255, 255, 255, 0.05);
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
border-radius: 20px;
|
||||||
animation: slideUp 0.5s ease-out;
|
backdrop-filter: blur(20px);
|
||||||
}
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
||||||
@keyframes slideUp {
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
from {
|
animation: slideUp 0.5s ease-out;
|
||||||
opacity: 0;
|
}
|
||||||
transform: translateY(20px);
|
@keyframes slideUp {
|
||||||
}
|
from {
|
||||||
to {
|
opacity: 0;
|
||||||
opacity: 1;
|
transform: translateY(20px);
|
||||||
transform: translateY(0);
|
}
|
||||||
}
|
to {
|
||||||
}
|
opacity: 1;
|
||||||
.icon {
|
transform: translateY(0);
|
||||||
width: 80px;
|
}
|
||||||
height: 80px;
|
}
|
||||||
margin: 0 auto 24px;
|
.icon {
|
||||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
width: 80px;
|
||||||
border-radius: 50%;
|
height: 80px;
|
||||||
display: flex;
|
margin: 0 auto 24px;
|
||||||
align-items: center;
|
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||||
justify-content: center;
|
border-radius: 50%;
|
||||||
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.4);
|
display: flex;
|
||||||
animation: scaleIn 0.5s ease-out 0.2s both;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
@keyframes scaleIn {
|
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.4);
|
||||||
from {
|
animation: scaleIn 0.5s ease-out 0.2s both;
|
||||||
opacity: 0;
|
}
|
||||||
transform: scale(0.5);
|
@keyframes scaleIn {
|
||||||
}
|
from {
|
||||||
to {
|
opacity: 0;
|
||||||
opacity: 1;
|
transform: scale(0.5);
|
||||||
transform: scale(1);
|
}
|
||||||
}
|
to {
|
||||||
}
|
opacity: 1;
|
||||||
.icon svg {
|
transform: scale(1);
|
||||||
width: 40px;
|
}
|
||||||
height: 40px;
|
}
|
||||||
color: white;
|
.icon svg {
|
||||||
}
|
width: 40px;
|
||||||
h1 {
|
height: 40px;
|
||||||
margin: 0 0 12px 0;
|
color: white;
|
||||||
font-size: 28px;
|
}
|
||||||
font-weight: 600;
|
h1 {
|
||||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
margin: 0 0 12px 0;
|
||||||
-webkit-background-clip: text;
|
font-size: 28px;
|
||||||
-webkit-text-fill-color: transparent;
|
font-weight: 600;
|
||||||
background-clip: text;
|
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||||
}
|
-webkit-background-clip: text;
|
||||||
p {
|
-webkit-text-fill-color: transparent;
|
||||||
margin: 0;
|
background-clip: text;
|
||||||
color: #a0a0a0;
|
}
|
||||||
font-size: 15px;
|
p {
|
||||||
line-height: 1.6;
|
margin: 0;
|
||||||
}
|
color: #a0a0a0;
|
||||||
.hint {
|
font-size: 15px;
|
||||||
margin-top: 20px;
|
line-height: 1.6;
|
||||||
font-size: 13px;
|
}
|
||||||
color: #666;
|
.hint {
|
||||||
}
|
margin-top: 20px;
|
||||||
</style>
|
font-size: 13px;
|
||||||
</head>
|
color: #666;
|
||||||
<body>
|
}
|
||||||
<div class="container">
|
</style>
|
||||||
<div class="icon">
|
</head>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<body>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<div class="container">
|
||||||
</svg>
|
<div class="icon">
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<h1>授权失败</h1>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
<p>请返回 SecScore 应用查看错误信息</p>
|
</svg>
|
||||||
<p class="hint">此窗口可以关闭</p>
|
</div>
|
||||||
</div>
|
<h1>授权失败</h1>
|
||||||
</body>
|
<p>请返回 SecScore 应用查看错误信息</p>
|
||||||
</html>
|
<p class="hint">此窗口可以关闭</p>
|
||||||
"#;
|
</div>
|
||||||
|
</body>
|
||||||
let response_html = if error.is_some() { error_html } else { html };
|
</html>
|
||||||
|
"#;
|
||||||
(
|
|
||||||
axum::http::StatusCode::OK,
|
let response_html = if error.is_some() { error_html } else { html };
|
||||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
|
||||||
response_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_delete_file,
|
||||||
fs_list_files,
|
fs_list_files,
|
||||||
fs_file_exists,
|
fs_file_exists,
|
||||||
|
fs_open_path,
|
||||||
http_server_start,
|
http_server_start,
|
||||||
http_server_stop,
|
http_server_stop,
|
||||||
http_server_status,
|
http_server_status,
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
use chrono::{DateTime, Months, Utc};
|
use chrono::{DateTime, Months, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait,
|
||||||
|
QueryFilter, Set, Statement, TransactionTrait,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::{json, Value as JsonValue};
|
||||||
use std::collections::HashSet;
|
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 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AutoScoreTrigger {
|
pub struct AutoScoreTrigger {
|
||||||
@@ -45,6 +58,37 @@ pub struct AutoScoreRule {
|
|||||||
pub last_executed: Option<String>,
|
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 {
|
impl Default for AutoScoreRule {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -87,12 +131,14 @@ impl AutoScoreService {
|
|||||||
|
|
||||||
pub async fn initialize(
|
pub async fn initialize(
|
||||||
&mut self,
|
&mut self,
|
||||||
_app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if self.initialized {
|
if self.initialized {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.initialized = true;
|
self.initialized = true;
|
||||||
|
Self::spawn_scheduler(app_handle.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,38 +257,137 @@ impl AutoScoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option<i64> {
|
pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option<i64> {
|
||||||
let now = Utc::now();
|
check_interval_trigger(rule)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn notify_rules_changed(&self, app_handle: &AppHandle) {
|
pub async fn notify_rules_changed(&self, app_handle: &AppHandle) {
|
||||||
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules);
|
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> {
|
fn normalize_rule(mut rule: AutoScoreRule) -> Result<AutoScoreRule, String> {
|
||||||
@@ -296,6 +441,21 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, Stri
|
|||||||
value: Some(value),
|
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)),
|
_ => Err(format!("Unsupported trigger event: {}", event)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,8 +465,8 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
|
|||||||
|
|
||||||
match event.as_str() {
|
match event.as_str() {
|
||||||
"add_score" => {
|
"add_score" => {
|
||||||
let value = normalize_non_zero_numeric_string(action.value.as_deref())
|
let value = normalize_non_zero_integer_string(action.value.as_deref())
|
||||||
.ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?;
|
.ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?;
|
||||||
|
|
||||||
Ok(AutoScoreAction {
|
Ok(AutoScoreAction {
|
||||||
event,
|
event,
|
||||||
@@ -384,18 +544,18 @@ fn stringify_tag_values(values: &[String]) -> Option<String> {
|
|||||||
serde_json::to_string(values).ok()
|
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();
|
let normalized = value?.trim();
|
||||||
if normalized.is_empty() {
|
if normalized.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = normalized.parse::<f64>().ok()?;
|
let parsed = normalized.parse::<i32>().ok()?;
|
||||||
if !parsed.is_finite() || parsed == 0.0 {
|
if parsed == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(normalized.to_string())
|
Some(parsed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_last_executed(value: Option<String>) -> Option<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_INTERVAL = "interval_minutes"
|
||||||
const TRIGGER_FIELD_TAG = "student_tag"
|
const TRIGGER_FIELD_TAG = "student_tag"
|
||||||
|
const TRIGGER_FIELD_SQL = "student_sql"
|
||||||
const TRIGGER_TYPE_INTERVAL = "interval_duration"
|
const TRIGGER_TYPE_INTERVAL = "interval_duration"
|
||||||
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
|
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
|
||||||
const OP_EQUAL = "equal"
|
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
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +225,25 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
|
|||||||
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
|
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
|
||||||
({
|
({
|
||||||
...AntdConfig,
|
...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: {
|
widgets: {
|
||||||
...AntdConfig.widgets,
|
...AntdConfig.widgets,
|
||||||
[TRIGGER_WIDGET_INTERVAL]: {
|
[TRIGGER_WIDGET_INTERVAL]: {
|
||||||
@@ -250,6 +297,15 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
|||||||
showSearch: true,
|
showSearch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[TRIGGER_FIELD_SQL]: {
|
||||||
|
label: t("autoScore.triggerStudentSql"),
|
||||||
|
type: "text",
|
||||||
|
operators: [OP_EQUAL],
|
||||||
|
valueSources: ["value"],
|
||||||
|
fieldSettings: {
|
||||||
|
placeholder: t("autoScore.triggerStudentSqlPlaceholder"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
...AntdConfig.settings,
|
...AntdConfig.settings,
|
||||||
@@ -257,6 +313,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
|||||||
compactMode: false,
|
compactMode: false,
|
||||||
renderSize: "medium",
|
renderSize: "medium",
|
||||||
showNot: true,
|
showNot: true,
|
||||||
|
notLabel: t("autoScore.relationNot"),
|
||||||
forceShowConj: true,
|
forceShowConj: true,
|
||||||
canLeaveEmptyGroup: false,
|
canLeaveEmptyGroup: false,
|
||||||
canReorder: true,
|
canReorder: true,
|
||||||
@@ -267,6 +324,9 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
|||||||
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
||||||
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
||||||
|
|
||||||
|
export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree =>
|
||||||
|
QbUtils.checkTree(tree, config)
|
||||||
|
|
||||||
export const triggersToQueryTree = (
|
export const triggersToQueryTree = (
|
||||||
config: Config,
|
config: Config,
|
||||||
triggers: AutoScoreTrigger[]
|
triggers: AutoScoreTrigger[]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
createEmptyTriggerTree,
|
createEmptyTriggerTree,
|
||||||
createTriggerQueryConfig,
|
createTriggerQueryConfig,
|
||||||
hasUnsupportedTriggerLogic,
|
hasUnsupportedTriggerLogic,
|
||||||
|
normalizeTriggerTree,
|
||||||
normalizeActionDrafts,
|
normalizeActionDrafts,
|
||||||
queryTreeToTriggers,
|
queryTreeToTriggers,
|
||||||
triggersToQueryTree,
|
triggersToQueryTree,
|
||||||
@@ -54,6 +55,8 @@ interface AutoScoreManagerProps {
|
|||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
|
||||||
|
|
||||||
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const [form] = Form.useForm<RuleFormValues>()
|
const [form] = Form.useForm<RuleFormValues>()
|
||||||
@@ -92,6 +95,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
}
|
}
|
||||||
}, [currentPage, pageSize, rules.length])
|
}, [currentPage, pageSize, rules.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
|
||||||
|
}, [triggerConfig])
|
||||||
|
|
||||||
const resetEditor = () => {
|
const resetEditor = () => {
|
||||||
setEditingRuleId(null)
|
setEditingRuleId(null)
|
||||||
form.setFieldsValue({ name: "", studentNames: [] })
|
form.setFieldsValue({ name: "", studentNames: [] })
|
||||||
@@ -262,6 +269,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
|
|
||||||
const res = await api.autoScoreDeleteRule(ruleId)
|
const res = await api.autoScoreDeleteRule(ruleId)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0)
|
||||||
messageApi.success(t("autoScore.deleteSuccess"))
|
messageApi.success(t("autoScore.deleteSuccess"))
|
||||||
if (editingRuleId === ruleId) {
|
if (editingRuleId === ruleId) {
|
||||||
resetEditor()
|
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) => {
|
const formatLastExecuted = (value?: string | null) => {
|
||||||
if (!value) return t("autoScore.notExecuted")
|
if (!value) return t("autoScore.notExecuted")
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -358,12 +398,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
{
|
{
|
||||||
title: t("common.operation"),
|
title: t("common.operation"),
|
||||||
key: "operation",
|
key: "operation",
|
||||||
width: 140,
|
width: 220,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Button type="link" disabled={!canEdit} onClick={() => handleEdit(row)}>
|
<Button type="link" disabled={!canEdit} onClick={() => handleEdit(row)}>
|
||||||
{t("common.edit")}
|
{t("common.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="link" onClick={() => handleOpenRuleFile(row).catch(() => void 0)}>
|
||||||
|
{t("autoScore.openFile")}
|
||||||
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t("autoScore.deleteConfirm")}
|
title={t("autoScore.deleteConfirm")}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
@@ -391,7 +434,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: "16px" }}
|
style={{ marginBottom: "16px" }}
|
||||||
message={t("autoScore.adminRequired")}
|
title={t("autoScore.adminRequired")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -403,7 +446,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
name="name"
|
name="name"
|
||||||
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
|
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>
|
||||||
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
|
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -814,9 +814,11 @@
|
|||||||
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
|
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
|
||||||
"triggerIntervalTime": "Interval Time",
|
"triggerIntervalTime": "Interval Time",
|
||||||
"triggerStudentTag": "Student Tag",
|
"triggerStudentTag": "Student Tag",
|
||||||
|
"triggerStudentSql": "Student SQL",
|
||||||
|
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
|
||||||
"actionAddScore": "Add Score",
|
"actionAddScore": "Add Score",
|
||||||
"actionAddTag": "Add Tag",
|
"actionAddTag": "Add Tag",
|
||||||
"intervalAmountPlaceholder": "Enter interval amount",
|
"intervalAmountPlaceholder": "Enter interval time",
|
||||||
"intervalUnitMonth": "month(s) later",
|
"intervalUnitMonth": "month(s) later",
|
||||||
"intervalUnitDay": "day(s) later",
|
"intervalUnitDay": "day(s) later",
|
||||||
"intervalUnitMinute": "minute(s) later",
|
"intervalUnitMinute": "minute(s) later",
|
||||||
@@ -827,7 +829,11 @@
|
|||||||
"tagNamePlaceholder": "Enter tag name",
|
"tagNamePlaceholder": "Enter tag name",
|
||||||
"reasonPlaceholder": "Enter reason",
|
"reasonPlaceholder": "Enter reason",
|
||||||
"relationAnd": "AND",
|
"relationAnd": "AND",
|
||||||
"relationOr": "OR"
|
"relationOr": "OR",
|
||||||
|
"operatorContains": "Contains",
|
||||||
|
"relationNot": "Not",
|
||||||
|
"openFile": "Open File",
|
||||||
|
"openFileFailed": "Failed to open automation file"
|
||||||
},
|
},
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"studentTag": {
|
"studentTag": {
|
||||||
|
|||||||
@@ -812,9 +812,11 @@
|
|||||||
"operationNoteLabel": "操作说明(可选)",
|
"operationNoteLabel": "操作说明(可选)",
|
||||||
"triggerIntervalTime": "间隔时间",
|
"triggerIntervalTime": "间隔时间",
|
||||||
"triggerStudentTag": "学生标签",
|
"triggerStudentTag": "学生标签",
|
||||||
|
"triggerStudentSql": "学生 SQL 条件",
|
||||||
|
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
|
||||||
"actionAddScore": "加分",
|
"actionAddScore": "加分",
|
||||||
"actionAddTag": "添加标签",
|
"actionAddTag": "添加标签",
|
||||||
"intervalAmountPlaceholder": "请输入间隔数量",
|
"intervalAmountPlaceholder": "请输入间隔时间数值",
|
||||||
"intervalUnitMonth": "个月后",
|
"intervalUnitMonth": "个月后",
|
||||||
"intervalUnitDay": "天后",
|
"intervalUnitDay": "天后",
|
||||||
"intervalUnitMinute": "分钟后",
|
"intervalUnitMinute": "分钟后",
|
||||||
@@ -824,8 +826,12 @@
|
|||||||
"scorePlaceholder": "请输入分数",
|
"scorePlaceholder": "请输入分数",
|
||||||
"tagNamePlaceholder": "请输入标签名称",
|
"tagNamePlaceholder": "请输入标签名称",
|
||||||
"reasonPlaceholder": "请输入理由",
|
"reasonPlaceholder": "请输入理由",
|
||||||
"relationAnd": "并且",
|
"relationAnd": "且",
|
||||||
"relationOr": "或者"
|
"relationOr": "或",
|
||||||
|
"operatorContains": "包含",
|
||||||
|
"relationNot": "非",
|
||||||
|
"openFile": "打开文件",
|
||||||
|
"openFileFailed": "打开自动化文件失败"
|
||||||
},
|
},
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"studentTag": {
|
"studentTag": {
|
||||||
|
|||||||
@@ -613,6 +613,11 @@ const api = {
|
|||||||
folder?: "automatic" | "script"
|
folder?: "automatic" | "script"
|
||||||
): Promise<{ success: boolean; data: boolean }> =>
|
): Promise<{ success: boolean; data: boolean }> =>
|
||||||
invoke("fs_file_exists", { relativePath, folder }),
|
invoke("fs_file_exists", { relativePath, folder }),
|
||||||
|
fsOpenPath: (
|
||||||
|
relativePath: string,
|
||||||
|
folder?: "automatic" | "script"
|
||||||
|
): Promise<{ success: boolean; message?: string }> =>
|
||||||
|
invoke("fs_open_path", { relativePath, folder }),
|
||||||
|
|
||||||
// App
|
// App
|
||||||
registerUrlProtocol: (): Promise<{
|
registerUrlProtocol: (): Promise<{
|
||||||
|
|||||||
Reference in New Issue
Block a user