mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
Generated
+12
-1
@@ -1079,7 +1079,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4242,6 +4242,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"winreg 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6913,6 +6914,16 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ dirs = "6.0.0"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
winreg = "0.52"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
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]
|
||||||
@@ -367,9 +373,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)
|
||||||
@@ -378,8 +384,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") {
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ use std::sync::Arc;
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel,
|
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
||||||
SettingsKey, SettingsValue,
|
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||||
|
AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -27,7 +28,13 @@ pub struct CreateAutoScoreRule {
|
|||||||
#[serde(rename = "studentNames")]
|
#[serde(rename = "studentNames")]
|
||||||
pub student_names: Vec<String>,
|
pub student_names: Vec<String>,
|
||||||
pub triggers: Vec<AutoScoreTrigger>,
|
pub triggers: Vec<AutoScoreTrigger>,
|
||||||
|
#[serde(rename = "triggerTree", default)]
|
||||||
|
pub trigger_tree: Option<JsonValue>,
|
||||||
pub actions: Vec<AutoScoreAction>,
|
pub actions: Vec<AutoScoreAction>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub execution: AutoScoreExecutionConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filters: AutoScoreFilterConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -38,7 +45,19 @@ pub struct UpdateAutoScoreRule {
|
|||||||
#[serde(rename = "studentNames")]
|
#[serde(rename = "studentNames")]
|
||||||
pub student_names: Vec<String>,
|
pub student_names: Vec<String>,
|
||||||
pub triggers: Vec<AutoScoreTrigger>,
|
pub triggers: Vec<AutoScoreTrigger>,
|
||||||
|
#[serde(rename = "triggerTree", default)]
|
||||||
|
pub trigger_tree: Option<JsonValue>,
|
||||||
pub actions: Vec<AutoScoreAction>,
|
pub actions: Vec<AutoScoreAction>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub execution: AutoScoreExecutionConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filters: AutoScoreFilterConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RollbackBatchParams {
|
||||||
|
#[serde(rename = "batchId")]
|
||||||
|
pub batch_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -60,7 +79,10 @@ fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule {
|
|||||||
enabled: rule.enabled,
|
enabled: rule.enabled,
|
||||||
student_names: rule.student_names,
|
student_names: rule.student_names,
|
||||||
triggers: rule.triggers,
|
triggers: rule.triggers,
|
||||||
|
trigger_tree: rule.trigger_tree,
|
||||||
actions: rule.actions,
|
actions: rule.actions,
|
||||||
|
execution: rule.execution,
|
||||||
|
filters: rule.filters,
|
||||||
last_executed: None,
|
last_executed: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +94,10 @@ fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule {
|
|||||||
enabled: rule.enabled,
|
enabled: rule.enabled,
|
||||||
student_names: rule.student_names,
|
student_names: rule.student_names,
|
||||||
triggers: rule.triggers,
|
triggers: rule.triggers,
|
||||||
|
trigger_tree: rule.trigger_tree,
|
||||||
actions: rule.actions,
|
actions: rule.actions,
|
||||||
|
execution: rule.execution,
|
||||||
|
filters: rule.filters,
|
||||||
last_executed: None,
|
last_executed: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,3 +361,38 @@ pub async fn auto_score_sort_rules(
|
|||||||
|
|
||||||
Ok(IpcResponse::success(true))
|
Ok(IpcResponse::success(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auto_score_query_batches(
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Vec<AutoScoreExecutionBatch>>, String> {
|
||||||
|
{
|
||||||
|
let state_guard = state.read();
|
||||||
|
let mut permissions = state_guard.permissions.write();
|
||||||
|
if !check_admin_permission(&mut permissions, sender_id) {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let batches = query_execution_batches(state.inner()).await?;
|
||||||
|
Ok(IpcResponse::success(batches))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auto_score_rollback_batch(
|
||||||
|
params: RollbackBatchParams,
|
||||||
|
sender_id: Option<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<AutoScoreExecutionBatch>, String> {
|
||||||
|
{
|
||||||
|
let state_guard = state.read();
|
||||||
|
let mut permissions = state_guard.permissions.write();
|
||||||
|
if !check_admin_permission(&mut permissions, sender_id) {
|
||||||
|
return Ok(IpcResponse::error("Permission denied: admin required"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch = rollback_execution_batch(state.inner(), ¶ms.batch_id).await?;
|
||||||
|
Ok(IpcResponse::success(batch))
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ pub struct ScoreEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CreateScoreEvent {
|
pub struct CreateScoreEvent {
|
||||||
|
#[serde(alias = "studentName")]
|
||||||
pub student_name: String,
|
pub student_name: String,
|
||||||
|
#[serde(alias = "reasonContent")]
|
||||||
pub reason_content: String,
|
pub reason_content: String,
|
||||||
pub delta: i32,
|
pub delta: i32,
|
||||||
}
|
}
|
||||||
@@ -44,8 +46,10 @@ pub struct QueryEventParams {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct QueryByStudentParams {
|
pub struct QueryByStudentParams {
|
||||||
|
#[serde(alias = "studentName")]
|
||||||
pub student_name: String,
|
pub student_name: String,
|
||||||
pub limit: Option<i32>,
|
pub limit: Option<i32>,
|
||||||
|
#[serde(alias = "startTime")]
|
||||||
pub start_time: Option<String>,
|
pub start_time: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ use parking_lot::RwLock;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
use std::process::Command;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
settings::{
|
settings::{
|
||||||
@@ -161,20 +166,6 @@ pub async fn settings_set(
|
|||||||
Ok(IpcResponse::success(()))
|
Ok(IpcResponse::success(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fallback_font_families() -> Vec<String> {
|
|
||||||
vec![
|
|
||||||
"系统默认".to_string(),
|
|
||||||
"Microsoft YaHei UI".to_string(),
|
|
||||||
"Microsoft YaHei".to_string(),
|
|
||||||
"PingFang SC".to_string(),
|
|
||||||
"Noto Sans CJK SC".to_string(),
|
|
||||||
"Source Han Sans SC".to_string(),
|
|
||||||
"Segoe UI".to_string(),
|
|
||||||
"Arial".to_string(),
|
|
||||||
"Helvetica Neue".to_string(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_font_name(value: &str) -> String {
|
fn normalize_font_name(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -188,33 +179,20 @@ fn normalize_font_name(value: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn query_system_fonts() -> Vec<String> {
|
fn query_system_fonts() -> Vec<String> {
|
||||||
let output = Command::new("reg")
|
let mut families = BTreeSet::new();
|
||||||
.args([
|
|
||||||
"query",
|
|
||||||
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts",
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
let Ok(output) = output else {
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||||
|
let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts");
|
||||||
|
let Ok(key) = key else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
if !output.status.success() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut families = BTreeSet::new();
|
for (value_name, _) in key.enum_values().flatten() {
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let name = normalize_font_name(&value_name);
|
||||||
for line in stdout.lines() {
|
if name.is_empty() {
|
||||||
if !line.contains("REG_") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((left, _)) = line.split_once("REG_") else {
|
families.insert(name);
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let name = normalize_font_name(left);
|
|
||||||
if !name.is_empty() {
|
|
||||||
families.insert(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
families.into_iter().collect()
|
families.into_iter().collect()
|
||||||
@@ -276,19 +254,5 @@ fn query_system_fonts() -> Vec<String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, String> {
|
pub async fn settings_get_system_fonts() -> Result<IpcResponse<Vec<String>>, String> {
|
||||||
let mut fonts = query_system_fonts();
|
Ok(IpcResponse::success(query_system_fonts()))
|
||||||
if fonts.is_empty() {
|
|
||||||
fonts = fallback_font_families();
|
|
||||||
} else {
|
|
||||||
let mut merged = BTreeSet::new();
|
|
||||||
for item in fallback_font_families() {
|
|
||||||
merged.insert(item);
|
|
||||||
}
|
|
||||||
for item in fonts {
|
|
||||||
merged.insert(item);
|
|
||||||
}
|
|
||||||
fonts = merged.into_iter().collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(IpcResponse::success(fonts))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ pub fn run() {
|
|||||||
auto_score_toggle_rule,
|
auto_score_toggle_rule,
|
||||||
auto_score_get_status,
|
auto_score_get_status,
|
||||||
auto_score_sort_rules,
|
auto_score_sort_rules,
|
||||||
|
auto_score_query_batches,
|
||||||
|
auto_score_rollback_batch,
|
||||||
board_get_configs,
|
board_get_configs,
|
||||||
board_save_configs,
|
board_save_configs,
|
||||||
board_query_sql,
|
board_query_sql,
|
||||||
@@ -126,6 +128,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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ pub mod settings;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
pub use auth::AuthService;
|
pub use auth::AuthService;
|
||||||
pub use auto_score::{AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger};
|
pub use auto_score::{
|
||||||
|
query_execution_batches, rollback_execution_batch, AutoScoreAction, AutoScoreExecutionBatch,
|
||||||
|
AutoScoreExecutionConfig, AutoScoreFilterConfig, AutoScoreRule, AutoScoreService,
|
||||||
|
AutoScoreTrigger,
|
||||||
|
};
|
||||||
pub use data::DataService;
|
pub use data::DataService;
|
||||||
pub use logger::LoggerService;
|
pub use logger::LoggerService;
|
||||||
pub use permission::{PermissionLevel, PermissionService};
|
pub use permission::{PermissionLevel, PermissionService};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct SettingsSpec {
|
|||||||
pub themes_custom: JsonValue,
|
pub themes_custom: JsonValue,
|
||||||
pub auto_score_enabled: bool,
|
pub auto_score_enabled: bool,
|
||||||
pub auto_score_rules: JsonValue,
|
pub auto_score_rules: JsonValue,
|
||||||
|
pub auto_score_batches: JsonValue,
|
||||||
pub current_theme_id: String,
|
pub current_theme_id: String,
|
||||||
pub dashboards_config: JsonValue,
|
pub dashboards_config: JsonValue,
|
||||||
pub pg_connection_string: String,
|
pub pg_connection_string: String,
|
||||||
@@ -33,6 +34,7 @@ impl Default for SettingsSpec {
|
|||||||
themes_custom: JsonValue::Array(vec![]),
|
themes_custom: JsonValue::Array(vec![]),
|
||||||
auto_score_enabled: false,
|
auto_score_enabled: false,
|
||||||
auto_score_rules: JsonValue::Array(vec![]),
|
auto_score_rules: JsonValue::Array(vec![]),
|
||||||
|
auto_score_batches: JsonValue::Array(vec![]),
|
||||||
current_theme_id: "light-default".to_string(),
|
current_theme_id: "light-default".to_string(),
|
||||||
dashboards_config: JsonValue::Array(vec![]),
|
dashboards_config: JsonValue::Array(vec![]),
|
||||||
pg_connection_string: String::new(),
|
pg_connection_string: String::new(),
|
||||||
@@ -64,6 +66,7 @@ pub enum SettingsKey {
|
|||||||
ThemesCustom,
|
ThemesCustom,
|
||||||
AutoScoreEnabled,
|
AutoScoreEnabled,
|
||||||
AutoScoreRules,
|
AutoScoreRules,
|
||||||
|
AutoScoreBatches,
|
||||||
CurrentThemeId,
|
CurrentThemeId,
|
||||||
DashboardsConfig,
|
DashboardsConfig,
|
||||||
PgConnectionString,
|
PgConnectionString,
|
||||||
@@ -83,6 +86,7 @@ impl SettingsKey {
|
|||||||
SettingsKey::ThemesCustom => "themes_custom",
|
SettingsKey::ThemesCustom => "themes_custom",
|
||||||
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
SettingsKey::AutoScoreEnabled => "auto_score_enabled",
|
||||||
SettingsKey::AutoScoreRules => "auto_score_rules",
|
SettingsKey::AutoScoreRules => "auto_score_rules",
|
||||||
|
SettingsKey::AutoScoreBatches => "auto_score_batches",
|
||||||
SettingsKey::CurrentThemeId => "current_theme_id",
|
SettingsKey::CurrentThemeId => "current_theme_id",
|
||||||
SettingsKey::DashboardsConfig => "dashboards_config",
|
SettingsKey::DashboardsConfig => "dashboards_config",
|
||||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||||
@@ -102,6 +106,7 @@ impl SettingsKey {
|
|||||||
"themes_custom" => Some(SettingsKey::ThemesCustom),
|
"themes_custom" => Some(SettingsKey::ThemesCustom),
|
||||||
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
"auto_score_enabled" => Some(SettingsKey::AutoScoreEnabled),
|
||||||
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
"auto_score_rules" => Some(SettingsKey::AutoScoreRules),
|
||||||
|
"auto_score_batches" => Some(SettingsKey::AutoScoreBatches),
|
||||||
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
"current_theme_id" => Some(SettingsKey::CurrentThemeId),
|
||||||
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
||||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||||
@@ -378,6 +383,16 @@ impl SettingsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
defs.insert(
|
||||||
|
SettingsKey::AutoScoreBatches,
|
||||||
|
SettingDefinition {
|
||||||
|
kind: SettingValueKind::Json,
|
||||||
|
default_value: SettingsValue::Json(JsonValue::Array(vec![])),
|
||||||
|
write_permission: PermissionRequirement::Admin,
|
||||||
|
validate: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
defs.insert(
|
defs.insert(
|
||||||
SettingsKey::CurrentThemeId,
|
SettingsKey::CurrentThemeId,
|
||||||
SettingDefinition {
|
SettingDefinition {
|
||||||
@@ -586,6 +601,10 @@ impl SettingsService {
|
|||||||
SettingsValue::Json(j) => j,
|
SettingsValue::Json(j) => j,
|
||||||
_ => JsonValue::Array(vec![]),
|
_ => JsonValue::Array(vec![]),
|
||||||
},
|
},
|
||||||
|
auto_score_batches: match self.get_value(SettingsKey::AutoScoreBatches) {
|
||||||
|
SettingsValue::Json(j) => j,
|
||||||
|
_ => JsonValue::Array(vec![]),
|
||||||
|
},
|
||||||
current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) {
|
current_theme_id: match self.get_value(SettingsKey::CurrentThemeId) {
|
||||||
SettingsValue::String(s) => s,
|
SettingsValue::String(s) => s,
|
||||||
_ => "light-default".to_string(),
|
_ => "light-default".to_string(),
|
||||||
|
|||||||
+2
-13
@@ -33,26 +33,15 @@ import { OAuthLogin } from "./components/OAuth/OAuthLogin"
|
|||||||
import { OAuthCallback } from "./components/OAuth/OAuthCallback"
|
import { OAuthCallback } from "./components/OAuth/OAuthCallback"
|
||||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||||
|
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
||||||
|
|
||||||
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
||||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
||||||
0,
|
0,
|
||||||
4
|
4
|
||||||
)
|
)
|
||||||
const SYSTEM_FONT_STACK =
|
|
||||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
|
||||||
|
|
||||||
const resolveFontFamily = (fontValue?: string): string => {
|
|
||||||
if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK
|
|
||||||
if (fontValue.startsWith("system-")) {
|
|
||||||
const family = fontValue.slice("system-".length).trim()
|
|
||||||
if (family) return `"${family}", ${SYSTEM_FONT_STACK}`
|
|
||||||
}
|
|
||||||
return SYSTEM_FONT_STACK
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyGlobalFontFamily = (fontValue?: string) => {
|
const applyGlobalFontFamily = (fontValue?: string) => {
|
||||||
const fontFamily = resolveFontFamily(fontValue)
|
const fontFamily = resolveStoredFontFamily(fontValue)
|
||||||
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
|
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
|
||||||
document.body.style.fontFamily = fontFamily
|
document.body.style.fontFamily = fontFamily
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|||||||
() => [
|
() => [
|
||||||
{ value: "add_score", label: t("autoScore.actionAddScore") },
|
{ value: "add_score", label: t("autoScore.actionAddScore") },
|
||||||
{ value: "add_tag", label: t("autoScore.actionAddTag") },
|
{ value: "add_tag", label: t("autoScore.actionAddTag") },
|
||||||
|
{ value: "settle_score", label: t("autoScore.actionSettleScore") },
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
@@ -86,7 +87,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|||||||
onChange={(event: ActionEvent) =>
|
onChange={(event: ActionEvent) =>
|
||||||
updateAction(action.id, {
|
updateAction(action.id, {
|
||||||
event,
|
event,
|
||||||
value: event === "add_score" ? "1" : [],
|
value: event === "add_score" ? "1" : event === "add_tag" ? [] : "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -100,7 +101,7 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|||||||
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
|
updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : action.event === "add_tag" ? (
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
showSearch
|
showSearch
|
||||||
@@ -114,6 +115,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|||||||
options={mergedTagOptions}
|
options={mergedTagOptions}
|
||||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ minWidth: 260, color: "var(--ss-text-secondary)" }}>
|
||||||
|
{t("autoScore.actionSettleScoreHint")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
|
|||||||
@@ -24,17 +24,40 @@ export interface AutoScoreAction {
|
|||||||
value?: string | null
|
value?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoScoreExecutionConfig {
|
||||||
|
cooldownMinutes?: number | null
|
||||||
|
maxRunsPerDay?: number | null
|
||||||
|
maxScoreDeltaPerDay?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoScoreExecutionBatch {
|
||||||
|
id: string
|
||||||
|
ruleId: number
|
||||||
|
ruleName: string
|
||||||
|
runAt: string
|
||||||
|
affectedStudents: number
|
||||||
|
affectedStudentNames: string[]
|
||||||
|
createdEventIds: number[]
|
||||||
|
addedStudentTagIds: number[]
|
||||||
|
scoreDeltaTotal: number
|
||||||
|
settled: boolean
|
||||||
|
rolledBack: boolean
|
||||||
|
rollbackAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutoScoreRule {
|
export interface AutoScoreRule {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
studentNames: string[]
|
studentNames: string[]
|
||||||
triggers: AutoScoreTrigger[]
|
triggers: AutoScoreTrigger[]
|
||||||
|
triggerTree?: JsonGroup | null
|
||||||
actions: AutoScoreAction[]
|
actions: AutoScoreAction[]
|
||||||
|
execution?: AutoScoreExecutionConfig
|
||||||
lastExecuted?: string | null
|
lastExecuted?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActionEvent = "add_score" | "add_tag"
|
export type ActionEvent = "add_score" | "add_tag" | "settle_score"
|
||||||
|
|
||||||
export interface AutoScoreTagOption {
|
export interface AutoScoreTagOption {
|
||||||
label: string
|
label: string
|
||||||
@@ -51,9 +74,14 @@ 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_FIELD_SCORE = "student_score"
|
||||||
|
const TRIGGER_FIELD_SCORE_GT = "student_score_gt"
|
||||||
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"
|
||||||
|
const OP_GREATER = "greater"
|
||||||
|
const OP_LESS = "less"
|
||||||
const OP_MULTISELECT_CONTAINS = "multiselect_contains"
|
const OP_MULTISELECT_CONTAINS = "multiselect_contains"
|
||||||
|
|
||||||
const buildEmptyGroup = (): JsonGroup => ({
|
const buildEmptyGroup = (): JsonGroup => ({
|
||||||
@@ -146,11 +174,44 @@ 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],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.event === "student_score_gt" || trigger.event === "student_score_lt") {
|
||||||
|
const score = toFiniteNumber(trigger.value)
|
||||||
|
if (score === null) return null
|
||||||
|
return {
|
||||||
|
id: QbUtils.uuid(),
|
||||||
|
type: "rule",
|
||||||
|
properties: {
|
||||||
|
field: TRIGGER_FIELD_SCORE,
|
||||||
|
operator: trigger.event === "student_score_lt" ? OP_LESS : OP_GREATER,
|
||||||
|
value: [score],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
|
const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
|
||||||
const field = typeof rule.properties?.field === "string" ? rule.properties.field : ""
|
const field = typeof rule.properties?.field === "string" ? rule.properties.field : ""
|
||||||
|
const operator = typeof rule.properties?.operator === "string" ? rule.properties.operator : ""
|
||||||
const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined
|
const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined
|
||||||
|
|
||||||
if (field === TRIGGER_FIELD_INTERVAL) {
|
if (field === TRIGGER_FIELD_INTERVAL) {
|
||||||
@@ -175,6 +236,24 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === TRIGGER_FIELD_SCORE || field === TRIGGER_FIELD_SCORE_GT) {
|
||||||
|
const score = toFiniteNumber(value)
|
||||||
|
if (score === null) return null
|
||||||
|
return {
|
||||||
|
event: operator === OP_LESS ? "student_score_lt" : "student_score_gt",
|
||||||
|
value: String(score),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +276,35 @@ 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_GREATER]: {
|
||||||
|
...AntdConfig.operators[OP_GREATER],
|
||||||
|
label: ">",
|
||||||
|
labelForFormat: ">",
|
||||||
|
},
|
||||||
|
[OP_LESS]: {
|
||||||
|
...AntdConfig.operators[OP_LESS],
|
||||||
|
label: "<",
|
||||||
|
labelForFormat: "<",
|
||||||
|
},
|
||||||
|
[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 +358,22 @@ 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[TRIGGER_FIELD_SCORE]: {
|
||||||
|
label: t("autoScore.triggerStudentScore"),
|
||||||
|
type: "number",
|
||||||
|
defaultOperator: OP_GREATER,
|
||||||
|
operators: [OP_GREATER, OP_LESS],
|
||||||
|
valueSources: ["value"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
...AntdConfig.settings,
|
...AntdConfig.settings,
|
||||||
@@ -257,16 +381,21 @@ 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,
|
||||||
canRegroup: true,
|
canRegroup: true,
|
||||||
|
setOpOnChangeField: ["default"],
|
||||||
},
|
},
|
||||||
}) as Config
|
}) as Config
|
||||||
|
|
||||||
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[]
|
||||||
@@ -286,6 +415,77 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc
|
|||||||
return collectTriggersFromItems(jsonTree.children1)
|
return collectTriggersFromItems(jsonTree.children1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const queryTreeToJson = (tree: ImmutableTree, config: Config): JsonGroup | null => {
|
||||||
|
const checkedTree = QbUtils.checkTree(tree, config)
|
||||||
|
const jsonTree = QbUtils.getTree(checkedTree, false, true)
|
||||||
|
if (!jsonTree || jsonTree.type !== "group") return null
|
||||||
|
return jsonTree as JsonGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateTriggerTreeNode = (
|
||||||
|
node: JsonItem,
|
||||||
|
fallbackTriggers: AutoScoreTrigger[],
|
||||||
|
fallbackIndex: { current: number }
|
||||||
|
): JsonItem | null => {
|
||||||
|
if (node.type === "group") {
|
||||||
|
const children = Array.isArray(node.children1)
|
||||||
|
? node.children1
|
||||||
|
.map((child) => hydrateTriggerTreeNode(child, fallbackTriggers, fallbackIndex))
|
||||||
|
.filter((child): child is JsonItem => Boolean(child))
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children1: children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type !== "rule") {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTrigger = fallbackTriggers[fallbackIndex.current]
|
||||||
|
fallbackIndex.current += 1
|
||||||
|
const parsed = triggerFromRule(node)
|
||||||
|
if (parsed) {
|
||||||
|
const normalizedRule = ruleFromTrigger(parsed)
|
||||||
|
if (!normalizedRule) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
properties: normalizedRule.properties,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fallbackTrigger) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRule = ruleFromTrigger(fallbackTrigger)
|
||||||
|
if (!fallbackRule) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
properties: fallbackRule.properties,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerTreeJsonToQueryTree = (
|
||||||
|
config: Config,
|
||||||
|
triggerTree?: JsonGroup | null,
|
||||||
|
fallbackTriggers: AutoScoreTrigger[] = []
|
||||||
|
): ImmutableTree => {
|
||||||
|
if (triggerTree && triggerTree.type === "group") {
|
||||||
|
const hydratedTree = hydrateTriggerTreeNode(triggerTree, fallbackTriggers, { current: 0 })
|
||||||
|
if (hydratedTree && hydratedTree.type === "group") {
|
||||||
|
return QbUtils.checkTree(QbUtils.loadTree(hydratedTree), config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return triggersToQueryTree(config, fallbackTriggers)
|
||||||
|
}
|
||||||
|
|
||||||
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
||||||
const conjunction =
|
const conjunction =
|
||||||
typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
||||||
@@ -319,7 +519,7 @@ export const createDefaultActionDraft = (): ActionDraft => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isActionEvent = (value: unknown): value is ActionEvent =>
|
const isActionEvent = (value: unknown): value is ActionEvent =>
|
||||||
value === "add_score" || value === "add_tag"
|
value === "add_score" || value === "add_tag" || value === "settle_score"
|
||||||
|
|
||||||
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
|
export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined): ActionDraft[] => {
|
||||||
if (!Array.isArray(drafts) || drafts.length === 0) {
|
if (!Array.isArray(drafts) || drafts.length === 0) {
|
||||||
@@ -336,7 +536,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
|||||||
value:
|
value:
|
||||||
draft.event === "add_tag"
|
draft.event === "add_tag"
|
||||||
? parseTagValues(draft.value)
|
? parseTagValues(draft.value)
|
||||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
: draft.event === "settle_score"
|
||||||
|
? ""
|
||||||
|
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||||
} satisfies ActionDraft
|
} satisfies ActionDraft
|
||||||
})
|
})
|
||||||
.filter((item): item is ActionDraft => Boolean(item))
|
.filter((item): item is ActionDraft => Boolean(item))
|
||||||
@@ -347,12 +549,18 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
|||||||
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||||
const mapped = actions
|
const mapped = actions
|
||||||
.map((action) => {
|
.map((action) => {
|
||||||
if (action.event !== "add_score" && action.event !== "add_tag") return null
|
if (action.event !== "add_score" && action.event !== "add_tag" && action.event !== "settle_score") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: QbUtils.uuid(),
|
id: QbUtils.uuid(),
|
||||||
event: action.event,
|
event: action.event,
|
||||||
value:
|
value:
|
||||||
action.event === "add_tag" ? parseTagValues(action.value) : toStringValue(action.value),
|
action.event === "add_tag"
|
||||||
|
? parseTagValues(action.value)
|
||||||
|
: action.event === "settle_score"
|
||||||
|
? ""
|
||||||
|
: toStringValue(action.value),
|
||||||
} satisfies ActionDraft
|
} satisfies ActionDraft
|
||||||
})
|
})
|
||||||
.filter((item): item is ActionDraft => Boolean(item))
|
.filter((item): item is ActionDraft => Boolean(item))
|
||||||
@@ -379,6 +587,11 @@ export const actionDraftsToPayload = (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (draft.event === "settle_score") {
|
||||||
|
actions.push({ event: draft.event })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
const serializedTagValue = stringifyTagValues(parseTagValues(draft.value))
|
||||||
if (!serializedTagValue) {
|
if (!serializedTagValue) {
|
||||||
return { actions: [], error: "tag_required" }
|
return { actions: [], error: "tag_required" }
|
||||||
|
|||||||
@@ -1,44 +1,87 @@
|
|||||||
export type IntervalUnit = "minute" | "day" | "month"
|
export type IntervalUnit = "minute" | "day" | "month"
|
||||||
|
|
||||||
export interface IntervalValue {
|
export interface IntervalValue {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyIntervalValue {
|
||||||
amount: number
|
amount: number
|
||||||
unit: IntervalUnit
|
unit: IntervalUnit
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
const normalizeNonNegativeInteger = (value: unknown): number | null => {
|
||||||
|
|
||||||
const normalizePositiveInteger = (value: unknown): number | null => {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
const normalized = Math.floor(value)
|
const normalized = Math.floor(value)
|
||||||
return normalized > 0 ? normalized : null
|
return normalized >= 0 ? normalized : null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
if (typeof value === "string" && value.trim()) {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
const normalized = Math.floor(parsed)
|
const normalized = Math.floor(parsed)
|
||||||
return normalized > 0 ? normalized : null
|
return normalized >= 0 ? normalized : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizePositiveInteger = (value: unknown): number | null => {
|
||||||
|
const normalized = normalizeNonNegativeInteger(value)
|
||||||
|
if (normalized === null || normalized <= 0) return null
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
const isIntervalUnit = (value: unknown): value is IntervalUnit =>
|
const isIntervalUnit = (value: unknown): value is IntervalUnit =>
|
||||||
value === "minute" || value === "day" || value === "month"
|
value === "minute" || value === "day" || value === "month"
|
||||||
|
|
||||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
export const getDefaultIntervalValue = (): IntervalValue => ({
|
||||||
amount: 1,
|
days: 1,
|
||||||
unit: DEFAULT_INTERVAL_UNIT,
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fromTotalMinutes = (totalMinutes: number): IntervalValue => {
|
||||||
|
const normalized = Math.max(0, Math.floor(totalMinutes))
|
||||||
|
const days = Math.floor(normalized / (24 * 60))
|
||||||
|
const hours = Math.floor((normalized % (24 * 60)) / 60)
|
||||||
|
const minutes = normalized % 60
|
||||||
|
return { days, hours, minutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toTotalMinutes = (value: IntervalValue): number =>
|
||||||
|
value.days * 24 * 60 + value.hours * 60 + value.minutes
|
||||||
|
|
||||||
|
const normalizeCompositeValue = (value: Partial<IntervalValue>): IntervalValue | null => {
|
||||||
|
const days = normalizeNonNegativeInteger(value.days) ?? 0
|
||||||
|
const hours = normalizeNonNegativeInteger(value.hours) ?? 0
|
||||||
|
const minutes = normalizeNonNegativeInteger(value.minutes) ?? 0
|
||||||
|
|
||||||
|
const totalMinutes = days * 24 * 60 + hours * 60 + minutes
|
||||||
|
if (totalMinutes <= 0) return null
|
||||||
|
return fromTotalMinutes(totalMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyToComposite = (value: Partial<LegacyIntervalValue>): IntervalValue | null => {
|
||||||
|
const amount = normalizePositiveInteger(value.amount)
|
||||||
|
if (!amount || !isIntervalUnit(value.unit)) return null
|
||||||
|
|
||||||
|
if (value.unit === "minute") return fromTotalMinutes(amount)
|
||||||
|
if (value.unit === "day") return fromTotalMinutes(amount * 24 * 60)
|
||||||
|
|
||||||
|
// Legacy month values are approximated as 30 days in the editor.
|
||||||
|
return fromTotalMinutes(amount * 30 * 24 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
const amount = normalizePositiveInteger((value as IntervalValue).amount)
|
const composite = normalizeCompositeValue(value as Partial<IntervalValue>)
|
||||||
const unit = (value as IntervalValue).unit
|
if (composite) return composite
|
||||||
if (amount && isIntervalUnit(unit)) {
|
|
||||||
return { amount, unit }
|
const legacy = legacyToComposite(value as Partial<LegacyIntervalValue>)
|
||||||
}
|
if (legacy) return legacy
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = value === null || value === undefined ? "" : String(value).trim()
|
const text = value === null || value === undefined ? "" : String(value).trim()
|
||||||
@@ -46,15 +89,16 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null
|
|||||||
|
|
||||||
const legacyMinutes = normalizePositiveInteger(text)
|
const legacyMinutes = normalizePositiveInteger(text)
|
||||||
if (legacyMinutes !== null && !text.startsWith("{")) {
|
if (legacyMinutes !== null && !text.startsWith("{")) {
|
||||||
return { amount: legacyMinutes, unit: "minute" }
|
return fromTotalMinutes(legacyMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as Partial<IntervalValue>
|
const parsed = JSON.parse(text) as Partial<IntervalValue & LegacyIntervalValue>
|
||||||
const amount = normalizePositiveInteger(parsed.amount)
|
const composite = normalizeCompositeValue(parsed)
|
||||||
if (amount && isIntervalUnit(parsed.unit)) {
|
if (composite) return composite
|
||||||
return { amount, unit: parsed.unit }
|
|
||||||
}
|
const legacy = legacyToComposite(parsed)
|
||||||
|
if (legacy) return legacy
|
||||||
} catch {
|
} catch {
|
||||||
void 0
|
void 0
|
||||||
}
|
}
|
||||||
@@ -63,17 +107,15 @@ export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => {
|
export const stringifyIntervalTriggerValue = (value: IntervalValue): string | null => {
|
||||||
const amount = normalizePositiveInteger(value.amount)
|
const normalized = normalizeCompositeValue(value)
|
||||||
if (!amount || !isIntervalUnit(value.unit)) {
|
if (!normalized) return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.unit === "minute") {
|
const totalMinutes = toTotalMinutes(normalized)
|
||||||
return String(amount)
|
if (totalMinutes <= 0) return null
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
amount,
|
days: normalized.days,
|
||||||
unit: value.unit,
|
hours: normalized.hours,
|
||||||
|
minutes: normalized.minutes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +1,87 @@
|
|||||||
import React, { useMemo } from "react"
|
import React from "react"
|
||||||
import { InputNumber, Select } from "antd"
|
import { InputNumber } from "antd"
|
||||||
import type { WidgetProps } from "@react-awesome-query-builder/antd"
|
import type { WidgetProps } from "@react-awesome-query-builder/antd"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import {
|
||||||
|
getDefaultIntervalValue,
|
||||||
|
parseIntervalTriggerValue,
|
||||||
|
stringifyIntervalTriggerValue,
|
||||||
|
type IntervalValue,
|
||||||
|
} from "./IntervalValueCodec"
|
||||||
|
|
||||||
export type IntervalUnit = "month" | "day" | "minute"
|
export { parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueCodec"
|
||||||
|
export type { IntervalValue, IntervalUnit } from "./IntervalValueCodec"
|
||||||
|
|
||||||
export interface IntervalValue {
|
const buildNextIntervalValue = (
|
||||||
amount: number
|
currentValue: IntervalValue | null,
|
||||||
unit: IntervalUnit
|
patch: Partial<IntervalValue>
|
||||||
}
|
): IntervalValue => {
|
||||||
|
const base = currentValue ?? getDefaultIntervalValue()
|
||||||
export const DEFAULT_INTERVAL_UNIT: IntervalUnit = "day"
|
return {
|
||||||
|
days: patch.days ?? base.days,
|
||||||
export const getDefaultIntervalValue = (): IntervalValue => ({
|
hours: patch.hours ?? base.hours,
|
||||||
amount: 1,
|
minutes: patch.minutes ?? base.minutes,
|
||||||
unit: DEFAULT_INTERVAL_UNIT,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const parseIntervalTriggerValue = (value: unknown): IntervalValue | null => {
|
|
||||||
if (value === null || value === undefined) return null
|
|
||||||
|
|
||||||
const str = String(value).trim()
|
|
||||||
if (!str) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(str)
|
|
||||||
if (
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
parsed !== null &&
|
|
||||||
typeof parsed.amount === "number" &&
|
|
||||||
typeof parsed.unit === "string"
|
|
||||||
) {
|
|
||||||
const validUnits: IntervalUnit[] = ["month", "day", "minute"]
|
|
||||||
if (validUnits.includes(parsed.unit as IntervalUnit)) {
|
|
||||||
return {
|
|
||||||
amount: Math.max(1, Math.floor(parsed.amount)),
|
|
||||||
unit: parsed.unit as IntervalUnit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
void 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stringifyIntervalTriggerValue = (value: IntervalValue): string => {
|
const getDisplayValue = (value: number | undefined, fallback = 0) =>
|
||||||
return JSON.stringify({
|
typeof value === "number" && Number.isFinite(value) ? value : fallback
|
||||||
amount: value.amount,
|
|
||||||
unit: value.unit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntervalValueWidget: React.FC<WidgetProps> = ({
|
export const IntervalValueWidget: React.FC<WidgetProps> = ({
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
readonly,
|
readonly,
|
||||||
placeholder,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const parsedValue = parseIntervalTriggerValue(value)
|
const parsedValue = parseIntervalTriggerValue(value)
|
||||||
|
|
||||||
const unitOptions = useMemo(
|
const updateValue = (patch: Partial<IntervalValue>) => {
|
||||||
() => [
|
const nextValue = buildNextIntervalValue(parsedValue, patch)
|
||||||
{ label: t("autoScore.intervalUnitMonth"), value: "month" },
|
const serialized = stringifyIntervalTriggerValue(nextValue)
|
||||||
{ label: t("autoScore.intervalUnitDay"), value: "day" },
|
setValue(serialized)
|
||||||
{ label: t("autoScore.intervalUnitMinute"), value: "minute" },
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleAmountChange = (nextAmount: number | null) => {
|
|
||||||
if (nextAmount === null) {
|
|
||||||
setValue(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedValue = stringifyIntervalTriggerValue({
|
|
||||||
amount: nextAmount,
|
|
||||||
unit: parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT,
|
|
||||||
})
|
|
||||||
setValue(serializedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUnitChange = (nextUnit: IntervalUnit) => {
|
|
||||||
const baseValue = parsedValue ?? getDefaultIntervalValue()
|
|
||||||
const serializedValue = stringifyIntervalTriggerValue({
|
|
||||||
amount: baseValue.amount,
|
|
||||||
unit: nextUnit,
|
|
||||||
})
|
|
||||||
setValue(serializedValue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, minWidth: 280 }}>
|
<div style={{ display: "flex", gap: 12, minWidth: 360 }}>
|
||||||
<InputNumber
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||||
min={1}
|
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||||
precision={0}
|
{t("autoScore.intervalPartDay")}
|
||||||
disabled={readonly}
|
</span>
|
||||||
value={parsedValue?.amount}
|
<InputNumber
|
||||||
placeholder={placeholder || t("autoScore.intervalAmountPlaceholder")}
|
min={0}
|
||||||
onChange={handleAmountChange}
|
precision={0}
|
||||||
style={{ width: 130 }}
|
disabled={readonly}
|
||||||
/>
|
value={getDisplayValue(parsedValue?.days)}
|
||||||
<Select
|
onChange={(next) => updateValue({ days: Math.max(0, Number(next ?? 0)) })}
|
||||||
disabled={readonly}
|
style={{ width: "100%" }}
|
||||||
value={parsedValue?.unit ?? DEFAULT_INTERVAL_UNIT}
|
/>
|
||||||
options={unitOptions}
|
</div>
|
||||||
onChange={handleUnitChange}
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||||
style={{ width: 140 }}
|
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||||
/>
|
{t("autoScore.intervalPartHour")}
|
||||||
|
</span>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
disabled={readonly}
|
||||||
|
value={getDisplayValue(parsedValue?.hours)}
|
||||||
|
onChange={(next) => updateValue({ hours: Math.max(0, Number(next ?? 0)) })}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, minWidth: 96 }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||||
|
{t("autoScore.intervalPartMinute")}
|
||||||
|
</span>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
disabled={readonly}
|
||||||
|
value={getDisplayValue(parsedValue?.minutes)}
|
||||||
|
onChange={(next) => updateValue({ minutes: Math.max(0, Number(next ?? 0)) })}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from "react"
|
import React, { useMemo } from "react"
|
||||||
import { Card } from "antd"
|
import { Alert, Card } from "antd"
|
||||||
import {
|
import {
|
||||||
Builder,
|
Builder,
|
||||||
Query,
|
Query,
|
||||||
@@ -46,6 +46,12 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
|
|||||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||||
title={t("autoScore.whenTriggered")}
|
title={t("autoScore.whenTriggered")}
|
||||||
>
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: "16px" }}
|
||||||
|
title={t("autoScore.triggerScheduleHint")}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="query-builder-container"
|
className="query-builder-container"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
message,
|
message,
|
||||||
} from "antd"
|
} from "antd"
|
||||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||||
@@ -19,6 +22,7 @@ import type { ColumnsType } from "antd/es/table"
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { fetchAllTags } from "./TagEditorDialog"
|
import { fetchAllTags } from "./TagEditorDialog"
|
||||||
import { ActionEditor } from "./AutoScore/ActionEditor"
|
import { ActionEditor } from "./AutoScore/ActionEditor"
|
||||||
|
import { parseIntervalTriggerValue } from "./AutoScore/IntervalValueCodec"
|
||||||
import { TriggerRuleBuilder } from "./AutoScore/TriggerRuleBuilder"
|
import { TriggerRuleBuilder } from "./AutoScore/TriggerRuleBuilder"
|
||||||
import {
|
import {
|
||||||
actionDraftsToPayload,
|
actionDraftsToPayload,
|
||||||
@@ -26,11 +30,14 @@ import {
|
|||||||
createDefaultActionDraft,
|
createDefaultActionDraft,
|
||||||
createEmptyTriggerTree,
|
createEmptyTriggerTree,
|
||||||
createTriggerQueryConfig,
|
createTriggerQueryConfig,
|
||||||
hasUnsupportedTriggerLogic,
|
normalizeTriggerTree,
|
||||||
|
queryTreeToJson,
|
||||||
normalizeActionDrafts,
|
normalizeActionDrafts,
|
||||||
queryTreeToTriggers,
|
queryTreeToTriggers,
|
||||||
triggersToQueryTree,
|
triggerTreeJsonToQueryTree,
|
||||||
type ActionDraft,
|
type ActionDraft,
|
||||||
|
type AutoScoreExecutionBatch,
|
||||||
|
type AutoScoreExecutionConfig,
|
||||||
type AutoScoreRule,
|
type AutoScoreRule,
|
||||||
type AutoScoreTagOption,
|
type AutoScoreTagOption,
|
||||||
} from "./AutoScore/AutoScoreUtils"
|
} from "./AutoScore/AutoScoreUtils"
|
||||||
@@ -48,12 +55,15 @@ interface TagItem {
|
|||||||
interface RuleFormValues {
|
interface RuleFormValues {
|
||||||
name?: string
|
name?: string
|
||||||
studentNames?: string[]
|
studentNames?: string[]
|
||||||
|
execution?: AutoScoreExecutionConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoScoreManagerProps {
|
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>()
|
||||||
@@ -79,11 +89,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
|
const [actionDrafts, setActionDrafts] = useState<ActionDraft[]>([createDefaultActionDraft()])
|
||||||
const [students, setStudents] = useState<StudentItem[]>([])
|
const [students, setStudents] = useState<StudentItem[]>([])
|
||||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||||
|
const [batches, setBatches] = useState<AutoScoreExecutionBatch[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [rollingBackBatchId, setRollingBackBatchId] = useState<string | null>(null)
|
||||||
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
|
const [editingRuleId, setEditingRuleId] = useState<number | null>(null)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [batchCurrentPage, setBatchCurrentPage] = useState(1)
|
||||||
|
const [batchPageSize, setBatchPageSize] = useState(10)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
|
const maxPage = Math.max(1, Math.ceil(rules.length / pageSize))
|
||||||
@@ -92,9 +106,20 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
}
|
}
|
||||||
}, [currentPage, pageSize, rules.length])
|
}, [currentPage, pageSize, rules.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(batches.length / batchPageSize))
|
||||||
|
if (batchCurrentPage > maxPage) {
|
||||||
|
setBatchCurrentPage(maxPage)
|
||||||
|
}
|
||||||
|
}, [batchCurrentPage, batchPageSize, batches.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
|
||||||
|
}, [triggerConfig])
|
||||||
|
|
||||||
const resetEditor = () => {
|
const resetEditor = () => {
|
||||||
setEditingRuleId(null)
|
setEditingRuleId(null)
|
||||||
form.setFieldsValue({ name: "", studentNames: [] })
|
form.setFieldsValue({ name: "", studentNames: [], execution: {} })
|
||||||
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
setTriggerTree(createEmptyTriggerTree(triggerConfig))
|
||||||
setActionDrafts([createDefaultActionDraft()])
|
setActionDrafts([createDefaultActionDraft()])
|
||||||
}
|
}
|
||||||
@@ -149,11 +174,25 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchBatches = async () => {
|
||||||
|
const api = (window as any).api
|
||||||
|
if (!api || !canEdit) return
|
||||||
|
try {
|
||||||
|
const res = await api.autoScoreQueryBatches()
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
setBatches(res.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
void 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canEdit) return
|
if (!canEdit) return
|
||||||
fetchTags().catch(() => void 0)
|
fetchTags().catch(() => void 0)
|
||||||
fetchStudents().catch(() => void 0)
|
fetchStudents().catch(() => void 0)
|
||||||
fetchRules().catch(() => void 0)
|
fetchRules().catch(() => void 0)
|
||||||
|
fetchBatches().catch(() => void 0)
|
||||||
}, [canEdit])
|
}, [canEdit])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -167,22 +206,23 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
const name = String(values.name || "").trim()
|
const name = String(values.name || "").trim()
|
||||||
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
|
const studentNames = Array.isArray(values.studentNames) ? values.studentNames : []
|
||||||
|
const execution = values.execution || {}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
messageApi.warning(t("autoScore.nameRequired"))
|
messageApi.warning(t("autoScore.nameRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUnsupportedTriggerLogic(triggerTree, triggerConfig)) {
|
|
||||||
messageApi.warning(t("autoScore.unsupportedLogic"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggers = queryTreeToTriggers(triggerTree, triggerConfig)
|
const triggers = queryTreeToTriggers(triggerTree, triggerConfig)
|
||||||
|
const triggerTreeJson = queryTreeToJson(triggerTree, triggerConfig)
|
||||||
if (triggers.length === 0) {
|
if (triggers.length === 0) {
|
||||||
messageApi.warning(t("autoScore.triggerRequired"))
|
messageApi.warning(t("autoScore.triggerRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!triggers.some((trigger) => trigger.event === "interval_time_passed")) {
|
||||||
|
messageApi.warning(t("autoScore.intervalTriggerRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedActionDrafts = normalizeActionDrafts(actionDrafts)
|
const normalizedActionDrafts = normalizeActionDrafts(actionDrafts)
|
||||||
const actionPayload = actionDraftsToPayload(normalizedActionDrafts)
|
const actionPayload = actionDraftsToPayload(normalizedActionDrafts)
|
||||||
@@ -213,7 +253,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
studentNames,
|
studentNames,
|
||||||
triggers,
|
triggers,
|
||||||
|
triggerTree: triggerTreeJson,
|
||||||
actions: actionPayload.actions,
|
actions: actionPayload.actions,
|
||||||
|
execution,
|
||||||
}
|
}
|
||||||
const res =
|
const res =
|
||||||
editingRuleId === null
|
editingRuleId === null
|
||||||
@@ -226,6 +268,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
)
|
)
|
||||||
resetEditor()
|
resetEditor()
|
||||||
await fetchRules()
|
await fetchRules()
|
||||||
|
await fetchBatches()
|
||||||
emitDataUpdated()
|
emitDataUpdated()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(
|
messageApi.error(
|
||||||
@@ -247,8 +290,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: rule.name,
|
name: rule.name,
|
||||||
studentNames: rule.studentNames || [],
|
studentNames: rule.studentNames || [],
|
||||||
|
execution: rule.execution || {},
|
||||||
})
|
})
|
||||||
setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || []))
|
setTriggerTree(triggerTreeJsonToQueryTree(triggerConfig, rule.triggerTree, rule.triggers || []))
|
||||||
setActionDrafts(actionsToDrafts(rule.actions || []))
|
setActionDrafts(actionsToDrafts(rule.actions || []))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,11 +306,13 @@ 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()
|
||||||
}
|
}
|
||||||
await fetchRules()
|
await fetchRules()
|
||||||
|
await fetchBatches()
|
||||||
emitDataUpdated()
|
emitDataUpdated()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
||||||
@@ -295,6 +341,40 @@ 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,
|
||||||
|
triggerTree: rule.triggerTree ?? null,
|
||||||
|
actions: rule.actions,
|
||||||
|
execution: rule.execution || {},
|
||||||
|
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)
|
||||||
@@ -302,6 +382,69 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTriggerSummary = (trigger: AutoScoreRule["triggers"][number]) => {
|
||||||
|
if (trigger.event === "interval_time_passed") {
|
||||||
|
const intervalValue = parseIntervalTriggerValue(trigger.value)
|
||||||
|
if (!intervalValue) {
|
||||||
|
return `${t("autoScore.triggerIntervalTime")}: -`
|
||||||
|
}
|
||||||
|
return `${t("autoScore.triggerIntervalTime")}: ${intervalValue.days}${t(
|
||||||
|
"autoScore.intervalPartDay"
|
||||||
|
)} ${intervalValue.hours}${t("autoScore.intervalPartHour")} ${intervalValue.minutes}${t(
|
||||||
|
"autoScore.intervalPartMinute"
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
if (trigger.event === "student_has_tag") {
|
||||||
|
return `${t("autoScore.triggerStudentTag")}: ${String(trigger.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
trigger.event === "query_sql" ||
|
||||||
|
trigger.event === "student_query_sql" ||
|
||||||
|
trigger.event === "student_sql"
|
||||||
|
) {
|
||||||
|
return `${t("autoScore.triggerStudentSql")}: ${String(trigger.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
return `${trigger.event}: ${String(trigger.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatActionSummary = (action: AutoScoreRule["actions"][number]) => {
|
||||||
|
if (action.event === "add_score") {
|
||||||
|
return `${t("autoScore.actionAddScore")}: ${String(action.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
if (action.event === "add_tag") {
|
||||||
|
return `${t("autoScore.actionAddTag")}: ${String(action.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
if (action.event === "settle_score") {
|
||||||
|
return `${t("autoScore.actionSettleScore")}: ${t("autoScore.actionSettleScoreHint")}`
|
||||||
|
}
|
||||||
|
return `${action.event}: ${String(action.value || "").trim() || "-"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRollbackBatch = async (batchId: string) => {
|
||||||
|
const api = (window as any).api
|
||||||
|
if (!api || !canEdit) return
|
||||||
|
Modal.confirm({
|
||||||
|
title: t("autoScore.batchRollbackConfirm"),
|
||||||
|
onOk: async () => {
|
||||||
|
setRollingBackBatchId(batchId)
|
||||||
|
try {
|
||||||
|
const res = await api.autoScoreRollbackBatch({ batchId })
|
||||||
|
if (res.success) {
|
||||||
|
messageApi.success(t("autoScore.batchRollbackSuccess"))
|
||||||
|
await fetchBatches()
|
||||||
|
emitDataUpdated()
|
||||||
|
} else {
|
||||||
|
messageApi.error(res.message || t("autoScore.batchRollbackFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t("autoScore.batchRollbackFailed"))
|
||||||
|
} finally {
|
||||||
|
setRollingBackBatchId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AutoScoreRule> = [
|
const columns: ColumnsType<AutoScoreRule> = [
|
||||||
{
|
{
|
||||||
title: t("autoScore.name"),
|
title: t("autoScore.name"),
|
||||||
@@ -330,7 +473,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.studentNames.length > 0 ? (
|
row.studentNames.length > 0 ? (
|
||||||
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
<Tooltip title={row.studentNames.join("、")}>
|
||||||
|
<Tag>{t("autoScore.studentCount", { count: row.studentNames.length })}</Tag>
|
||||||
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tag>{t("autoScore.allStudents")}</Tag>
|
<Tag>{t("autoScore.allStudents")}</Tag>
|
||||||
),
|
),
|
||||||
@@ -339,13 +484,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
title: t("autoScore.triggers"),
|
title: t("autoScore.triggers"),
|
||||||
key: "triggers",
|
key: "triggers",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, row) => <Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>,
|
render: (_, row) => (
|
||||||
|
<Tooltip title={row.triggers.map(formatTriggerSummary).join("\n") || "-"}>
|
||||||
|
<Tag>{t("autoScore.triggerCount", { count: row.triggers.length })}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("autoScore.actions"),
|
title: t("autoScore.actions"),
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, row) => <Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>,
|
render: (_, row) => (
|
||||||
|
<Tooltip title={row.actions.map(formatActionSummary).join("\n") || "-"}>
|
||||||
|
<Tag>{t("autoScore.actionCount", { count: row.actions.length })}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("autoScore.lastExecuted"),
|
title: t("autoScore.lastExecuted"),
|
||||||
@@ -358,12 +511,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={() => {
|
||||||
@@ -379,7 +535,68 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const batchColumns: ColumnsType<AutoScoreExecutionBatch> = [
|
||||||
|
{
|
||||||
|
title: t("autoScore.batchId"),
|
||||||
|
dataIndex: "id",
|
||||||
|
key: "id",
|
||||||
|
ellipsis: true,
|
||||||
|
width: 220,
|
||||||
|
render: (value: string) => value.slice(0, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("autoScore.name"),
|
||||||
|
dataIndex: "ruleName",
|
||||||
|
key: "ruleName",
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("autoScore.lastExecuted"),
|
||||||
|
dataIndex: "runAt",
|
||||||
|
key: "runAt",
|
||||||
|
width: 180,
|
||||||
|
render: (value: string) => formatLastExecuted(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("autoScore.applicableStudents"),
|
||||||
|
dataIndex: "affectedStudents",
|
||||||
|
key: "affectedStudents",
|
||||||
|
width: 100,
|
||||||
|
render: (value: number) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("autoScore.actionAddScore"),
|
||||||
|
dataIndex: "scoreDeltaTotal",
|
||||||
|
key: "scoreDeltaTotal",
|
||||||
|
width: 120,
|
||||||
|
render: (value: number) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.operation"),
|
||||||
|
key: "operation",
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.rolledBack ? (
|
||||||
|
<Tag>{t("autoScore.batchRolledBack")}</Tag>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
loading={rollingBackBatchId === row.id}
|
||||||
|
disabled={row.settled || !canEdit}
|
||||||
|
onClick={() => handleRollbackBatch(row.id).catch(() => void 0)}
|
||||||
|
>
|
||||||
|
{t("autoScore.batchRollback")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||||
|
const pagedBatches = batches.slice(
|
||||||
|
(batchCurrentPage - 1) * batchPageSize,
|
||||||
|
batchCurrentPage * batchPageSize
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "24px" }}>
|
<div style={{ padding: "24px" }}>
|
||||||
@@ -391,7 +608,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 +620,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
|
||||||
@@ -418,6 +635,21 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px" }}>
|
||||||
|
<Form.Item label={t("autoScore.cooldownMinutes")} name={["execution", "cooldownMinutes"]}>
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t("autoScore.maxRunsPerDay")} name={["execution", "maxRunsPerDay"]}>
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("autoScore.maxScoreDeltaPerDay")}
|
||||||
|
name={["execution", "maxScoreDeltaPerDay"]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -474,6 +706,33 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={t("autoScore.batchLogs")}
|
||||||
|
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={batchColumns}
|
||||||
|
dataSource={pagedBatches}
|
||||||
|
pagination={false}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 860 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16, textAlign: "right" }}>
|
||||||
|
<Pagination
|
||||||
|
current={batchCurrentPage}
|
||||||
|
pageSize={batchPageSize}
|
||||||
|
total={batches.length}
|
||||||
|
showSizeChanger
|
||||||
|
showTotal={(total) => t("common.total", { count: total })}
|
||||||
|
onChange={(page, size) => {
|
||||||
|
setBatchCurrentPage(page)
|
||||||
|
setBatchPageSize(size)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,21 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [students, setStudents] = useState<student[]>([])
|
const [students, setStudents] = useState<student[]>([])
|
||||||
const [reasons, setReasons] = useState<reason[]>([])
|
const [reasons, setReasons] = useState<reason[]>([])
|
||||||
const [events, setEvents] = useState<scoreEvent[]>([])
|
const [events, setEvents] = useState<scoreEvent[]>([])
|
||||||
|
const [recordCurrentPage, setRecordCurrentPage] = useState(1)
|
||||||
|
const [recordPageSize, setRecordPageSize] = useState(10)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [submitLoading, setSubmitLoading] = useState(false)
|
const [submitLoading, setSubmitLoading] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const selectedStudentNames = Form.useWatch("student_name", form) as string[] | undefined
|
const selectedStudentNames = Form.useWatch("student_name", form) as string[] | undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(events.length / recordPageSize))
|
||||||
|
if (recordCurrentPage > maxPage) {
|
||||||
|
setRecordCurrentPage(maxPage)
|
||||||
|
}
|
||||||
|
}, [events.length, recordCurrentPage, recordPageSize])
|
||||||
|
|
||||||
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
|
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
|
||||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||||
}
|
}
|
||||||
@@ -102,7 +111,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const [stuRes, reaRes, eveRes] = await Promise.all([
|
const [stuRes, reaRes, eveRes] = await Promise.all([
|
||||||
(window as any).api.queryStudents({}),
|
(window as any).api.queryStudents({}),
|
||||||
(window as any).api.queryReasons(),
|
(window as any).api.queryReasons(),
|
||||||
(window as any).api.queryEvents({ limit: 10 }),
|
(window as any).api.queryEvents({ limit: 100 }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (stuRes.success) setStudents(stuRes.data)
|
if (stuRes.success) setStudents(stuRes.data)
|
||||||
@@ -231,12 +240,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<scoreEvent> = [
|
const columns: ColumnsType<scoreEvent> = [
|
||||||
{ title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 100 },
|
{ title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 120 },
|
||||||
{
|
{
|
||||||
title: t("score.change"),
|
title: t("score.change"),
|
||||||
dataIndex: "delta",
|
dataIndex: "delta",
|
||||||
key: "delta",
|
key: "delta",
|
||||||
width: 80,
|
width: 92,
|
||||||
render: (delta: number) => (
|
render: (delta: number) => (
|
||||||
<Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
<Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||||
),
|
),
|
||||||
@@ -245,6 +254,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
title: t("score.reason"),
|
title: t("score.reason"),
|
||||||
dataIndex: "reason_content",
|
dataIndex: "reason_content",
|
||||||
key: "reason_content",
|
key: "reason_content",
|
||||||
|
width: 280,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -257,7 +267,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
{
|
{
|
||||||
title: t("common.operation"),
|
title: t("common.operation"),
|
||||||
key: "operation",
|
key: "operation",
|
||||||
width: 80,
|
width: 96,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}>
|
<Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}>
|
||||||
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
||||||
@@ -274,17 +284,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("score.title")}</h2>
|
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("score.title")}</h2>
|
||||||
|
|
||||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||||
<Form form={form} layout="vertical" initialValues={{ type: "add" }}>
|
<Form form={form} layout="vertical" initialValues={{ type: "add", student_name: [] }}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
||||||
<Form.Item label={t("score.student")} name="student_name">
|
<Form.Item label={t("score.student")}>
|
||||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
<Space orientation="vertical" size={8} style={{ width: "100%" }}>
|
||||||
<Select
|
<Form.Item name="student_name" noStyle>
|
||||||
mode="multiple"
|
<Select
|
||||||
showSearch
|
mode="multiple"
|
||||||
placeholder={t("score.pleaseSelectStudent")}
|
showSearch
|
||||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
placeholder={t("score.pleaseSelectStudent")}
|
||||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||||
/>
|
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -386,8 +398,19 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="uuid"
|
rowKey="uuid"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="small"
|
pagination={{
|
||||||
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
|
current: recordCurrentPage,
|
||||||
|
pageSize: recordPageSize,
|
||||||
|
total: events.length,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => t("common.total", { count: total }),
|
||||||
|
onChange: (page, size) => {
|
||||||
|
setRecordCurrentPage(page)
|
||||||
|
setRecordPageSize(size)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 860 }}
|
||||||
style={{ color: "var(--ss-text-main)" }}
|
style={{ color: "var(--ss-text-main)" }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+40
-40
@@ -18,8 +18,14 @@ import {
|
|||||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||||
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
import { OAuthLogin } from "./OAuth/OAuthLogin"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { pinyin } from "pinyin-pro"
|
||||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||||
import { useResponsive } from "../hooks/useResponsive"
|
import { useResponsive } from "../hooks/useResponsive"
|
||||||
|
import {
|
||||||
|
buildSystemFontFamily,
|
||||||
|
buildSystemFontValue,
|
||||||
|
SYSTEM_FONT_STACK,
|
||||||
|
} from "../shared/fontFamily"
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
@@ -37,46 +43,33 @@ interface FontOption {
|
|||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
fontFamily: string
|
fontFamily: string
|
||||||
|
searchText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_FONT_STACK =
|
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
|
||||||
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
|
||||||
|
|
||||||
|
const toPascalCasePinyin = (value: string): string =>
|
||||||
|
pinyin(value, { toneType: "none" })
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||||
|
.join("")
|
||||||
|
|
||||||
|
const formatFontDisplayName = (name: string): string => {
|
||||||
|
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
|
||||||
|
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFontSearchText = (name: string, label: string): string =>
|
||||||
|
`${name} ${label}`.toLowerCase()
|
||||||
|
|
||||||
const defaultFontOptions: FontOption[] = [
|
const defaultFontOptions: FontOption[] = [
|
||||||
{
|
{
|
||||||
value: "system",
|
value: "system",
|
||||||
label: "系统默认",
|
label: "系统默认",
|
||||||
fontFamily: SYSTEM_FONT_STACK,
|
fontFamily: SYSTEM_FONT_STACK,
|
||||||
},
|
searchText: "系统默认 system default",
|
||||||
{
|
|
||||||
value: "system-Microsoft YaHei UI",
|
|
||||||
label: "Microsoft YaHei UI",
|
|
||||||
fontFamily: '"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system-Microsoft YaHei",
|
|
||||||
label: "Microsoft YaHei",
|
|
||||||
fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system-PingFang SC",
|
|
||||||
label: "PingFang SC",
|
|
||||||
fontFamily: '"PingFang SC", "Hiragino Sans GB", sans-serif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system-Noto Sans CJK SC",
|
|
||||||
label: "Noto Sans CJK SC",
|
|
||||||
fontFamily: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system-Segoe UI",
|
|
||||||
label: "Segoe UI",
|
|
||||||
fontFamily: '"Segoe UI", sans-serif',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system-Arial",
|
|
||||||
label: "Arial",
|
|
||||||
fontFamily: "Arial, sans-serif",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -272,13 +265,17 @@ export const Settings: React.FC<{
|
|||||||
const remoteOptions = res.data
|
const remoteOptions = res.data
|
||||||
.map((name: string) => String(name || "").trim())
|
.map((name: string) => String(name || "").trim())
|
||||||
.filter((name: string) => Boolean(name))
|
.filter((name: string) => Boolean(name))
|
||||||
.map(
|
.map((name: string) => {
|
||||||
(name: string) =>
|
const label = formatFontDisplayName(name)
|
||||||
({
|
return {
|
||||||
value: `system-${name}`,
|
value: buildSystemFontValue(name),
|
||||||
label: name,
|
label,
|
||||||
fontFamily: `"${name}", ${SYSTEM_FONT_STACK}`,
|
fontFamily: buildSystemFontFamily(name),
|
||||||
}) satisfies FontOption
|
searchText: buildFontSearchText(name, label),
|
||||||
|
} satisfies FontOption
|
||||||
|
})
|
||||||
|
.sort((a: FontOption, b: FontOption) =>
|
||||||
|
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
||||||
@@ -829,10 +826,13 @@ export const Settings: React.FC<{
|
|||||||
options={fontOptions.map((opt) => ({
|
options={fontOptions.map((opt) => ({
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
|
searchText: opt.searchText,
|
||||||
}))}
|
}))}
|
||||||
showSearch
|
showSearch
|
||||||
filterOption={(input, option) =>
|
filterOption={(input, option) =>
|
||||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
String(option?.searchText ?? option?.label ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(input.toLowerCase())
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
"leaderboard": "Leaderboard",
|
"leaderboard": "Leaderboard",
|
||||||
"settlements": "Settlements",
|
"settlements": "Settlements",
|
||||||
"reasons": "Reasons",
|
"reasons": "Reasons",
|
||||||
"autoScore": "Auto Score",
|
"autoScore": "Automation",
|
||||||
"rewardExchange": "Reward Exchange",
|
"rewardExchange": "Reward Exchange",
|
||||||
"rewardSettings": "Reward Settings",
|
"rewardSettings": "Reward Settings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -758,7 +758,7 @@
|
|||||||
"name": "Name"
|
"name": "Name"
|
||||||
},
|
},
|
||||||
"autoScore": {
|
"autoScore": {
|
||||||
"title": "Auto Score Management",
|
"title": "Automation Management",
|
||||||
"name": "Automation Name",
|
"name": "Automation Name",
|
||||||
"namePlaceholder": "e.g. Daily Check-in Points",
|
"namePlaceholder": "e.g. Daily Check-in Points",
|
||||||
"nameRequired": "Please enter automation name",
|
"nameRequired": "Please enter automation name",
|
||||||
@@ -772,6 +772,7 @@
|
|||||||
"addTrigger": "Add Rule",
|
"addTrigger": "Add Rule",
|
||||||
"addAction": "Add Action",
|
"addAction": "Add Action",
|
||||||
"whenTriggered": "When the following rules trigger",
|
"whenTriggered": "When the following rules trigger",
|
||||||
|
"triggerScheduleHint": "Note: whether a rule runs is still scheduled by the interval trigger. Tags, SQL, and AND / OR / NOT only decide which students are matched for that run.",
|
||||||
"triggeredActions": "Actions triggered when rules are met",
|
"triggeredActions": "Actions triggered when rules are met",
|
||||||
"addAutomation": "Add Automation",
|
"addAutomation": "Add Automation",
|
||||||
"updateAutomation": "Update Automation",
|
"updateAutomation": "Update Automation",
|
||||||
@@ -785,6 +786,7 @@
|
|||||||
"fetchFailed": "Failed to get automation",
|
"fetchFailed": "Failed to get automation",
|
||||||
"unsupportedLogic": "Saving currently supports AND-only conditions. OR / NOT logic is not wired to backend persistence yet",
|
"unsupportedLogic": "Saving currently supports AND-only conditions. OR / NOT logic is not wired to backend persistence yet",
|
||||||
"triggerRequired": "Please add at least one trigger",
|
"triggerRequired": "Please add at least one trigger",
|
||||||
|
"intervalTriggerRequired": "Please add at least one interval trigger, otherwise the rule will never run automatically",
|
||||||
"actionRequired": "Please add at least one action",
|
"actionRequired": "Please add at least one action",
|
||||||
"createSuccess": "Automation created successfully",
|
"createSuccess": "Automation created successfully",
|
||||||
"updateSuccess": "Automation updated successfully",
|
"updateSuccess": "Automation updated successfully",
|
||||||
@@ -814,12 +816,38 @@
|
|||||||
"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",
|
||||||
|
"triggerStudentScore": "Student Score",
|
||||||
|
"triggerStudentScoreGreater": "Student Score Greater Than",
|
||||||
|
"triggerStudentSql": "Custom SQL Condition",
|
||||||
|
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
|
||||||
"actionAddScore": "Add Score",
|
"actionAddScore": "Add Score",
|
||||||
"actionAddTag": "Add Tag",
|
"actionAddTag": "Add Tag",
|
||||||
"intervalAmountPlaceholder": "Enter interval amount",
|
"actionSettleScore": "Settle Score",
|
||||||
|
"actionSettleScoreHint": "No parameters required",
|
||||||
|
"cooldownMinutes": "Cooldown (minutes)",
|
||||||
|
"maxStudentsPerRun": "Max Students Per Run",
|
||||||
|
"maxRunsPerDay": "Max Runs Per Day",
|
||||||
|
"maxScoreDeltaPerDay": "Max Score Delta Per Day",
|
||||||
|
"filterGroups": "Filter by Group",
|
||||||
|
"filterGrades": "Filter by Grade",
|
||||||
|
"filterMinScore": "Min Current Score",
|
||||||
|
"filterMaxScore": "Max Current Score",
|
||||||
|
"filterRecentEventDays": "Recent Event Days",
|
||||||
|
"filterMinRecentEventCount": "Min Recent Event Count",
|
||||||
|
"batchLogs": "Execution Logs",
|
||||||
|
"batchId": "Batch",
|
||||||
|
"batchRollback": "Rollback",
|
||||||
|
"batchRolledBack": "Rolled Back",
|
||||||
|
"batchRollbackConfirm": "Confirm rollback this execution batch?",
|
||||||
|
"batchRollbackSuccess": "Batch rollback succeeded",
|
||||||
|
"batchRollbackFailed": "Batch rollback failed",
|
||||||
|
"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",
|
||||||
|
"intervalPartDay": "Days",
|
||||||
|
"intervalPartHour": "Hours",
|
||||||
|
"intervalPartMinute": "Minutes",
|
||||||
"minutesPlaceholder": "Enter minutes",
|
"minutesPlaceholder": "Enter minutes",
|
||||||
"minutes": "minutes",
|
"minutes": "minutes",
|
||||||
"tagsPlaceholder": "Enter tag name",
|
"tagsPlaceholder": "Enter tag name",
|
||||||
@@ -827,7 +855,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": {
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
"leaderboard": "排行榜",
|
"leaderboard": "排行榜",
|
||||||
"settlements": "结算历史",
|
"settlements": "结算历史",
|
||||||
"reasons": "理由管理",
|
"reasons": "理由管理",
|
||||||
"autoScore": "自动加分",
|
"autoScore": "自动化",
|
||||||
"rewardExchange": "奖励兑换",
|
"rewardExchange": "奖励兑换",
|
||||||
"rewardSettings": "奖励设置",
|
"rewardSettings": "奖励设置",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
@@ -758,7 +758,7 @@
|
|||||||
"name": "姓名"
|
"name": "姓名"
|
||||||
},
|
},
|
||||||
"autoScore": {
|
"autoScore": {
|
||||||
"title": "自动化加分管理",
|
"title": "自动化管理",
|
||||||
"name": "自动化名称",
|
"name": "自动化名称",
|
||||||
"namePlaceholder": "例如:每日签到加分",
|
"namePlaceholder": "例如:每日签到加分",
|
||||||
"nameRequired": "请输入自动化名称",
|
"nameRequired": "请输入自动化名称",
|
||||||
@@ -772,6 +772,7 @@
|
|||||||
"addTrigger": "添加规则",
|
"addTrigger": "添加规则",
|
||||||
"addAction": "添加行动",
|
"addAction": "添加行动",
|
||||||
"whenTriggered": "当以下规则触发时",
|
"whenTriggered": "当以下规则触发时",
|
||||||
|
"triggerScheduleHint": "提示:规则是否执行仍由“间隔时间”触发器决定,标签、SQL、AND / OR / NOT 仅用于筛选本次命中的学生。",
|
||||||
"triggeredActions": "满足规则时触发的行动",
|
"triggeredActions": "满足规则时触发的行动",
|
||||||
"addAutomation": "添加自动化",
|
"addAutomation": "添加自动化",
|
||||||
"updateAutomation": "更新自动化",
|
"updateAutomation": "更新自动化",
|
||||||
@@ -785,6 +786,7 @@
|
|||||||
"fetchFailed": "获取自动化失败",
|
"fetchFailed": "获取自动化失败",
|
||||||
"unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储",
|
"unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储",
|
||||||
"triggerRequired": "请至少添加一个触发器",
|
"triggerRequired": "请至少添加一个触发器",
|
||||||
|
"intervalTriggerRequired": "请至少添加一个“间隔时间”触发器,否则规则不会自动执行",
|
||||||
"actionRequired": "请至少添加一个行动",
|
"actionRequired": "请至少添加一个行动",
|
||||||
"createSuccess": "自动化创建成功",
|
"createSuccess": "自动化创建成功",
|
||||||
"updateSuccess": "自动化更新成功",
|
"updateSuccess": "自动化更新成功",
|
||||||
@@ -812,20 +814,50 @@
|
|||||||
"operationNoteLabel": "操作说明(可选)",
|
"operationNoteLabel": "操作说明(可选)",
|
||||||
"triggerIntervalTime": "间隔时间",
|
"triggerIntervalTime": "间隔时间",
|
||||||
"triggerStudentTag": "学生标签",
|
"triggerStudentTag": "学生标签",
|
||||||
|
"triggerStudentScore": "学生分数",
|
||||||
|
"triggerStudentScoreGreater": "学生分数大于",
|
||||||
|
"triggerStudentSql": "自定义 SQL 条件",
|
||||||
|
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
|
||||||
"actionAddScore": "加分",
|
"actionAddScore": "加分",
|
||||||
"actionAddTag": "添加标签",
|
"actionAddTag": "添加标签",
|
||||||
"intervalAmountPlaceholder": "请输入间隔数量",
|
"actionSettleScore": "结算分数",
|
||||||
"intervalUnitMonth": "个月后",
|
"actionSettleScoreHint": "无需参数",
|
||||||
"intervalUnitDay": "天后",
|
"cooldownMinutes": "冷却时间(分钟)",
|
||||||
"intervalUnitMinute": "分钟后",
|
"maxStudentsPerRun": "单次最多学生数",
|
||||||
|
"maxRunsPerDay": "每日最多执行次数",
|
||||||
|
"maxScoreDeltaPerDay": "每日最多积分变动",
|
||||||
|
"filterGroups": "按分组筛选",
|
||||||
|
"filterGrades": "按年级筛选",
|
||||||
|
"filterMinScore": "最低当前积分",
|
||||||
|
"filterMaxScore": "最高当前积分",
|
||||||
|
"filterRecentEventDays": "最近事件天数",
|
||||||
|
"filterMinRecentEventCount": "最近事件最少次数",
|
||||||
|
"batchLogs": "执行日志",
|
||||||
|
"batchId": "批次",
|
||||||
|
"batchRollback": "回滚",
|
||||||
|
"batchRolledBack": "已回滚",
|
||||||
|
"batchRollbackConfirm": "确认回滚这个执行批次?",
|
||||||
|
"batchRollbackSuccess": "批次回滚成功",
|
||||||
|
"batchRollbackFailed": "批次回滚失败",
|
||||||
|
"intervalAmountPlaceholder": "请输入间隔时间",
|
||||||
|
"intervalUnitMonth": "个月",
|
||||||
|
"intervalUnitDay": "天",
|
||||||
|
"intervalUnitMinute": "分钟",
|
||||||
|
"intervalPartDay": "天",
|
||||||
|
"intervalPartHour": "小时",
|
||||||
|
"intervalPartMinute": "分钟",
|
||||||
"minutesPlaceholder": "请输入分钟数",
|
"minutesPlaceholder": "请输入分钟数",
|
||||||
"minutes": "分钟",
|
"minutes": "分钟",
|
||||||
"tagsPlaceholder": "请输入标签名称",
|
"tagsPlaceholder": "请输入标签名称",
|
||||||
"scorePlaceholder": "请输入分数",
|
"scorePlaceholder": "请输入分数",
|
||||||
"tagNamePlaceholder": "请输入标签名称",
|
"tagNamePlaceholder": "请输入标签名称",
|
||||||
"reasonPlaceholder": "请输入理由",
|
"reasonPlaceholder": "请输入理由",
|
||||||
"relationAnd": "并且",
|
"relationAnd": "且",
|
||||||
"relationOr": "或者"
|
"relationOr": "或",
|
||||||
|
"operatorContains": "包含",
|
||||||
|
"relationNot": "非",
|
||||||
|
"openFile": "打开文件",
|
||||||
|
"openFileFailed": "打开自动化文件失败"
|
||||||
},
|
},
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"studentTag": {
|
"studentTag": {
|
||||||
|
|||||||
+66
-6
@@ -32,13 +32,36 @@ export interface autoScoreAction {
|
|||||||
value?: string | null
|
value?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface autoScoreExecutionConfig {
|
||||||
|
cooldownMinutes?: number | null
|
||||||
|
maxRunsPerDay?: number | null
|
||||||
|
maxScoreDeltaPerDay?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface autoScoreExecutionBatch {
|
||||||
|
id: string
|
||||||
|
ruleId: number
|
||||||
|
ruleName: string
|
||||||
|
runAt: string
|
||||||
|
affectedStudents: number
|
||||||
|
affectedStudentNames: string[]
|
||||||
|
createdEventIds: number[]
|
||||||
|
addedStudentTagIds: number[]
|
||||||
|
scoreDeltaTotal: number
|
||||||
|
settled: boolean
|
||||||
|
rolledBack: boolean
|
||||||
|
rollbackAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface autoScoreRule {
|
export interface autoScoreRule {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
studentNames: string[]
|
studentNames: string[]
|
||||||
triggers: autoScoreTrigger[]
|
triggers: autoScoreTrigger[]
|
||||||
|
triggerTree?: any | null
|
||||||
actions: autoScoreAction[]
|
actions: autoScoreAction[]
|
||||||
|
execution?: autoScoreExecutionConfig
|
||||||
lastExecuted?: string | null
|
lastExecuted?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +75,7 @@ export type settingsKey =
|
|||||||
| "themes_custom"
|
| "themes_custom"
|
||||||
| "auto_score_enabled"
|
| "auto_score_enabled"
|
||||||
| "auto_score_rules"
|
| "auto_score_rules"
|
||||||
|
| "auto_score_batches"
|
||||||
| "current_theme_id"
|
| "current_theme_id"
|
||||||
| "dashboards_config"
|
| "dashboards_config"
|
||||||
| "pg_connection_string"
|
| "pg_connection_string"
|
||||||
@@ -68,6 +92,7 @@ export interface settingsSpec {
|
|||||||
themes_custom: themeConfig[]
|
themes_custom: themeConfig[]
|
||||||
auto_score_enabled: boolean
|
auto_score_enabled: boolean
|
||||||
auto_score_rules: autoScoreRule[]
|
auto_score_rules: autoScoreRule[]
|
||||||
|
auto_score_batches: autoScoreExecutionBatch[]
|
||||||
current_theme_id: string
|
current_theme_id: string
|
||||||
dashboards_config: any[]
|
dashboards_config: any[]
|
||||||
pg_connection_string: string
|
pg_connection_string: string
|
||||||
@@ -231,17 +256,34 @@ const api = {
|
|||||||
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||||
invoke("event_query", { params }),
|
invoke("event_query", { params }),
|
||||||
createEvent: (data: {
|
createEvent: (data: {
|
||||||
studentName: string
|
student_name?: string
|
||||||
reasonContent: string
|
reason_content?: string
|
||||||
delta: number
|
delta: number
|
||||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
studentName?: string
|
||||||
invoke("event_create", { data }),
|
reasonContent?: string
|
||||||
|
}): Promise<{ success: boolean; data?: number; message?: string }> => {
|
||||||
|
const normalized = {
|
||||||
|
student_name: String(data.student_name ?? data.studentName ?? "").trim(),
|
||||||
|
reason_content: String(data.reason_content ?? data.reasonContent ?? "").trim(),
|
||||||
|
delta: Number(data.delta),
|
||||||
|
}
|
||||||
|
return invoke("event_create", { data: normalized })
|
||||||
|
},
|
||||||
deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }),
|
deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }),
|
||||||
queryEventsByStudent: (params: {
|
queryEventsByStudent: (params: {
|
||||||
studentName: string
|
student_name?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
|
start_time?: string
|
||||||
|
studentName?: string
|
||||||
startTime?: string
|
startTime?: string
|
||||||
}): Promise<{ success: boolean; data: any[] }> => invoke("event_query_by_student", { params }),
|
}): Promise<{ success: boolean; data: any[] }> =>
|
||||||
|
invoke("event_query_by_student", {
|
||||||
|
params: {
|
||||||
|
student_name: String(params.student_name ?? params.studentName ?? "").trim(),
|
||||||
|
limit: params.limit,
|
||||||
|
start_time: params.start_time ?? params.startTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
queryLeaderboard: (params: {
|
queryLeaderboard: (params: {
|
||||||
range: "today" | "week" | "month"
|
range: "today" | "week" | "month"
|
||||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||||
@@ -274,7 +316,9 @@ const api = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
studentNames: string[]
|
studentNames: string[]
|
||||||
triggers: autoScoreTrigger[]
|
triggers: autoScoreTrigger[]
|
||||||
|
triggerTree?: any | null
|
||||||
actions: autoScoreAction[]
|
actions: autoScoreAction[]
|
||||||
|
execution?: autoScoreExecutionConfig
|
||||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||||
invoke("auto_score_add_rule", { rule }),
|
invoke("auto_score_add_rule", { rule }),
|
||||||
autoScoreUpdateRule: (rule: {
|
autoScoreUpdateRule: (rule: {
|
||||||
@@ -283,7 +327,9 @@ const api = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
studentNames: string[]
|
studentNames: string[]
|
||||||
triggers: autoScoreTrigger[]
|
triggers: autoScoreTrigger[]
|
||||||
|
triggerTree?: any | null
|
||||||
actions: autoScoreAction[]
|
actions: autoScoreAction[]
|
||||||
|
execution?: autoScoreExecutionConfig
|
||||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||||
invoke("auto_score_update_rule", { rule }),
|
invoke("auto_score_update_rule", { rule }),
|
||||||
autoScoreDeleteRule: (
|
autoScoreDeleteRule: (
|
||||||
@@ -306,6 +352,15 @@ const api = {
|
|||||||
ruleIds: number[]
|
ruleIds: number[]
|
||||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||||
invoke("auto_score_sort_rules", { ruleIds }),
|
invoke("auto_score_sort_rules", { ruleIds }),
|
||||||
|
autoScoreQueryBatches: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: autoScoreExecutionBatch[]
|
||||||
|
message?: string
|
||||||
|
}> => invoke("auto_score_query_batches"),
|
||||||
|
autoScoreRollbackBatch: (params: {
|
||||||
|
batchId: string
|
||||||
|
}): Promise<{ success: boolean; data?: autoScoreExecutionBatch; message?: string }> =>
|
||||||
|
invoke("auto_score_rollback_batch", { params }),
|
||||||
|
|
||||||
// Settings & Sync
|
// Settings & Sync
|
||||||
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
||||||
@@ -613,6 +668,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<{
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const SYSTEM_FONT_STACK =
|
||||||
|
'"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'
|
||||||
|
|
||||||
|
export const normalizeSystemFontName = (name: string): string =>
|
||||||
|
String(name || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^["']+|["']+$/g, "")
|
||||||
|
|
||||||
|
export const quoteFontFamilyName = (name: string): string =>
|
||||||
|
`"${normalizeSystemFontName(name).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
||||||
|
|
||||||
|
export const buildSystemFontFamily = (name: string): string => {
|
||||||
|
const normalized = normalizeSystemFontName(name)
|
||||||
|
if (!normalized) return SYSTEM_FONT_STACK
|
||||||
|
return `${quoteFontFamilyName(normalized)}, ${SYSTEM_FONT_STACK}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSystemFontValue = (name: string): string => {
|
||||||
|
const normalized = normalizeSystemFontName(name)
|
||||||
|
if (!normalized) return "system"
|
||||||
|
return `system-${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveStoredFontFamily = (fontValue?: string): string => {
|
||||||
|
if (!fontValue || fontValue === "system") return SYSTEM_FONT_STACK
|
||||||
|
if (fontValue.startsWith("system-")) {
|
||||||
|
return buildSystemFontFamily(fontValue.slice("system-".length))
|
||||||
|
}
|
||||||
|
return SYSTEM_FONT_STACK
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user