feat: 自动化加分基本修复完毕 现在是全新的自动化qwqqq!

This commit is contained in:
NanGua-QWQ
2026-04-06 14:41:27 +08:00
parent 448df1a40b
commit ed4891210d
11 changed files with 1259 additions and 394 deletions
Binary file not shown.
+16 -4
View File
@@ -335,7 +335,10 @@ pub async fn oauth_get_authorization_url(
use rand::Rng;
let mut rng = rand::thread_rng();
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes)
base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&random_bytes,
)
});
let url = format!(
@@ -345,7 +348,10 @@ pub async fn oauth_get_authorization_url(
urlencoding::encode(&state)
);
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state }))
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
url,
state,
}))
}
#[tauri::command]
@@ -355,7 +361,10 @@ pub async fn oauth_exchange_code(
platform_secret: String,
callback_url: String,
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url);
println!(
"[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}",
code, platform_id, callback_url
);
let client = reqwest::Client::new();
let params = [
@@ -377,7 +386,10 @@ pub async fn oauth_exchange_code(
println!("[OAuth] 响应状态:{}", response.status());
let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?;
let response_text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
println!("[OAuth] 响应内容:{}", response_text);
if !response_text.is_empty() && response_text.contains("error") {
+53
View File
@@ -3,6 +3,7 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use tauri::State;
@@ -284,3 +285,55 @@ pub async fn fs_file_exists(
Ok(IpcResponse::success(file_path.exists()))
}
#[tauri::command]
pub async fn fs_open_path(
relative_path: String,
folder: String,
_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<()>, String> {
let folder_type = ConfigFolder::from_str(&folder)
.ok_or_else(|| format!("Invalid folder type: {}", folder))?;
let folder_path = ensure_folder_exists(&folder_type)?;
let file_path = folder_path.join(&relative_path);
if !file_path.exists() {
return Ok(IpcResponse::error("File not found"));
}
open_path_with_system(&file_path)?;
Ok(IpcResponse::success_empty())
}
fn open_path_with_system(path: &PathBuf) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "", path.to_string_lossy().as_ref()])
.spawn()
.map_err(|e| e.to_string())?;
return Ok(());
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(path)
.spawn()
.map_err(|e| e.to_string())?;
return Ok(());
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Command::new("xdg-open")
.arg(path)
.spawn()
.map_err(|e| e.to_string())?;
return Ok(());
}
#[allow(unreachable_code)]
Err("Unsupported platform".to_string())
}
+5 -2
View File
@@ -4,8 +4,8 @@ use std::net::SocketAddr;
use std::sync::Arc;
use tauri::Emitter;
use tauri::State;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tokio::sync::Mutex;
use crate::state::AppState;
@@ -103,7 +103,10 @@ async fn handle_oauth_callback(
let error = params.get("error").cloned();
let error_description = params.get("error_description").cloned();
println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error);
println!(
"[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}",
code, state, error
);
let result = OAuthCallbackResult {
code: code.clone(),
+1
View File
@@ -126,6 +126,7 @@ pub fn run() {
fs_delete_file,
fs_list_files,
fs_file_exists,
fs_open_path,
http_server_start,
http_server_stop,
http_server_status,
+712 -36
View File
@@ -1,10 +1,23 @@
use chrono::{DateTime, Months, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, EntityTrait,
QueryFilter, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use serde_json::{json, Value as JsonValue};
use std::collections::HashSet;
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use tokio::time::{interval, Duration};
use uuid::Uuid;
use crate::db::entities::{score_events, student_tags, students, tags};
use crate::services::settings::{SettingsKey, SettingsValue};
use crate::state::SafeAppState;
const DEFAULT_INTERVAL_MINUTES: i64 = 30;
const AUTO_SCORE_TICK_SECONDS: u64 = 15;
const AUTO_SCORE_SQL_LIMIT: u64 = 5000;
const AUTO_SCORE_REASON_PREFIX: &str = "自动化";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AutoScoreTrigger {
@@ -45,6 +58,37 @@ pub struct AutoScoreRule {
pub last_executed: Option<String>,
}
#[derive(Debug, Clone)]
enum PlannedAction {
AddScore(i32),
AddTags(Vec<String>),
}
#[derive(Debug, Default, Clone)]
struct StudentRefs {
ids: HashSet<i32>,
names: HashSet<String>,
}
#[derive(Debug, Default, Clone, Copy)]
struct RuleExecutionStats {
affected_students: usize,
created_events: usize,
added_tags: usize,
}
impl StudentRefs {
fn is_empty(&self) -> bool {
self.ids.is_empty() && self.names.is_empty()
}
fn intersect(mut self, other: StudentRefs) -> StudentRefs {
self.ids.retain(|id| other.ids.contains(id));
self.names.retain(|name| other.names.contains(name));
self
}
}
impl Default for AutoScoreRule {
fn default() -> Self {
Self {
@@ -87,12 +131,14 @@ impl AutoScoreService {
pub async fn initialize(
&mut self,
_app_handle: &AppHandle,
app_handle: &AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
if self.initialized {
return Ok(());
}
self.initialized = true;
Self::spawn_scheduler(app_handle.clone());
Ok(())
}
@@ -211,38 +257,137 @@ impl AutoScoreService {
}
pub fn check_interval_trigger(&self, rule: &AutoScoreRule) -> Option<i64> {
let now = Utc::now();
for trigger in &rule.triggers {
if trigger.event != "interval_time_passed" {
continue;
}
let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or(
IntervalTriggerValue {
amount: DEFAULT_INTERVAL_MINUTES,
unit: IntervalUnit::Minute,
},
);
let base_time = rule
.last_executed
.as_ref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
.unwrap_or(now);
let next_execute_time = add_interval_to_time(base_time, &interval)?;
let delay_ms = (next_execute_time - now).num_milliseconds();
return Some(delay_ms.max(0));
}
None
check_interval_trigger(rule)
}
pub async fn notify_rules_changed(&self, app_handle: &AppHandle) {
let _ = app_handle.emit("auto-score:rulesChanged", &self.rules);
}
fn spawn_scheduler(app_handle: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut ticker = interval(Duration::from_secs(AUTO_SCORE_TICK_SECONDS));
loop {
ticker.tick().await;
if let Err(error) = Self::run_scheduler_tick(&app_handle).await {
eprintln!("auto score scheduler tick failed: {}", error);
}
}
});
}
async fn run_scheduler_tick(app_handle: &AppHandle) -> Result<(), String> {
let state = app_handle.state::<SafeAppState>().inner().clone();
let rules_snapshot = {
let state_guard = state.read();
let auto_score = state_guard.auto_score.read();
auto_score.get_rules().to_vec()
};
if !rules_snapshot.iter().any(|rule| rule.enabled) {
return Ok(());
}
let conn = {
let state_guard = state.read();
let db_conn = state_guard.db.read().clone();
db_conn
}
.ok_or_else(|| "Database not connected".to_string())?;
let mut next_rules = rules_snapshot.clone();
let mut changed = false;
for rule in next_rules.iter_mut().filter(|rule| rule.enabled) {
let Some(delay_ms) = check_interval_trigger(rule) else {
continue;
};
if delay_ms > 0 {
continue;
}
match execute_rule(&conn, rule).await {
Ok(stats) => {
if stats.affected_students == 0 {
Self::log_rule_skipped(&state, rule, "no matched students");
continue;
}
rule.last_executed = Some(Utc::now().to_rfc3339());
changed = true;
Self::log_rule_executed(&state, rule, stats);
}
Err(error) => {
Self::log_rule_failed(&state, rule, &error);
}
}
}
if !changed {
return Ok(());
}
persist_rules_to_settings(&state, &next_rules).await?;
{
let state_guard = state.read();
let mut auto_score = state_guard.auto_score.write();
auto_score.replace_rules(next_rules.clone());
}
let enabled = next_rules.iter().any(|rule| rule.enabled);
let _ = app_handle.emit("auto-score:rulesChanged", &next_rules);
let _ = app_handle.emit(
"settings:changed",
json!({
"key": "auto_score_enabled",
"value": enabled,
}),
);
Ok(())
}
fn log_rule_executed(state: &SafeAppState, rule: &AutoScoreRule, stats: RuleExecutionStats) {
let state_guard = state.read();
let logger = state_guard.logger.read();
logger.info_with_meta(
"auto_score:rule_executed",
json!({
"rule_id": rule.id,
"rule_name": rule.name,
"affected_students": stats.affected_students,
"created_events": stats.created_events,
"added_tags": stats.added_tags,
}),
);
}
fn log_rule_failed(state: &SafeAppState, rule: &AutoScoreRule, error: &str) {
let state_guard = state.read();
let logger = state_guard.logger.read();
logger.warn_with_meta(
"auto_score:rule_failed",
json!({
"rule_id": rule.id,
"rule_name": rule.name,
"error": error,
}),
);
}
fn log_rule_skipped(state: &SafeAppState, rule: &AutoScoreRule, reason: &str) {
let state_guard = state.read();
let logger = state_guard.logger.read();
logger.info_with_meta(
"auto_score:rule_skipped",
json!({
"rule_id": rule.id,
"rule_name": rule.name,
"reason": reason,
}),
);
}
}
fn normalize_rule(mut rule: AutoScoreRule) -> Result<AutoScoreRule, String> {
@@ -296,6 +441,21 @@ fn normalize_trigger(trigger: AutoScoreTrigger) -> Result<AutoScoreTrigger, Stri
value: Some(value),
})
}
"query_sql" | "student_query_sql" | "student_sql" => {
let raw_sql = trigger
.value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "SQL trigger requires non-empty SQL".to_string())?;
if !is_valid_auto_score_sql(&raw_sql) {
return Err("Only read-only SQL expression/query is allowed".to_string());
}
Ok(AutoScoreTrigger {
event,
value: Some(raw_sql),
})
}
_ => Err(format!("Unsupported trigger event: {}", event)),
}
}
@@ -305,8 +465,8 @@ fn normalize_action(action: AutoScoreAction) -> Result<AutoScoreAction, String>
match event.as_str() {
"add_score" => {
let value = normalize_non_zero_numeric_string(action.value.as_deref())
.ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?;
let value = normalize_non_zero_integer_string(action.value.as_deref())
.ok_or_else(|| "Add score action requires a non-zero integer value".to_string())?;
Ok(AutoScoreAction {
event,
@@ -384,18 +544,18 @@ fn stringify_tag_values(values: &[String]) -> Option<String> {
serde_json::to_string(values).ok()
}
fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option<String> {
fn normalize_non_zero_integer_string(value: Option<&str>) -> Option<String> {
let normalized = value?.trim();
if normalized.is_empty() {
return None;
}
let parsed = normalized.parse::<f64>().ok()?;
if !parsed.is_finite() || parsed == 0.0 {
let parsed = normalized.parse::<i32>().ok()?;
if parsed == 0 {
return None;
}
Some(normalized.to_string())
Some(parsed.to_string())
}
fn normalize_last_executed(value: Option<String>) -> Option<String> {
@@ -454,3 +614,519 @@ fn add_interval_to_time(
}
}
}
fn check_interval_trigger(rule: &AutoScoreRule) -> Option<i64> {
let now = Utc::now();
for trigger in &rule.triggers {
if trigger.event != "interval_time_passed" {
continue;
}
let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or(
IntervalTriggerValue {
amount: DEFAULT_INTERVAL_MINUTES,
unit: IntervalUnit::Minute,
},
);
let Some(base_time) = rule
.last_executed
.as_ref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc))
else {
return Some(0);
};
let next_execute_time = add_interval_to_time(base_time, &interval)?;
let delay_ms = (next_execute_time - now).num_milliseconds();
return Some(delay_ms.max(0));
}
None
}
fn contains_forbidden_keyword(sql: &str) -> bool {
let forbidden: HashSet<&str> = [
"insert", "update", "delete", "drop", "alter", "create", "truncate", "reindex", "vacuum",
"grant", "revoke", "commit", "rollback", "begin", "attach", "detach", "pragma", "analyze",
"merge", "call", "execute",
]
.into_iter()
.collect();
let mut token = String::new();
for ch in sql.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
token.push(ch);
continue;
}
if !token.is_empty() {
if forbidden.contains(token.as_str()) {
return true;
}
token.clear();
}
}
!token.is_empty() && forbidden.contains(token.as_str())
}
fn starts_with_select_or_with(sql: &str) -> bool {
sql.split_whitespace()
.next()
.map(|first| {
let first_lower = first.to_ascii_lowercase();
first_lower == "select" || first_lower == "with"
})
.unwrap_or(false)
}
fn has_sql_comment_or_multi_statement(sql: &str) -> bool {
sql.contains(';') || sql.contains("--") || sql.contains("/*") || sql.contains("*/")
}
fn is_readonly_query(sql: &str) -> bool {
let trimmed = sql.trim();
if trimmed.is_empty() {
return false;
}
if has_sql_comment_or_multi_statement(trimmed) {
return false;
}
if !starts_with_select_or_with(trimmed) {
return false;
}
!contains_forbidden_keyword(&trimmed.to_ascii_lowercase())
}
fn is_safe_sql_expression(sql: &str) -> bool {
let trimmed = sql.trim();
if trimmed.is_empty() {
return false;
}
if has_sql_comment_or_multi_statement(trimmed) {
return false;
}
!contains_forbidden_keyword(&trimmed.to_ascii_lowercase())
}
fn is_valid_auto_score_sql(sql: &str) -> bool {
let trimmed = sql.trim();
if trimmed.is_empty() {
return false;
}
if starts_with_select_or_with(trimmed) {
is_readonly_query(trimmed)
} else {
is_safe_sql_expression(trimmed)
}
}
fn build_readonly_student_query(sql_or_expression: &str) -> Result<String, String> {
let trimmed = sql_or_expression.trim();
if trimmed.is_empty() {
return Err("Empty SQL".to_string());
}
let normalized = if starts_with_select_or_with(trimmed) {
if !is_readonly_query(trimmed) {
return Err("Only read-only SELECT/CTE query is allowed".to_string());
}
trimmed.to_string()
} else {
if !is_safe_sql_expression(trimmed) {
return Err("Only safe SQL expression is allowed".to_string());
}
format!("SELECT id, name FROM students WHERE ({})", trimmed)
};
Ok(format!(
"SELECT * FROM ({}) AS ss_auto_score_query LIMIT {}",
normalized, AUTO_SCORE_SQL_LIMIT
))
}
async fn persist_rules_to_settings(
state: &SafeAppState,
rules: &[AutoScoreRule],
) -> Result<(), String> {
let rules_json = AutoScoreService::serialize_rules(rules)?;
let enabled = rules.iter().any(|rule| rule.enabled);
let state_guard = state.read();
let db_conn = state_guard.db.read().clone();
let mut settings = state_guard.settings.write();
settings.attach_db(db_conn);
settings.initialize().await?;
settings
.set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json))
.await?;
settings
.set_value(
SettingsKey::AutoScoreEnabled,
SettingsValue::Boolean(enabled),
)
.await?;
Ok(())
}
async fn execute_rule(
conn: &DatabaseConnection,
rule: &AutoScoreRule,
) -> Result<RuleExecutionStats, String> {
let mut target_students = resolve_target_students(conn, rule).await?;
if !rule.student_names.is_empty() {
let whitelist: HashSet<String> = rule
.student_names
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect();
target_students.retain(|student| whitelist.contains(&student.name));
}
if target_students.is_empty() {
return Ok(RuleExecutionStats::default());
}
let planned_actions = plan_actions(&rule.actions)?;
if planned_actions.is_empty() {
return Err("No executable action".to_string());
}
let txn = conn.begin().await.map_err(|e| e.to_string())?;
let mut stats = RuleExecutionStats {
affected_students: target_students.len(),
..RuleExecutionStats::default()
};
let mut all_action_tags = Vec::new();
for action in &planned_actions {
if let PlannedAction::AddTags(tags) = action {
all_action_tags.extend(tags.iter().cloned());
}
}
let all_action_tags = dedupe_trimmed_strings(all_action_tags);
let mut tag_name_to_id = std::collections::HashMap::new();
for tag_name in all_action_tags {
let tag_id = ensure_tag_exists_in_txn(&txn, &tag_name).await?;
tag_name_to_id.insert(tag_name, tag_id);
}
for mut student in target_students {
for action in &planned_actions {
match action {
PlannedAction::AddScore(delta) => {
let now = now_iso();
let val_prev = student.score;
let val_curr = val_prev + delta;
let reward_points = student.reward_points + delta;
let event = score_events::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
uuid: Set(Uuid::new_v4().to_string()),
student_name: Set(student.name.clone()),
reason_content: Set(build_auto_score_reason(&rule.name, *delta)),
delta: Set(*delta),
val_prev: Set(val_prev),
val_curr: Set(val_curr),
event_time: Set(now.clone()),
settlement_id: Set(None),
};
event.insert(&txn).await.map_err(|e| e.to_string())?;
stats.created_events += 1;
let mut student_active: students::ActiveModel = student.clone().into();
student_active.score = Set(val_curr);
student_active.reward_points = Set(reward_points);
student_active.updated_at = Set(now);
student_active
.update(&txn)
.await
.map_err(|e| e.to_string())?;
student.score = val_curr;
student.reward_points = reward_points;
}
PlannedAction::AddTags(tag_names) => {
for tag_name in tag_names {
let Some(tag_id) = tag_name_to_id.get(tag_name).copied() else {
continue;
};
let inserted =
attach_tag_to_student_if_missing(&txn, student.id, tag_id).await?;
if inserted {
stats.added_tags += 1;
}
}
}
}
}
}
txn.commit().await.map_err(|e| e.to_string())?;
Ok(stats)
}
fn plan_actions(actions: &[AutoScoreAction]) -> Result<Vec<PlannedAction>, String> {
let mut planned = Vec::new();
for action in actions {
match action.event.as_str() {
"add_score" => {
let delta = action
.value
.as_deref()
.and_then(|value| value.trim().parse::<i32>().ok())
.filter(|value| *value != 0)
.ok_or_else(|| "Invalid add_score value".to_string())?;
planned.push(PlannedAction::AddScore(delta));
}
"add_tag" => {
let tags = parse_tag_values(action.value.as_deref());
if tags.is_empty() {
return Err("Invalid add_tag value".to_string());
}
planned.push(PlannedAction::AddTags(tags));
}
_ => {}
}
}
Ok(planned)
}
async fn resolve_target_students(
conn: &DatabaseConnection,
rule: &AutoScoreRule,
) -> Result<Vec<students::Model>, String> {
let explicit_sql_values: Vec<String> = rule
.triggers
.iter()
.filter(|trigger| {
matches!(
trigger.event.as_str(),
"query_sql" | "student_query_sql" | "student_sql"
)
})
.filter_map(|trigger| trigger.value.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect();
if !explicit_sql_values.is_empty() {
let mut refs: Option<StudentRefs> = None;
for sql_value in explicit_sql_values {
let next_refs = query_student_refs_by_sql(conn, &sql_value).await?;
refs = Some(match refs {
None => next_refs,
Some(existing) => existing.intersect(next_refs),
});
}
let refs = refs.unwrap_or_default();
if refs.is_empty() {
return Ok(vec![]);
}
return load_students_from_refs(conn, refs).await;
}
let tag_filters: Vec<String> = rule
.triggers
.iter()
.filter(|trigger| trigger.event == "student_has_tag")
.flat_map(|trigger| parse_tag_values(trigger.value.as_deref()))
.collect();
let tag_filters = dedupe_trimmed_strings(tag_filters);
if tag_filters.is_empty() {
return students::Entity::find()
.all(conn)
.await
.map_err(|e| e.to_string());
}
let tag_models = tags::Entity::find()
.filter(tags::Column::Name.is_in(tag_filters))
.all(conn)
.await
.map_err(|e| e.to_string())?;
if tag_models.is_empty() {
return Ok(vec![]);
}
let tag_ids: Vec<i32> = tag_models.iter().map(|tag| tag.id).collect();
let links = student_tags::Entity::find()
.filter(student_tags::Column::TagId.is_in(tag_ids))
.all(conn)
.await
.map_err(|e| e.to_string())?;
if links.is_empty() {
return Ok(vec![]);
}
let student_ids: HashSet<i32> = links.iter().map(|link| link.student_id).collect();
students::Entity::find()
.filter(students::Column::Id.is_in(student_ids))
.all(conn)
.await
.map_err(|e| e.to_string())
}
async fn query_student_refs_by_sql(
conn: &DatabaseConnection,
sql_or_expression: &str,
) -> Result<StudentRefs, String> {
let wrapped_sql = build_readonly_student_query(sql_or_expression)?;
let rows = conn
.query_all(Statement::from_string(
conn.get_database_backend(),
wrapped_sql,
))
.await
.map_err(|e| e.to_string())?;
let mut refs = StudentRefs::default();
for row in &rows {
if let Some(id) = try_get_i32(row, "id").or_else(|| try_get_i32(row, "student_id")) {
refs.ids.insert(id);
}
if let Some(name) =
try_get_string(row, "name").or_else(|| try_get_string(row, "student_name"))
{
refs.names.insert(name);
}
}
Ok(refs)
}
async fn load_students_from_refs(
conn: &DatabaseConnection,
refs: StudentRefs,
) -> Result<Vec<students::Model>, String> {
if refs.is_empty() {
return Ok(vec![]);
}
let mut condition = Condition::any();
if !refs.ids.is_empty() {
condition = condition.add(students::Column::Id.is_in(refs.ids));
}
if !refs.names.is_empty() {
condition = condition.add(students::Column::Name.is_in(refs.names));
}
students::Entity::find()
.filter(condition)
.all(conn)
.await
.map_err(|e| e.to_string())
}
fn try_get_i32(row: &sea_orm::QueryResult, column: &str) -> Option<i32> {
row.try_get::<i32>("", column)
.ok()
.or_else(|| {
row.try_get::<i64>("", column)
.ok()
.and_then(|value| i32::try_from(value).ok())
})
.or_else(|| {
row.try_get::<String>("", column)
.ok()
.and_then(|value| value.trim().parse::<i32>().ok())
})
}
fn try_get_string(row: &sea_orm::QueryResult, column: &str) -> Option<String> {
row.try_get::<String>("", column).ok().and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
async fn ensure_tag_exists_in_txn(
txn: &sea_orm::DatabaseTransaction,
tag_name: &str,
) -> Result<i32, String> {
if let Some(existing) = tags::Entity::find()
.filter(tags::Column::Name.eq(tag_name))
.one(txn)
.await
.map_err(|e| e.to_string())?
{
return Ok(existing.id);
}
let now = now_iso();
let inserted = tags::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
name: Set(tag_name.to_string()),
created_at: Set(now.clone()),
updated_at: Set(now),
}
.insert(txn)
.await
.map_err(|e| e.to_string())?;
Ok(inserted.id)
}
async fn attach_tag_to_student_if_missing(
txn: &sea_orm::DatabaseTransaction,
student_id: i32,
tag_id: i32,
) -> Result<bool, String> {
let existing = student_tags::Entity::find()
.filter(student_tags::Column::StudentId.eq(student_id))
.filter(student_tags::Column::TagId.eq(tag_id))
.one(txn)
.await
.map_err(|e| e.to_string())?;
if existing.is_some() {
return Ok(false);
}
let now = now_iso();
student_tags::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
student_id: Set(student_id),
tag_id: Set(tag_id),
created_at: Set(now),
}
.insert(txn)
.await
.map_err(|e| e.to_string())?;
Ok(true)
}
fn now_iso() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
fn build_auto_score_reason(rule_name: &str, delta: i32) -> String {
if delta > 0 {
format!("{}: {} (+{})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
} else {
format!("{}: {} ({})", AUTO_SCORE_REASON_PREFIX, rule_name, delta)
}
}
@@ -51,6 +51,7 @@ export type ActionDraftError = "action_required" | "score_required" | "tag_requi
const TRIGGER_FIELD_INTERVAL = "interval_minutes"
const TRIGGER_FIELD_TAG = "student_tag"
const TRIGGER_FIELD_SQL = "student_sql"
const TRIGGER_TYPE_INTERVAL = "interval_duration"
const TRIGGER_WIDGET_INTERVAL = "interval_duration"
const OP_EQUAL = "equal"
@@ -146,6 +147,24 @@ const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => {
}
}
if (
trigger.event === "query_sql" ||
trigger.event === "student_query_sql" ||
trigger.event === "student_sql"
) {
const sql = toStringValue(trigger.value).trim()
if (!sql) return null
return {
id: QbUtils.uuid(),
type: "rule",
properties: {
field: TRIGGER_FIELD_SQL,
operator: OP_EQUAL,
value: [sql],
},
}
}
return null
}
@@ -175,6 +194,15 @@ const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => {
}
}
if (field === TRIGGER_FIELD_SQL) {
const sql = toStringValue(value).trim()
if (!sql) return null
return {
event: "query_sql",
value: sql,
}
}
return null
}
@@ -197,6 +225,25 @@ const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigg
export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagOption[]): Config =>
({
...AntdConfig,
conjunctions: {
...AntdConfig.conjunctions,
AND: {
...AntdConfig.conjunctions.AND,
label: t("autoScore.relationAnd"),
},
OR: {
...AntdConfig.conjunctions.OR,
label: t("autoScore.relationOr"),
},
},
operators: {
...AntdConfig.operators,
[OP_MULTISELECT_CONTAINS]: {
...AntdConfig.operators[OP_MULTISELECT_CONTAINS],
label: t("autoScore.operatorContains"),
labelForFormat: t("autoScore.operatorContains"),
},
},
widgets: {
...AntdConfig.widgets,
[TRIGGER_WIDGET_INTERVAL]: {
@@ -250,6 +297,15 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
showSearch: true,
},
},
[TRIGGER_FIELD_SQL]: {
label: t("autoScore.triggerStudentSql"),
type: "text",
operators: [OP_EQUAL],
valueSources: ["value"],
fieldSettings: {
placeholder: t("autoScore.triggerStudentSqlPlaceholder"),
},
},
},
settings: {
...AntdConfig.settings,
@@ -257,6 +313,7 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
compactMode: false,
renderSize: "medium",
showNot: true,
notLabel: t("autoScore.relationNot"),
forceShowConj: true,
canLeaveEmptyGroup: false,
canReorder: true,
@@ -267,6 +324,9 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
export const normalizeTriggerTree = (tree: ImmutableTree, config: Config): ImmutableTree =>
QbUtils.checkTree(tree, config)
export const triggersToQueryTree = (
config: Config,
triggers: AutoScoreTrigger[]
+46 -3
View File
@@ -27,6 +27,7 @@ import {
createEmptyTriggerTree,
createTriggerQueryConfig,
hasUnsupportedTriggerLogic,
normalizeTriggerTree,
normalizeActionDrafts,
queryTreeToTriggers,
triggersToQueryTree,
@@ -54,6 +55,8 @@ interface AutoScoreManagerProps {
canEdit: boolean
}
const getRuleFileRelativePath = (ruleId: number) => `auto-score/rule-${ruleId}.json`
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
const { t, i18n } = useTranslation()
const [form] = Form.useForm<RuleFormValues>()
@@ -92,6 +95,10 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
}
}, [currentPage, pageSize, rules.length])
useEffect(() => {
setTriggerTree((prevTree) => normalizeTriggerTree(prevTree, triggerConfig))
}, [triggerConfig])
const resetEditor = () => {
setEditingRuleId(null)
form.setFieldsValue({ name: "", studentNames: [] })
@@ -262,6 +269,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
const res = await api.autoScoreDeleteRule(ruleId)
if (res.success) {
await api.fsDeleteFile(getRuleFileRelativePath(ruleId), "automatic").catch(() => void 0)
messageApi.success(t("autoScore.deleteSuccess"))
if (editingRuleId === ruleId) {
resetEditor()
@@ -295,6 +303,38 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
}
}
const handleOpenRuleFile = async (rule: AutoScoreRule) => {
const api = (window as any).api
if (!api) return
try {
const relativePath = getRuleFileRelativePath(rule.id)
const fileContent = {
id: rule.id,
name: rule.name,
enabled: rule.enabled,
studentNames: rule.studentNames,
triggers: rule.triggers,
actions: rule.actions,
lastExecuted: rule.lastExecuted ?? null,
exportedAt: new Date().toISOString(),
}
const writeRes = await api.fsWriteJson(relativePath, fileContent, "automatic")
if (!writeRes?.success) {
throw new Error("prepare rule file failed")
}
const openRes = await api.fsOpenPath(relativePath, "automatic")
if (!openRes?.success) {
throw new Error(openRes?.message || "open rule file failed")
}
} catch {
messageApi.error(t("autoScore.openFileFailed"))
}
}
const formatLastExecuted = (value?: string | null) => {
if (!value) return t("autoScore.notExecuted")
const date = new Date(value)
@@ -358,12 +398,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
{
title: t("common.operation"),
key: "operation",
width: 140,
width: 220,
render: (_, row) => (
<Space size={4}>
<Button type="link" disabled={!canEdit} onClick={() => handleEdit(row)}>
{t("common.edit")}
</Button>
<Button type="link" onClick={() => handleOpenRuleFile(row).catch(() => void 0)}>
{t("autoScore.openFile")}
</Button>
<Popconfirm
title={t("autoScore.deleteConfirm")}
onConfirm={() => {
@@ -391,7 +434,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
type="warning"
showIcon
style={{ marginBottom: "16px" }}
message={t("autoScore.adminRequired")}
title={t("autoScore.adminRequired")}
/>
)}
@@ -403,7 +446,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
name="name"
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
>
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} />
<Input placeholder={t("autoScore.namePlaceholder")} disabled={!canEdit} autoComplete="off"/>
</Form.Item>
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
<Select
+8 -2
View File
@@ -814,9 +814,11 @@
"tagNamesPlaceholder": "e.g. excellent_student,class_monitor",
"triggerIntervalTime": "Interval Time",
"triggerStudentTag": "Student Tag",
"triggerStudentSql": "Student SQL",
"triggerStudentSqlPlaceholder": "Enter student SQL or a WHERE condition",
"actionAddScore": "Add Score",
"actionAddTag": "Add Tag",
"intervalAmountPlaceholder": "Enter interval amount",
"intervalAmountPlaceholder": "Enter interval time",
"intervalUnitMonth": "month(s) later",
"intervalUnitDay": "day(s) later",
"intervalUnitMinute": "minute(s) later",
@@ -827,7 +829,11 @@
"tagNamePlaceholder": "Enter tag name",
"reasonPlaceholder": "Enter reason",
"relationAnd": "AND",
"relationOr": "OR"
"relationOr": "OR",
"operatorContains": "Contains",
"relationNot": "Not",
"openFile": "Open File",
"openFileFailed": "Failed to open automation file"
},
"triggers": {
"studentTag": {
+9 -3
View File
@@ -812,9 +812,11 @@
"operationNoteLabel": "操作说明(可选)",
"triggerIntervalTime": "间隔时间",
"triggerStudentTag": "学生标签",
"triggerStudentSql": "学生 SQL 条件",
"triggerStudentSqlPlaceholder": "输入筛选学生的 SQL 或 WHERE 条件",
"actionAddScore": "加分",
"actionAddTag": "添加标签",
"intervalAmountPlaceholder": "请输入间隔数量",
"intervalAmountPlaceholder": "请输入间隔时间数值",
"intervalUnitMonth": "个月后",
"intervalUnitDay": "天后",
"intervalUnitMinute": "分钟后",
@@ -824,8 +826,12 @@
"scorePlaceholder": "请输入分数",
"tagNamePlaceholder": "请输入标签名称",
"reasonPlaceholder": "请输入理由",
"relationAnd": "且",
"relationOr": "或"
"relationAnd": "且",
"relationOr": "或",
"operatorContains": "包含",
"relationNot": "非",
"openFile": "打开文件",
"openFileFailed": "打开自动化文件失败"
},
"triggers": {
"studentTag": {
+5
View File
@@ -613,6 +613,11 @@ const api = {
folder?: "automatic" | "script"
): Promise<{ success: boolean; data: boolean }> =>
invoke("fs_file_exists", { relativePath, folder }),
fsOpenPath: (
relativePath: string,
folder?: "automatic" | "script"
): Promise<{ success: boolean; message?: string }> =>
invoke("fs_open_path", { relativePath, folder }),
// App
registerUrlProtocol: (): Promise<{